Merge origin/master with current Pierzainer development

Preserve local abutment, cast-in-place rebar and compact header features alongside remote drawing, ground-link and quantity workspaces. Adapt drawing and takeoff consumers to generated solplate plans, retain current P1 regression coverage, and resolve bearing-detail annotation collisions.

Validation: 122 test files / 975 tests passed; production build passed; 10 browser integration checks passed.
This commit is contained in:
ypshim committed 2026-11-06 17:10:33 +09:00
commit f51865c57b
2645 files changed
+64918 -4910

No files matched your search

+36 -26
View File
@@ -1,4 +1,6 @@
import './style.css';
import { createDrawingWorkspace } from '../도면작성/workspace';
import { createTakeoffWorkspace } from '../수량산출/workspace';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { swapXYAzimuth } from './core/coordinate-frame';
@@ -145,8 +147,8 @@ interface Pier extends PierValues {
}
/** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */
/** The stages: ① 형상 생성, ② 형상계획, ③ 철근배근. 'quantity' is the 철근재료표 screen opened from ③ (시안: header ①②③, 2026-11-06). */
type Stage = 'shape' | 'plan' | 'rebar' | 'quantity';
/** ① 형상 생성, ② 형상계획, ③ 철근배근, ④ 도면작성 (origin). 'quantity' is the 재료표 · 수량산출 screen opened from ③ (시안: no header step, 2026-11-06). */
type Stage = 'shape' | 'plan' | 'rebar' | 'drawing' | 'quantity';
/** 부재 표시 toggles (2026-11-03). `pier` parts follow the pier scenes; `abut` names follow the abutment groups (abut-scene.ts). */
const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [
@@ -279,6 +281,21 @@ let backupTimer: number | undefined;
const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
const drawingWorkspace = createDrawingWorkspace($('view-drawing'), () => ({
project: state.project, selected: state.active,
piers: state.piers.map(p => ({ input: p.input, model: p.model, dirty: pendingKeys(p).length > 0 })),
}), selectPier);
const quantityRebarTon = new Map<string,number|null>();
const quantityWorkspace = createTakeoffWorkspace($('view-quantity'), () => ({
project: state.project, selected: state.active,
piers: state.piers.map(p => ({input:p.input,model:p.model,dirty:pendingKeys(p).length>0,rebarTon:quantityRebarTon.get(p.input.name)})),
}), selectPier, (name,data) => {
const pier=state.piers.find(p=>p.input.name===name);if(!pier)return;
pier.input={...pier.input,quantityTakeoff:data};pier.draft={...pier.draft,quantityTakeoff:data};
renderSaveStatus();scheduleBackup();
});
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
@@ -2046,6 +2063,7 @@ function renderHeader(): void {
$('project-line').textContent = parts.join(' · ');
$('project-line').title = parts.join(' · ');
setHeaderStatus('step1-status', '', pier?.model ? '생성됨' : '미생성');
setHeaderStatus('step-drawing-status', '', pier?.model ? '가능' : '형상 필요');
renderSaveStatus();
const total = pendingTotal();
const targets = generationTargets();
@@ -2214,6 +2232,7 @@ function renderAll(): void {
renderReviewTasks();
renderRebarColor();
if (state.stage === 'quantity') renderQuantityScreen();
if (state.stage === 'drawing') drawingWorkspace.update();
scheduleBackup();
}
@@ -2686,20 +2705,22 @@ function setStage(stage: Stage): void {
// Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout.
// Entering or leaving ② or ③ changes which objects are drawn (visibility.ts), so the scene is rebuilt then.
const selectionOnly = (s: Stage) => s === 'plan' || s === 'rebar';
if ((allPiers !== null) !== multiSceneOn() || selectionOnly(stage) !== selectionOnly(previous) || (stage === 'rebar') !== (previous === 'rebar')) setupScene();
// ④ 도면작성 hides the 3D view, so the scene is not rebuilt for it (origin).
if (stage !== 'drawing' && ((allPiers !== null) !== multiSceneOn() || selectionOnly(stage) !== selectionOnly(previous) || (stage === 'rebar') !== (previous === 'rebar'))) setupScene();
if (state.scene) setRebarView(state.scene, stage === 'rebar');
for (const g of abutGroups) setRebarViewOn(g, stage === 'rebar');
$<HTMLElement>('view-shape').hidden = stage !== 'shape';
$<HTMLElement>('view-plan').hidden = stage !== 'plan';
$<HTMLElement>('view-rebar').hidden = stage !== 'rebar';
$<HTMLElement>('view-quantity').hidden = stage !== 'quantity';
$<HTMLElement>('view-drawing').hidden = stage !== 'drawing';
if (stage === 'quantity') renderQuantityScreen();
// Move the viewer after its new parent is visible, so that resize() measures the real size.
placeViewer(stage);
if (rebar3d) rebar3d.group.visible = stage === 'rebar';
// The highlight is the 'current' class (style.css); aria-current is set alongside it for assistive tech.
// The 재료표 screen belongs to ③, so ③ stays highlighted while it is open.
const nav: [string, Stage[]][] = [['nav-shape', ['shape']], ['nav-plan', ['plan']], ['nav-rebar', ['rebar', 'quantity']]];
const nav: [string, Stage[]][] = [['nav-shape', ['shape']], ['nav-plan', ['plan']], ['nav-rebar', ['rebar', 'quantity']], ['nav-drawing', ['drawing']]];
for (const [id, keys] of nav) {
const btn = $<HTMLButtonElement>(id);
btn.classList.toggle('current', keys.includes(stage));
@@ -4605,6 +4626,9 @@ function renderQuantityScreen(): void {
const entries = targets.flatMap((pier) =>
pier.model ? [{ pier, q: pierQuantity(pier) }] : [],
);
quantityRebarTon.clear();
for(const {pier,q} of entries)quantityRebarTon.set(pier.input.name,q.missing.length?null:q.kg/1000);
quantityWorkspace.refresh();
// 전체 with more than one pier: the bridge total first (교량 전체 집계); the per-pier tables follow.
if (state.quantityAll && entries.length > 1) body.appendChild(bridgeTotalBlock(aggregateQuantities(entries.map((e) => e.q)), entries.length));
for (const { pier, q } of entries) {
@@ -5105,7 +5129,12 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
importInput.value = '';
}
});
const importLabel = el('label', '지반 모델 파일 불러오기 (json)');
const groundLaunch = el('button', '지반 모델 파일 불러오기 · 지반 생성 프로그램');
groundLaunch.type = 'button';groundLaunch.className = 'btn';
groundLaunch.addEventListener('click', () => window.dispatchEvent(new Event('pierzainer:open-ground')));
wrap.appendChild(groundLaunch);
const linkStatus = el('p', '현재 Excel을 사용하여 종평면도에서 지반을 생성합니다.', 'muted small');linkStatus.id='ground-link-status';wrap.appendChild(linkStatus);
const importLabel = el('label', '기존 지반 JSON 직접 불러오기');
importLabel.appendChild(importInput);
wrap.appendChild(importLabel);
@@ -6016,16 +6045,8 @@ function applyImport(): void {
setStatus(`${state.piers.length}기 형상을 생성했습니다.${abutNote} 정보·경고는 오른쪽 알림에서 확인하세요.`);
}
function openImport(): void {
const landing = document.querySelector<HTMLDivElement>('.landing-overlay');
if (landing && !landing.hidden) return;
const dlg = $<HTMLDialogElement>('import-dialog');
if (!dlg.open) dlg.showModal();
$<HTMLInputElement>('project-name').focus();
}
async function fetchSample(): Promise<Uint8Array> {
const res = await fetch(new URL('samples/운곡교P1입력.xlsx', document.baseURI));
const res = await fetch(new URL('../도면추출/samples/운곡교P1입력.xlsx', import.meta.url));
if (!res.ok) throw new Error(`샘플 응답 ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}
@@ -6034,7 +6055,6 @@ declare global {
interface Window {
loadBytesToApp?: (bytes: Uint8Array, fileName: string) => void;
openSampleProject?: () => Promise<void>;
openDrawingFlow?: () => void;
}
}
@@ -6056,16 +6076,6 @@ window.openSampleProject = async () => {
setError(error instanceof Error ? error.message : '샘플 교량 모델을 불러오지 못했습니다.');
}
};
window.openDrawingFlow = () => {
const app = document.querySelector<HTMLElement>('.app');
const landing = document.querySelector<HTMLDivElement>('.landing-overlay');
if (app) {
app.hidden = false;
app.setAttribute('aria-hidden', 'false');
}
if (landing) landing.hidden = true;
setStatus('도면 입력은 현재 개발 중입니다. 기존 도면 분석 모듈과 연결되기 전까지는 파일 선택 흐름만 유지합니다.');
};
/** Piers that need a (re)build: never generated, or with changes not yet in the 3D model. */
function generationTargets(): Pier[] {
@@ -6087,7 +6097,6 @@ function regenerateChanged(): void {
}
// --- events -----------------------------------------------------------------------------
$<HTMLButtonElement>('open-import').addEventListener('click', openImport);
$<HTMLButtonElement>('dlg-close').addEventListener('click', () => {
$<HTMLDialogElement>('import-dialog').close();
document.querySelector<HTMLElement>('.landing-overlay')?.classList.remove('modal-open');
@@ -6102,6 +6111,7 @@ $<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shap
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
$<HTMLButtonElement>('quantity-back').addEventListener('click', () => setStage('rebar'));
$<HTMLButtonElement>('nav-drawing').addEventListener('click', () => setStage('drawing'));
$<HTMLInputElement>('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value));
$<HTMLInputElement>('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value));
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {