import './style.css'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { swapXYAzimuth } from './core/coordinate-frame'; import * as XLSX from 'xlsx'; import { parseWorkbook, type SheetReport } from './core/parseWorkbook'; import { parseAbutments, toAbutInput, type AbutRecord } from './core/abut/parse'; import { buildAbut } from './core/abut/geometry'; import { structureOrder } from './core/abut/order'; import { ABUT_DETAIL_LABELS, abutInputRows, abutSummaryRows } from './core/abut/summary'; import { applyAbutEdit, applyBearingEdit, applyShapeEdit, ABUT_EDIT_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit'; import { ABUT_SHAPE_DEFAULT, resolveShape } from './core/abut/shape'; import { abutShown, piersShown } from './core/abut/visibility'; import { abutMemberRows, ABUT_MEMBER_EDITABLE, type AbutMemberId } from './core/abut/member-list'; import { abutSectionSvg } from './view/abut-section-svg'; import type { WingForm } from './core/abut/geometry'; import { buildMembers } from './core/abut/members'; import { buildWings } from './core/abut/wings'; import { buildAbutGroup, cameraForBox } from './view/abut-scene'; import { fitCamera } from './view/fit'; import { buildPier, type PierModel } from './core/geometry'; import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate'; import { buildTemplateWorkbook } from './core/template'; import { EDITABLE_FIELDS, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes'; import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from './core/piles'; import { applyCopy, COPY_ITEMS, planCopy, type CopyItem } from './core/copy'; import { copyCandidates, copyItemsFor, copyItemsWithOverrides, hasCopyItems, selectedTargets, type CopyCandidate } from './core/copy-targets'; import { quantityTargetNames } from './core/quantity-scope'; import { aggregateQuantities, type QuantityTotal } from './core/quantity-total'; import { pileGeometry } from './core/geometry'; import { rebarSummary, type RebarRow } from './core/rebar/summary'; import { shearSegments } from './core/rebar/coping'; import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d'; import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common'; import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity'; import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model'; import { CORROSION_ALLOWANCE_MM, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground'; import { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout'; import { bearingPlanSvg, PAIR_CHIP_LABELS } from './view/bearing-plan-svg'; import { bearingSectionSvg } from './view/bearing-section-svg'; import { blockRebarSectionSvg } from './view/block-rebar-svg'; import { blockRebarPlanSvg } from './view/block-rebar-plan-svg'; import { copingEndStation, copingFrontSvg, copingSectionSvg } from './view/coping-rebar-svg'; import { BLOCK_REBAR_RANGES } from './core/rebar/block-check'; import { DEFAULT_BLOCK_REBAR, type BlockRebarSettings } from './core/rebar/bearing-block'; import { COPING_REBAR_RANGES, copingSettingsIssues, type CopingRebarSettings, DEFAULT_COPING_REBAR, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './core/rebar/coping-settings'; import { COLUMN_REBAR_RANGES, type ColumnRebarSettings, columnSettingsIssues, DEFAULT_COLUMN_REBAR } from './core/rebar/column-settings'; import { columnInputFor, columnRebar } from './core/rebar/column'; import { DEFAULT_FOOTING_REBAR, FOOTING_REBAR_RANGES, type FootingRebarSettings, footingSettingsIssues } from './core/rebar/footing-settings'; import { footingRuleValues } from './core/rebar/footing'; import { footingSectionSvg } from './view/footing-section-svg'; import { footingSectionLSvg } from './view/footing-section-l-svg'; import { footingCornerSvg } from './view/footing-corner-svg'; import { footingLegRows } from './core/rebar/footing-legs'; import { columnElevationSvg } from './view/column-elevation-svg'; import { type ColumnSection, columnSectionSvg } from './view/column-section-svg'; import { groundSectionSvg } from './view/ground-svg'; import { columnDiameterIssues } from './core/column-check'; import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec'; import { footingPlanSvg, footingSideSvg } from './view/footing-svg'; import { P1_COPING_GEOM } from './core/coping-geom'; import { SOLPLATE_RULE_DEFAULT, solplatePlan, solplateRuleIssues } from './core/solplate-rule'; import type { SolplateRule } from './core/types'; import { type BarSummary, elevationRows, solplateRows } from './core/bearing-table'; /** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */ let groundModel: GroundModelFile | null = null; import type { TypeFilter } from './core/selection'; import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options'; import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project'; import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup'; import type { Issue, PierInput, PierType, StrataRow } from './core/types'; import { buildPierScene, setRebarView, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene'; import { buildAllPiers, type AllPiers } from './view/all-piers'; import { buildTerrainMapGroup, disposeTerrainMapGroup, setTerrainMapMode } from './view/terrain-map'; /** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */ interface Pier extends PierValues { model: PierModel | null; issues: Issue[]; checked: boolean; } /** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */ /** The four stages: ① 형상 생성, ② 형상계획, ③ 철근배근, ④ 재료표 (결과 화면). */ type Stage = 'shape' | 'plan' | 'rebar' | 'quantity'; const PART_GROUPS: { label: string; parts: PartName[] }[] = [ { label: '받침·받침블럭', parts: ['blocks', 'pads', 'bearings'] }, { label: '솔플레이트', parts: ['solplates'] }, { label: '코핑', parts: ['coping'] }, { label: '기둥', parts: ['columns'] }, { label: '확대기초', parts: ['footings'] }, { label: '말뚝', parts: ['piles'] }, { label: '버림', parts: ['blinding'] }, { label: '지반면', parts: ['ground'] }, ]; const VIEWS: { kind: ViewKind; label: string }[] = [ { kind: 'iso', label: '아이소' }, { kind: 'front', label: '정면' }, { kind: 'side', label: '측면' }, { kind: 'top', label: '평면' }, ]; const TYPES: PierType[] = ['T형', '2주형', '3주형']; const CRS = '국토좌표계 (x = 동, y = 북)'; /** 시안 ② 부재 목록 (spec §5.2). `keys` are the inputs that exist today; the rest show values only. */ const MEMBERS: { id: MemberId; label: string; keys: EditableKey[]; note: string }[] = [ { id: 'bearing', label: '교량받침·받침블럭·솔플레이트', keys: ['bearingType', 'bearingLayout'], note: '받침 상세(C3)는 Q16 결정 후 추가합니다. 지금은 표시만 합니다.' }, { id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm', 'copingH1Mm', 'copingH2Mm', 'levelWidthMm'], note: '폭, 길이, 끝단 높이 h1, 테이퍼 높이 h2, Level 폭을 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' }, { id: 'column', label: '기둥', keys: ['columnDiameterMm'], note: '기둥 직경 D를 입력합니다 (1200–3000 mm, 50 mm 단위). 높이와 위치는 계산값입니다.' }, { id: 'footing', label: '확대기초 · 말뚝', keys: ['footingLengthMm', 'footingWidthMm', 'footingHeightMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileLengthMm'], note: '확대기초 치수, 비림, 말뚝 길이와 규격을 입력합니다. 규칙 미정 항목(모따기, 경사, 복합 말뚝)은 다음 단계에서 추가합니다.' }, { id: 'ground', label: '지반·지층', keys: ['groundEl', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'strataRows'], note: '지반고는 계산(흙덮개)에 쓰고, 지층은 행으로, 지하수위는 직접 입력합니다. 위치는 육상·하상·해상 중 고릅니다.' }, ]; interface Pending { fileName: string; entries: Pier[]; sheets: SheetReport[]; /** 교대 기록 read from the same workbook (AB). */ abutments: AbutRecord[]; abutIssues: Issue[]; } const state = { piers: [] as Pier[], /** 교대 기록 of the project (AB, 2026-11-03). Not drawn yet: the 3D placement is a later step. */ abutments: [] as AbutRecord[], /** 교대 편집 카드에서 고른 교대 (2026-11-03). */ abutActive: null as string | null, /** The 부재 · 교대 row whose card is open in ② (2026-11-03). */ abutMember: 'bearing' as AbutMemberId, /** The editable part of the 교대 panel is open (2026-11-03). */ abutEditOpen: false, /** 교대 기본값 (가져온 그대로), for 기본값 복원. */ abutDefaults: [] as AbutRecord[], active: null as string | null, view: 'iso' as ViewKind, visible: Object.fromEntries(PART_GROUPS.flatMap((g) => g.parts).map((p) => [p, true])) as Record, /** 접속슬래브 toggle (abutments only). */ abutSlabVisible: true, scene: null as PierScene | null, project: { name: '', bridge: '', client: '', author: '', crs: CRS, memo: '' } as ProjectInfo, /** Which stage screen is shown: ① 형상 생성 or ② 형상계획 (plan.md C1). */ stage: 'shape' as Stage, /** 전체 보기 on ① 형상 생성: every pier's concrete in one frame (콘크리트만). On by default (승인 2026-10-27). */ allView: true, /** ④ 재료표 scope: true = every pier (전체), false = the pier chosen in the list. */ quantityAll: true, /** ③ 공통 설정 (fck · fy). */ material: { ...DEFAULT_MATERIAL } as Material, /** ② 부재별 fck. A member without an entry uses the 공통 fck (③). Initial values from 교각도 (025·026, 승인 2026-10-27): 기둥·코핑 40, 기초 27. */ fckByMember: { ...IMPORTED_FCK } as FckByMember, /** Bar highlighted on ③ after [보기] (시안 ③ 알림). */ /** Bars selected on ③ (철근표 checkboxes, 3D click, [보기]). */ rebarSelection: new Set(), /** 선택만 보기: the bars not selected are hidden in 3D. */ rebarIsolate: false, /** Colour basis of the 3D rebar on ③: role (default), diameter, or member. */ rebarColor: 'role' as RebarColorMode, /** Member tab on ③ (철근표). */ rebarMember: 'coping' as RebarRow['member'], /** Member card selected on ② (시안 ② 부재 목록). */ member: 'coping' as MemberId, /** True once the project has a file on disk (first save via 시안 ⑤, or an opened file). Later saves go straight to it. */ savedOnce: false, filter: 'all' as 'all' | PierType, pending: null as Pending | null, source: { fileName: '', sheets: [] as string[] }, history: [] as ProjectFile['history'], /** Snapshot of the inputs at the last save or open. null until the project is first saved. */ savedSnapshot: null as string | null, }; let pendingBackup: Backup | null = null; /** 3D rebar of the current pier (③); its bars are named as in the 철근표. */ let rebar3d: RebarScene | null = null; /** 전체 보기: every pier's concrete in one frame (null in single-pier view). */ let allPiers: AllPiers | null = null; let abutGroups: THREE.Group[] = []; /** The 3D group of each abutment by name, so that selecting it can move the camera (2026-11-03). */ const abutGroupByName = new Map(); let groundMapGroup: THREE.Group | null = null; let groundMapMode: 'none' | 'ground' | 'satellite' = 'none'; let backupTimer: number | undefined; const $ = (id: string): T => document.getElementById(id) as T; function el(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (cls) node.className = cls; return node; } /** Results and short notices: a toast that goes away after 4 s. */ function setStatus(msg: string): void { showToast(msg, false); } /** Problems: a toast that stays until the user closes it. */ function setError(msg: string): void { showToast(msg, true); } function showToast(msg: string, sticky: boolean): void { const host = $('toast-host'); const t = el('div', undefined, sticky ? 'toast error' : 'toast'); t.appendChild(el('span', msg)); if (sticky) { const close = el('button', '닫기', 'btn small'); close.type = 'button'; close.addEventListener('click', () => t.remove()); t.appendChild(close); } else { window.setTimeout(() => t.remove(), 4000); } host.appendChild(t); while (host.childElementCount > 3) host.firstElementChild?.remove(); } // --- 3D renderer ------------------------------------------------------------- const container = $('canvas'); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.localClippingEnabled = true; // mitre planes on the bent coping renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); container.appendChild(renderer.domElement); const world = new THREE.Scene(); world.background = new THREE.Color(0xe9eef4); const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 5000); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; world.add(new THREE.HemisphereLight(0xffffff, 0x8899aa, 1.6)); const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(30, 60, 40); world.add(sun); function resize(): void { const w = container.clientWidth; const h = container.clientHeight; if (w === 0 || h === 0) return; renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); } window.addEventListener('resize', resize); // The 3D view follows its container, which changes when the layout frame or a panel size changes (승인 2026-10-23). new ResizeObserver(() => resize()).observe(container); resize(); const coordinateAxes: { line: SVGLineElement; label: SVGTextElement; vector: THREE.Vector3 }[] = [ { line: document.getElementById('axis-e-line') as unknown as SVGLineElement, label: document.getElementById('axis-e-label') as unknown as SVGTextElement, vector: new THREE.Vector3(1, 0, 0) }, { line: document.getElementById('axis-n-line') as unknown as SVGLineElement, label: document.getElementById('axis-n-label') as unknown as SVGTextElement, vector: new THREE.Vector3(0, 0, -1) }, { line: document.getElementById('axis-el-line') as unknown as SVGLineElement, label: document.getElementById('axis-el-label') as unknown as SVGTextElement, vector: new THREE.Vector3(0, 1, 0) }, ]; function updateCoordinateAxis(): void { camera.updateMatrixWorld(); const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0); const up = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1); const cx = 75; const cy = 75; const length = 52; for (const axis of coordinateAxes) { const screenX = axis.vector.dot(right); const screenY = axis.vector.dot(up); const x = cx + screenX * length; const y = cy - screenY * length; axis.line.setAttribute('x1', String(cx)); axis.line.setAttribute('y1', String(cy)); axis.line.setAttribute('x2', String(x)); axis.line.setAttribute('y2', String(y)); const dx = x - cx; const dy = y - cy; const lineLength = Math.hypot(dx, dy) || 1; axis.label.setAttribute('x', String(x + (dx / lineLength) * 12)); axis.label.setAttribute('y', String(y + (dy / lineLength) * 12)); } } function animate(): void { controls.update(); updateCoordinateAxis(); renderer.render(world, camera); requestAnimationFrame(animate); } animate(); // --- pier model ---------------------------------------------------------------- /** Fills the coping heights and Level width with the P1 values when a file or sheet does not give them (1단계). */ function withCopingDefaults(input: PierInput): PierInput { return { ...input, columnDiameterMm: input.columnDiameterMm ?? 2000, footingLengthMm: input.footingLengthMm ?? 10400, footingWidthMm: input.footingWidthMm ?? 8150, footingHeightMm: input.footingHeightMm ?? 2000, blindingMarginMm: input.blindingMarginMm ?? 100, blindingThicknessMm: input.blindingThicknessMm ?? 100, pileKind: input.pileKind ?? '강관', pileOuterMm: input.pileOuterMm ?? 609.6, pileThicknessMm: input.pileThicknessMm ?? 12, copingH1Mm: input.copingH1Mm ?? P1_COPING_GEOM.h1, copingH2Mm: input.copingH2Mm ?? P1_COPING_GEOM.h2, levelWidthMm: input.levelWidthMm ?? P1_COPING_GEOM.levelWidth, }; } function buildEntry(rawInput: PierInput, checked: boolean): Pier { const input = withCopingDefaults(rawInput); const issues: Issue[] = []; let model: PierModel | null = null; // 솔플레이트 규칙 (C안, 2026-11-03): a rule that cannot make a solplate is reported, and no model is built from it. const solIssues = solplateRuleIssues(input.solplate ?? SOLPLATE_RULE_DEFAULT); for (const message of solIssues) issues.push({ code: 'S01', level: 'E', pier: input.name, message }); if (validateInput(input, issues) && solIssues.length === 0) { try { model = buildPier(input); validatePier(input, model, issues); for (const message of columnDiameterIssues(input.columnDiameterMm ?? 2000, model)) { issues.push({ code: 'D01', level: 'E', pier: input.name, message }); } } catch (err) { issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message }); } } return { input, model, issues, checked, defaults: input, draft: input }; } /** Builds the model from the draft (the cards' values) and keeps the imported defaults. */ function regenerateEntry(p: Pier): Pier { return { ...buildEntry(p.draft, p.checked), defaults: p.defaults, draft: p.draft }; } function sheetOk(sheets: SheetReport[], name: string): boolean { return sheets.find((s) => s.name === name)?.ok === true; } function activePier(): Pier | undefined { return state.piers.find((p) => p.input.name === state.active); } // --- parts and views ------------------------------------------------------------- function buildPartToggles(): void { const box = $('parts'); const row = el('div', undefined, 'parts-row'); for (const group of PART_GROUPS) { const label = el('label'); const input = el('input'); input.type = 'checkbox'; input.checked = group.parts.every((p) => state.visible[p]); input.addEventListener('change', () => { for (const p of group.parts) { state.visible[p] = input.checked; setPartVisible(p, input.checked); } }); label.append(input, document.createTextNode(group.label)); row.appendChild(label); } // 접속슬래브 exists only on the abutments (2026-11-03): it has no pier part, so it is kept apart from state.visible. const slabLabel = el('label'); const slabInput = el('input'); slabInput.type = 'checkbox'; slabInput.checked = state.abutSlabVisible; slabInput.addEventListener('change', () => { state.abutSlabVisible = slabInput.checked; setAbutPartVisible('slab', slabInput.checked); }); slabLabel.append(slabInput, document.createTextNode('접속슬래브')); row.appendChild(slabLabel); box.appendChild(row); } /** Shows or hides one named part of every abutment (the 받침블럭 and 접속슬래브 of abut-scene.ts). */ function setAbutPartVisible(part: 'blocks' | 'slab', on: boolean): void { for (const g of abutGroups) { const member = g.getObjectByName(part); if (member) member.visible = on; } } function buildViewButtons(): void { const box = $('views'); for (const v of VIEWS) { const btn = el('button', v.label, 'btn'); btn.type = 'button'; btn.setAttribute('aria-pressed', String(v.kind === state.view)); btn.addEventListener('click', () => setView(v.kind)); box.appendChild(btn); } } /** Scenes whose parts follow the 부재 표시 checkboxes: every pier in 전체 보기, else the active pier. */ function partScenes(): PierScene[] { if (allPiers) return [...allPiers.scenes.values()]; return state.scene ? [state.scene] : []; } function setPartVisible(part: PartName, on: boolean): void { if (part === 'ground' && allPiers) { // With terrain only the shared grid follows the checkbox; without it each pier's 지반고 line does. if (allPiers.terrain) allPiers.terrain.visible = on; else for (const s of allPiers.scenes.values()) s.parts.ground.visible = on; return; } // 받침·받침블럭 also covers the abutments' blocks and pads (2026-11-03). if (part === 'blocks') setAbutPartVisible('blocks', on); for (const s of partScenes()) s.parts[part].visible = on; } function groundMapElevation(): number { const gridValues = groundModel?.terrainGrid?.z.filter((value): value is number => value !== null) ?? []; const pierValues = groundModel?.piers.flatMap((pier) => pier.centerEl === null ? [] : [pier.centerEl]) ?? []; const groundValues = gridValues.length > 0 ? gridValues : pierValues; return (groundValues.length > 0 ? Math.min(...groundValues) : 0) - 0.5; } function decodeGroundMapImage(layer: GroundMapLayer): Promise { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(); image.onerror = () => reject(new Error(`${layer.name} 내장 이미지를 읽지 못했습니다.`)); image.src = layer.image; }); } function addGroundMaps(pivot: { e: number; n: number }): void { const layers = groundModel?.mapLayers; if (!layers?.length) return; groundMapGroup = buildTerrainMapGroup(layers, pivot, groundMapElevation(), groundMapMode, (layer) => { setError(`${layer.name} 이미지를 3D 지도에 표시하지 못했습니다.`); }, groundModel?.terrainGrid); world.add(groundMapGroup); } function setGroundMapMode(mode: 'none' | 'ground' | 'satellite'): void { groundMapMode = mode; setTerrainMapMode(groundMapGroup, mode); const panel = $('ground-map-info'); panel.hidden = !groundModel?.mapLayers?.length; document.querySelectorAll('input[name="ground-map-mode"]').forEach((input) => { input.checked = input.value === mode; }); } function setView(kind: ViewKind): void { state.view = kind; document.querySelectorAll('#views button').forEach((b, i) => { b.setAttribute('aria-pressed', String(VIEWS[i].kind === kind)); }); // The band shows the current view only; choosing one closes the menu (승인 2026-10-23). $('views-toggle').textContent = `${VIEWS.find((v) => v.kind === kind)?.label ?? ''} ▾`; setMenuOpen('views', false); const scene = allPiers ? allPiers.view : state.scene; if (!scene) return; const pier = activePier(); if (!pier?.model) return; const framed = viewPosition(scene, kind, pier.model); // Keep the chosen view direction but fit every model (piers and abutments) in the window (2026-11-03). const fitted = fitToModels(framed.position, framed.target); camera.position.copy(fitted.position); controls.target.copy(fitted.target); controls.update(); } /** Bounding sphere of everything drawn (the piers or the all-piers view, plus the abutments) fitted to the camera. */ function fitToModels(position: THREE.Vector3, target: THREE.Vector3): { position: THREE.Vector3; target: THREE.Vector3 } { const box = new THREE.Box3(); const piersRoot = allPiers ? allPiers.piers : state.scene?.root; if (piersRoot) box.expandByObject(piersRoot); for (const g of abutGroups) if (g.visible) box.expandByObject(g); if (box.isEmpty()) return { position, target }; const sphere = box.getBoundingSphere(new THREE.Sphere()); return fitCamera(position, target, sphere, camera.fov, camera.aspect); } /** 전체 보기 applies on ① 형상 생성 only; ③ and the others keep the single-pier scene. */ /** The piers the 3D view shows together: all generated piers in ① (전체 보기), the 기준 pier alone in ②. */ function shownPiers(): Pier[] { if (state.stage === 'shape') return state.piers.filter((p) => p.model); const active = activePier(); return active?.model ? [active] : []; } /** Several piers in one frame: only ① with 전체 보기 on. ② shows the 기준 pier alone. */ function multiSceneOn(): boolean { return state.stage === 'shape' && state.allView; } /** Builds the shown piers in one frame. Returns false when none has a model. */ function buildAllScene(): boolean { const entries = shownPiers().flatMap((p) => { if (!p.model) return []; const importedPier = groundModel?.piers.find((q) => q.id === p.input.name); return [{ name: p.input.name, model: p.model, ...(groundModel && importedPier ? { groundEl: importedPier.centerEl } : {}), }]; }); if (entries.length === 0) return false; allPiers = buildAllPiers(entries, groundModel?.terrainGrid); world.add(allPiers.piers); if (allPiers.terrain) world.add(allPiers.terrain); addGroundMaps(entries[0].model.pivot); for (const key of Object.keys(state.visible) as PartName[]) setPartVisible(key, state.visible[key]); return true; } function setupScene(): void { // 교대 (AB, 2026-11-03): drawn in the frame of the first pier with a model, so they sit with the piers. for (const g of abutGroups) world.remove(g); abutGroups = []; abutGroupByName.clear(); // The frame is the same one the piers use: the first shown pier in the all-piers view, otherwise the active pier. const ref = multiSceneOn() ? shownPiers().find((q) => q.model)?.model : (activePier()?.model ?? state.piers.find((q) => q.model)?.model); if (ref) { for (const rec of state.abutments) { try { const model = buildAbut(toAbutInput(rec)); const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings), ref.pivot, rec.groundEl, groundModel?.terrainGrid); group.visible = abutShown(state.stage, state.abutActive, rec.name); // The 부재 표시 toggles hold for a new abutment group too (blocks: the 받침·받침블럭 toggle; slab: 접속슬래브). const blocks = group.getObjectByName('blocks'); if (blocks) blocks.visible = state.visible.blocks; const slab = group.getObjectByName('slab'); if (slab) slab.visible = state.abutSlabVisible; abutGroups.push(group); abutGroupByName.set(rec.name, group); world.add(group); } catch { // 입력 오류는 가져오기 때 알림으로 표시한다. 여기서는 그리지 않는다. } } } if (groundMapGroup) { world.remove(groundMapGroup); disposeTerrainMapGroup(groundMapGroup); groundMapGroup = null; } if (state.scene) world.remove(state.scene.root); state.scene = null; if (allPiers) { world.remove(allPiers.piers); if (allPiers.terrain) world.remove(allPiers.terrain); allPiers = null; } if (rebar3d) world.remove(rebar3d.group); rebar3d = null; if (multiSceneOn() && buildAllScene()) { setView(state.view); return; } const pier = activePier(); // In ② an abutment selection draws the abutment alone, so the pier scene is not built (visibility.ts). if (pier?.model && piersShown(state.stage, state.abutActive)) { rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember); rebar3d.group.visible = state.stage === 'rebar'; rebar3d.setColorMode(state.rebarColor); world.add(rebar3d.group); const importedPier = groundModel?.piers.find((q) => q.id === pier.input.name); const importedGroundEl = groundModel && importedPier ? importedPier.centerEl : undefined; const scene = buildPierScene(pier.model, groundModel?.terrainGrid, importedGroundEl); for (const key of Object.keys(state.visible) as PartName[]) { scene.parts[key].visible = state.visible[key]; } world.add(scene.root); addGroundMaps(pier.model.pivot); setRebarView(scene, state.stage === 'rebar'); state.scene = scene; setView(state.view); } } // --- main list (시안 ① 교각 목록) ---------------------------------------------------- function renderPierList(): void { const select = $('type-filter'); if (select.options.length === 0) { select.appendChild(new Option('전체', 'all')); for (const t of TYPES) select.appendChild(new Option(t, t)); } select.value = state.filter; for (const opt of Array.from(select.options)) { const count = opt.value === 'all' ? state.piers.length : state.piers.filter((p) => p.input.type === opt.value).length; opt.textContent = `${opt.value === 'all' ? '전체' : opt.value} (${count})`; } renderPierRows($('pier-items'), 'shape'); renderPierRows($('plan-pier-items'), 'plan'); renderAbutEditor(); const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter); $('pier-empty').hidden = state.piers.length > 0; } /** One 교대 row: the name and form, and a click shows its key values in the status line (the 3D view already has it). */ function abutRow(rec: AbutRecord, mode: Stage = 'shape'): HTMLLIElement { const li = el('li'); const name = el('button', `${rec.name} · ${rec.type} 교대`, 'pier-name'); name.type = 'button'; name.setAttribute('aria-pressed', String(state.abutActive === rec.name)); name.addEventListener('click', () => { state.abutActive = rec.name; state.abutEditOpen = false; setStatus(abutSummary(rec)); // Rebuilt first: the abutment and the camera frame are in the new scene (visibility.ts in ②). setupScene(); focusAbut(rec.name); renderAll(); }); li.append(name, el('span', '교대', 'badge')); return li; } /** Moves the camera to the selected pier: its own scene, in the all-piers view or the single view (2026-11-03). */ function focusPier(name: string): void { const root = allPiers ? allPiers.scenes.get(name)?.root : state.scene?.root; if (!root) return; root.updateWorldMatrix(true, true); const { position, target } = cameraForBox(new THREE.Box3().setFromObject(root)); camera.position.copy(position); controls.target.copy(target); controls.update(); } /** Moves the camera to the selected abutment (2026-11-03). */ function focusAbut(name: string): void { const group = abutGroupByName.get(name); if (!group) return; group.updateWorldMatrix(true, true); const { position, target } = cameraForBox(new THREE.Box3().setFromObject(group)); camera.position.copy(position); controls.target.copy(target); controls.update(); } /** 교대 편집 카드: the values of the selected abutment. Each change is checked, rebuilds the abutment in the 3D view and marks the project as changed. */ /** The 교대 입력 panel slots of ① (replaces the pier info) and ② (under the list). */ const ABUT_SLOTS = { shape: { root: 'abut-panel', name: 'abut-panel-name', input: 'abut-panel-input', toggle: 'abut-panel-edit-toggle', editor: 'abut-panel-editor', summary: 'abut-panel-summary', basic: 'abut-panel-basic', bearings: 'abut-panel-bearings', shape: 'abut-panel-shape', msg: 'abut-panel-msg', prefix: 'abs' }, plan: { wing: 'abut-panel-plan-wing', root: 'abut-panel-plan',name: 'abut-panel-plan-name', toggle: 'abut-panel-plan-edit-toggle', editor: 'abut-panel-plan-editor', summary: 'abut-panel-plan-summary', basic: 'abut-panel-plan-basic', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' }, } as const; /** Shows the 교대 입력 in place of the pier info when an abutment is selected (2026-11-03). */ function renderAbutEditor(): void { const rec = state.abutments.find((a) => a.name === state.abutActive); $('pier-info').hidden = Boolean(rec); // ② hides the pier cards (부재 목록, 코핑 카드, 속성 복사) while an abutment is selected (style.css .abut-selected). $('view-plan').classList.toggle('abut-selected', Boolean(rec)); for (const slot of [ABUT_SLOTS.shape, ABUT_SLOTS.plan]) { const box = $(slot.root); box.hidden = !rec; if (rec) fillAbutSlot(slot, rec); } if (rec) { renderAbutMembers(rec); applyAbutMemberCard(rec); } else { $('abut-members').replaceChildren(); } } /** The 부재 · 교대 rows in ②: each row's value and the card it opens (2026-11-03, phase 1). */ function renderAbutMembers(rec: AbutRecord): void { const box = $('abut-members'); const slabWidth = abutSummaryRows(rec).find(([label]) => label === '접속슬래브 폭')?.[1] ?? '—'; box.replaceChildren(); for (const row of abutMemberRows(rec, slabWidth)) { const btn = el('button', undefined, 'pier-name'); btn.type = 'button'; btn.setAttribute('aria-pressed', String(row.id === state.abutMember)); btn.append(el('span', row.label), el('span', row.summary, 'muted small')); if (row.changed) btn.append(el('span', '변경', 'badge')); btn.addEventListener('click', () => { state.abutMember = row.id; state.abutEditOpen = true; renderAll(); }); const li = el('li'); li.append(btn); box.append(li); } } /** Shows only the card of the open row in the ② 교대 editor; the basic items stay visible. */ function applyAbutMemberCard(rec: AbutRecord): void { const member = state.abutMember; const group = (id: string) => $(id).closest('details') ?? $(id); group('abut-panel-plan-bearings').hidden = member !== 'bearing'; group('abut-panel-plan-shape').hidden = member !== 'shape'; $('abut-panel-plan-wing').hidden = member !== 'wing'; $('abut-panel-plan-msg').textContent = ABUT_MEMBER_EDITABLE[member] ? '' : `${rec.name} 입력은 2단계에서 추가합니다. 현재 값만 보여 줍니다.`; } /** Fills one 교대 입력 panel: the basic items, the 받침 values and the 형상 values. Each change is checked and applied. */ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: AbutRecord): void { $(slot.name).textContent = rec.name; // 입력값 (read only, the mockup's "입력값" card) and 상세 (계산값, 8 rows), both as lists of label and value (2026-11-03). const fillList = (dl: HTMLDListElement, rows: [string, string][]) => { dl.replaceChildren(); for (const [label, value] of rows) dl.append(el('dt', label), el('dd', value)); }; // ② has no 입력값 list: its values are in ① and on the 삽도 labels (2026-11-03). if ('input' in slot) fillList($(slot.input), abutInputRows(rec)); fillList($(slot.summary), abutSummaryRows(rec).filter(([label]) => ABUT_DETAIL_LABELS.includes(label))); // 편집 opens the editable fields; they stay closed until asked for (2026-11-03). const toggle = $(slot.toggle); toggle.textContent = state.abutEditOpen ? '닫기' : '편집'; toggle.setAttribute('aria-expanded', String(state.abutEditOpen)); toggle.onclick = () => { state.abutEditOpen = !state.abutEditOpen; renderAll(); }; $(slot.editor).hidden = !state.abutEditOpen; const basic = $(slot.basic); const bearings = $(slot.bearings); const shape = $(slot.shape); const msg = $(slot.msg); basic.replaceChildren(); // ② puts the wing forms in their own card; ① keeps them in the basic items. // ② (plan card) shows no basic items: they are edited in ① only, and ② reads them off the 삽도 labels (2026-11-03). const planCard = 'wing' in slot; const wingBox = planCard ? $(slot.wing) : basic; wingBox.replaceChildren(); bearings.replaceChildren(); shape.replaceChildren(); // 삽도 (벽체·흉벽 개략 단면): the values it names are the card's values, so the drawing follows each change. const drawing = el('div', undefined, 'abut-section'); drawing.innerHTML = abutSectionSvg( resolveShape(rec.shape), { ...ABUT_SHAPE_DEFAULT, ...(rec.shape ?? {}) }, { seatEl: rec.seatEl, footingBottomEl: rec.foundationBottomEl, groundEl: rec.groundEl }, ); shape.append(drawing); msg.textContent = ''; const apply = (next: AbutRecord) => { state.abutments = state.abutments.map((a) => (a.name === next.name ? next : a)); setupScene(); renderAll(); }; const fail = (err: unknown, input: HTMLInputElement, back: string) => { msg.textContent = (err as Error).message; input.value = back; }; const numberInput = (id: string, value: string) => { const input = el('input'); input.id = id; input.type = 'number'; input.step = 'any'; input.value = value; return input; }; const idOf = (suffix: string) => `${slot.prefix}-${rec.name}-${suffix}`; for (const f of planCard ? [] : ABUT_EDIT_FIELDS) { const id = idOf(f.key); const lab = el('label', `${f.label}${f.unit ? ` (${f.unit})` : ''}`); lab.htmlFor = id; const input = numberInput(id, String(rec[f.key])); input.addEventListener('change', () => { try { apply(applyAbutEdit(rec, f.key, Number(input.value))); } catch (err) { fail(err, input, String(rec[f.key])); } }); basic.append(lab, input); } for (const side of ['left', 'right'] as const) { const id = idOf(`wing-${side}`); const lab = el('label', side === 'left' ? '좌 날개벽 (① 쪽)' : '우 날개벽 (n 쪽)'); lab.htmlFor = id; const sel = el('select'); sel.id = id; for (const [value, text] of [['general', '일반형'], ['short', '단축형'], ['none', '없음']] as const) sel.appendChild(new Option(text, value)); sel.value = rec.wings?.[side] ?? 'general'; sel.addEventListener('change', () => apply(setWingForm(rec, side, sel.value as WingForm))); wingBox.append(lab, sel); } const restore = el('button', '기본값으로 되돌리기', 'btn small'); restore.type = 'button'; restore.addEventListener('click', () => { const def = state.abutDefaults.find((a) => a.name === rec.name); if (def) apply(restoreAbutDefault(def)); }); if (!planCard) basic.appendChild(restore); for (const b of rec.bearings) { const zId = idOf(`bz-${b.no}`); const zLab = el('label', `받침 ${b.no} z (m)`); zLab.htmlFor = zId; const zIn = numberInput(zId, String(b.z)); zIn.addEventListener('change', () => { try { apply(applyBearingEdit(rec, b.no, 'z', Number(zIn.value))); } catch (err) { fail(err, zIn, String(b.z)); } }); const sId = idOf(`bs-${b.no}`); const sLab = el('label', `받침 ${b.no} 교좌면 (m, 비우면 자동)`); sLab.htmlFor = sId; const sIn = numberInput(sId, b.seatEl === undefined ? '' : String(b.seatEl)); sIn.addEventListener('change', () => { try { apply(applyBearingEdit(rec, b.no, 'seatEl', sIn.value === '' ? undefined : Number(sIn.value))); } catch (err) { fail(err, sIn, b.seatEl === undefined ? '' : String(b.seatEl)); } }); bearings.append(zLab, zIn, sLab, sIn); } for (const f of SHAPE_EDIT_FIELDS) { const current = rec.shape?.[f.key] ?? ABUT_SHAPE_DEFAULT[f.key]; const id = idOf(`shape-${f.key}`); const lab = el('label', f.label); lab.htmlFor = id; const input = numberInput(id, String(current)); if (current !== ABUT_SHAPE_DEFAULT[f.key]) input.classList.add('changed-input'); input.addEventListener('change', () => { try { apply(applyShapeEdit(rec, f.key, Number(input.value))); } catch (err) { fail(err, input, String(current)); } }); shape.append(lab, input, el('span', `기본 ${ABUT_SHAPE_DEFAULT[f.key]}`, 'muted small')); } } function abutSummary(rec: AbutRecord): string { return `${rec.name} (${rec.type}): 교좌면 EL ${rec.seatEl.toFixed(3)} m · 기초 하단 ${rec.foundationBottomEl.toFixed(3)} m · 지반고 ${rec.groundEl.toFixed(3)} m · 받침 ${rec.bearings.length}개 · skew ${(rec.skewDeg).toFixed(2)}° · 종단경사 ${(rec.slopeI * 100).toFixed(2)} %`; } /** Rows of the pier list. ① shows the type and warnings; ② shows the count of changes from the defaults. */ function renderPierRows(list: HTMLUListElement, mode: Stage): void { list.replaceChildren(); const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter); // 교대 rows (2026-11-03, AB): A1 first, A2 last, shown in ① with the piers when the filter is 전체. const abuts = (mode === 'shape' || mode === 'plan') && state.filter === 'all' ? structureOrder(state.abutments, visible.map((p) => p.input.name)) : []; const abutRefs = abuts.filter((r) => r.kind === 'abut'); for (const ref of abutRefs.filter((r) => r.type === 'A1')) { const rec = state.abutments.find((a) => a.name === ref.name); if (rec) list.appendChild(abutRow(rec, mode)); } for (const pier of visible) { const li = el('li'); // The pier name and type are shown here, since the 3D title band is gone on wide screens (승인 2026-10-23). const name = el('button', `${pier.input.name} · ${pier.input.type}`, 'pier-name'); name.type = 'button'; // One highlighted row at a time: while an abutment is being edited, no pier row is pressed (2026-11-03). name.setAttribute('aria-pressed', String(pier.input.name === state.active && state.abutActive === null)); name.addEventListener('click', () => { state.active = pier.input.name; state.abutActive = null; state.quantityAll = false; state.rebarSelection = new Set(); setupScene(); renderAll(); focusPier(pier.input.name); }); const badge = mode === 'plan' ? el('span', `변경 ${overrideKeys(pier).length}`, 'badge') : mode === 'rebar' || mode === 'quantity' ? (pier.model ? el('span', '계산됨', 'badge') : el('span', '오류', 'badge error')) : pier.model ? el('span', `${pier.input.type}${pier.issues.some((i) => i.level !== 'I') ? ' · 경고' : ''}`, 'badge') : el('span', '오류', 'badge error'); li.append(name, badge); list.appendChild(li); } // 종점 교대 (A2) and any other form come after the piers. for (const ref of abutRefs.filter((r) => r.type !== 'A1')) { const rec = state.abutments.find((a) => a.name === ref.name); if (rec) list.appendChild(abutRow(rec, mode)); } } function renderHeader(): void { const pier = activePier(); const parts = [state.project.name || '프로젝트 미지정']; if (state.project.bridge) parts.push(state.project.bridge); parts.push(`교각 ${state.piers.length}기`); $('project-line').textContent = parts.join(' · '); $('step1-status').textContent = pier?.model ? '생성됨' : '미생성'; renderSaveStatus(); const total = pendingTotal(); const targets = generationTargets(); $('step2-status').textContent = total ? `변경 ${total}건 · 재생성 필요` : '예정'; const regen = $('regen'); regen.disabled = targets.length === 0; regen.textContent = targets.length ? `변경 교각 다시 생성 · ${targets.length}기` : '교각 형상 생성'; renderCopyPanel(); } // --- results panel (시안 ① 입력값 · 계산 결과) ---------------------------------------- type Pair = [string, string]; /** 입력값 for every pier in one table; the row of the active pier is marked, and a row click makes that pier active. */ function renderInputTable(): void { const table = $('inputs-all'); const body = table.tBodies[0]; body.replaceChildren(); for (const p of state.piers) { const inp = p.input; const front = inp.bearings.filter((b) => b.row === '전열').length; const back = inp.bearings.filter((b) => b.row === '후열').length; const tr = el('tr', undefined, p.input.name === state.active ? 'active' : undefined); tr.tabIndex = 0; tr.addEventListener('click', () => selectPier(p.input.name)); tr.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') selectPier(p.input.name); }); const cells = [ inp.name, f3(inp.copingTopEl), f3(inp.footingBottomEl), f3(inp.groundEl), String(inp.pileLengthMm), `${inp.copingWidthMm}×${inp.copingLengthMm}`, `${front}·${back}`, ]; for (const v of cells) tr.appendChild(el('td', v)); body.appendChild(tr); } } function selectPier(name: string): void { state.active = name; state.rebarSelection = new Set(); setupScene(); renderAll(); } function fillKv(dl: HTMLElement, pairs: Pair[]): void { dl.replaceChildren(); for (const [k, v] of pairs) { const row = el('div'); row.append(el('dt', k), el('dd', v)); dl.appendChild(row); } } const f3 = (v: number) => v.toFixed(3); const pt = (p: { e: number; n: number }) => `E ${p.e.toFixed(3)}, N ${p.n.toFixed(3)}`; function renderPanels(): void { const pier = activePier(); const m = pier?.model; // The pier name is in 교각 목록 on wide screens; the title keeps it on narrow ones (승인 2026-10-23). $('viewer-title').textContent = pier ? `${pier.input.name} · ${pier.input.type} 교각 형상` : '교각 3D 뷰'; $('origin').textContent = m ? `원점 = 교각 중심 (${pt(m.pivot)})` : ''; renderInputTable(); $('inputs-active').textContent = pier ? `활성 교각 ${pier.input.name} 상세 (표의 행을 누르면 바뀜)` : ''; $('edit-title').textContent = pier ? `코핑·말뚝 입력 수정 · 기준 ${pier.input.name}` : '코핑·말뚝 입력 수정'; if (!pier) { fillKv($('inputs'), []); fillKv($('res-axis'), []); fillKv($('res-column'), []); fillKv($('res-found'), []); $('issues').replaceChildren(el('li', '교각을 불러오면 알림이 표시됩니다.')); $('issue-count').textContent = ''; return; } const inp = pier.input; const front = inp.bearings.filter((b) => b.row === '전열').length; const back = inp.bearings.filter((b) => b.row === '후열').length; const ip2 = inp.ips.find((p) => p.name === 'IP4'); fillKv($('inputs'), [ ['교각 / 기초 형식', `${inp.type} · ${inp.foundationType}`], ['코핑 상면 EL', `${f3(inp.copingTopEl)} m`], ['기초 하단 EL', `${f3(inp.footingBottomEl)} m`], ['지반고 EL', `${f3(inp.groundEl)} m`], ['코핑 폭 × 길이', `${inp.copingWidthMm} × ${inp.copingLengthMm} mm`], ['말뚝 길이', `${inp.pileLengthMm} mm`], ['IP', ip2 && ip2.angleDeg === 0 ? 'IP1–IP3 직선 (꺾임 0°)' : '꺾임 코핑'], ['받침', `전열 ${front} · 후열 ${back}`], ]); if (!m) { fillKv($('res-axis'), [['상태', '생성 불가 (알림 참조)']]); fillKv($('res-column'), []); fillKv($('res-found'), []); } else { fillKv($('res-axis'), [ ['IP 길이 / 축 방위각', `${f3(m.lengthIp)} m / ${m.azimuthDeg.toFixed(2)}°`], ['받침블럭 높이 전열 / 후열', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)} mm`], ['계단 경계 (BP1 기준)', m.segments.slice(1).map((s) => f3(s.s0)).join(' / ') + ' m'], ['횡방향 단차', '없음'], ['코핑 하면 EL 끝단 / Level', `${f3(m.endBottom)} / ${f3(m.levelBottom)} m`], ]); fillKv($('res-column'), [ ['기둥 높이', `${f3(m.derived.columnHeight)} m`], ['기둥 중심', pt(m.pivot)], ]); fillKv($('res-found'), [ ['확대기초 상면 EL', m.footings.length ? `${f3(m.footings[0].top)} m` : '없음'], ['말뚝 배치', m.derived.pileLayout ?? '없음'], ['말뚝 수 / 선단 EL', `${m.derived.pileCount} 본 / ${m.derived.pileTipEl === null ? '없음' : f3(m.derived.pileTipEl) + ' m'}`], ['흙덮개', `${f3(m.derived.soilCover)} m`], ]); } const list = $('issues'); list.replaceChildren(); if (pier.issues.length === 0) list.appendChild(el('li', '알림 없음')); for (const issue of pier.issues) { const li = el('li'); li.append(el('span', issue.code, `code ${issue.level}`), document.createTextNode(` ${issue.message}`)); list.appendChild(li); } $('issue-count').textContent = `(${pier.issues.length}건)`; } function renderDefaults(): void { const box = $('defaults'); box.replaceChildren(); const items = [ '[미확정] Q16 받침 EL·E 식: 현재 모델(블럭 높이 = EL 차) 적용', '[미확정] Q5 말뚝 지름 600 mm (강관) 기본값', '[미확정] Q4 직접기초 버림콘크리트 적용', ...NOT_IMPLEMENTED_RULES.map((r) => `미구현 규칙: ${r}`), ]; for (const text of items) box.appendChild(el('li', text)); } /** Pier last brought into view in 교각 목록, so that a manual scroll is not undone by the next render. */ let lastScrolledPier: string | null = null; function renderAll(): void { updatePartsCount(); const allToggle = $('all-toggle'); allToggle.setAttribute('aria-pressed', String(state.allView)); allToggle.title = `${shownPiers().filter((p) => p.model).length}기를 한 화면에 (① 형상 생성)`; allToggle.disabled = state.stage !== 'shape' || !state.piers.some((p) => p.model); if (state.active !== lastScrolledPier) { lastScrolledPier = state.active; document.querySelector('.pier-items [aria-pressed="true"]')?.scrollIntoView({ block: 'nearest' }); } $('status').textContent = state.piers.length ? '' : '엑셀을 불러오면 교각 형상을 생성합니다.'; renderHeader(); renderPierList(); renderPanels(); renderEditCard(); renderPlan(); renderRebar(); renderRebarColor(); if (state.stage === 'quantity') renderQuantityScreen(); scheduleBackup(); } // --- 속성 복사 (시안 ⑥, plan C8) --------------------------------------------------------- /** Names of the pier targets ticked in the copy panel. */ let copySelection = new Set(); function copyCandidateList(): CopyCandidate[] { return state.piers.map((p) => ({ name: p.input.name, type: p.input.type, hasModel: p.model !== null })); } /** The piers that get the change: only those ticked in the panel (기본 0기). */ function selectedCopyTargets(): Pier[] { const src = activePier(); if (!src) return []; const names = new Set(selectedTargets(copyCandidateList(), src.input.name, copySelection)); return state.piers.filter((p) => names.has(p.input.name)); } /** Copy items ticked by the user, limited to the items of the selected 부재. */ function chosenCopyItems(): CopyItem[] { return copyItemsFor(state.member).filter((item) => copyItemChoice.has(item)); } /** The items ticked by the user. Reset to the 기준 pier's changed items when the 기준 pier or 부재 changes. */ let copyItemChoice = new Set(); let copyItemKey = ''; /** Shows the copy items of the selected 부재 only. A 부재 without items shows a note instead. */ function renderCopyItems(): void { const src = activePier(); const key = `${src?.input.name ?? ''}|${state.member}`; if (key !== copyItemKey) { copyItemKey = key; copyItemChoice = new Set(src ? copyItemsWithOverrides(src) : []); } const box = $('cp-items'); box.replaceChildren(); const items = copyItemsFor(state.member); for (const item of items) { const label = el('label', undefined, 'chk'); const check = el('input'); check.type = 'checkbox'; check.dataset.item = item; check.checked = copyItemChoice.has(item); check.addEventListener('change', () => { if (check.checked) copyItemChoice.add(item); else copyItemChoice.delete(item); renderCopyPreview(); }); label.append(check, document.createTextNode(COPY_ITEMS[item].label)); box.appendChild(label); } // The panel is hidden for a 부재 without copy items (교량받침·기둥 are 예정). $('copy-panel').hidden = !hasCopyItems(state.member); } function renderCopyTargets(): void { const box = $('cp-targets'); box.replaceChildren(); const src = activePier(); for (const c of copyCandidates(copyCandidateList(), src?.input.name ?? '')) { const label = el('label', undefined, 'chk'); const check = el('input'); check.type = 'checkbox'; check.disabled = !c.selectable; check.checked = c.selectable && copySelection.has(c.name); check.setAttribute('aria-label', `${c.name} 대상`); check.addEventListener('change', () => { if (check.checked) copySelection.add(c.name); else copySelection.delete(c.name); renderCopyPreview(); }); const otherType = src !== undefined && c.type !== src.input.type ? ' · 형식 다름' : ''; const text = c.selectable ? `${c.name} · ${c.type}${otherType}` : `${c.name} · ${c.type} (모델 없음, 제외)`; label.append(check, document.createTextNode(text)); box.appendChild(label); } } function renderCopyPreview(): void { const src = activePier(); const items = chosenCopyItems(); const box = $('cp-preview'); box.replaceChildren(); let applied = 0; let skippedTotal = 0; const selected = selectedCopyTargets(); for (const t of selected) { if (!src) break; const plan = planCopy(src.draft, t.draft, items); const labels = plan.changes.map((c) => `${FIELD_LABELS[c.key]} ${c.from} → ${c.to}`); const skipped = plan.skipped.map((k) => k.reason); skippedTotal += skipped.length; if (plan.changes.length) applied++; const detail = [...labels, ...skipped.map((r) => `건너뜀: ${r}`)].join(' · ') || '바뀌는 값 없음'; box.appendChild(el('p', `${t.input.name} · ${detail}`, 'cp-line')); } const n = selected.length; $('cp-summary').textContent = `${src?.input.name ?? ''} → ${n}기 · 항목 ${items.length}개 · 건너뜀 ${skippedTotal}건`; const apply = $('cp-apply'); apply.textContent = selected.length === 0 ? '대상을 고르세요' : `${selected.map((t) => t.input.name).join(', ')} ${selected.length}기에만 적용`; apply.disabled = applied === 0; if (items.length === 0) apply.textContent = '복사 항목 없음'; } /** The inline 속성 복사 panel: source is the 기준 pier; targets are ticked inside the panel only. */ function renderCopyPanel(): void { const src = activePier(); $('cp-source').textContent = src ? `${src.input.name} · ${src.input.type} · ${src.input.foundationType}` : '교각을 선택하세요'; renderCopyItems(); renderCopyTargets(); renderCopyPreview(); } function applyCopyPanel(): void { const src = activePier(); if (!src) return; const items = chosenCopyItems(); const regen = $('cp-regen').checked; let applied = 0; let changes = 0; const targets = new Set(selectedCopyTargets().map((t) => t.input.name)); state.piers = state.piers.map((t) => { if (t === src || !targets.has(t.input.name)) return t; const plan = planCopy(src.draft, t.draft, items); if (plan.changes.length === 0) return t; applied++; changes += plan.changes.length; const next = { ...t, draft: applyCopy(t.draft, plan) }; return regen ? regenerateEntry(next) : next; }); setupScene(); renderAll(); setStatus(`${applied}기에 속성을 복사했습니다. 변경 ${changes}건${regen ? ' (형상 다시 생성)' : ' · 재생성 필요'}.`); } // --- input cards and change tracking (plan B1 · C2) ------------------------------------- function pendingTotal(): number { return state.piers.reduce((n, p) => n + pendingKeys(p).length, 0); } function fieldLabel(key: EditableKey): string { return FIELD_LABELS[key]; } function editField(name: string, key: EditableKey, raw: string): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; const value = Number(raw); if (!Number.isFinite(value) || value <= 0) { setError(`${fieldLabel(key)}은(는) 0보다 큰 숫자여야 합니다. 값을 되돌렸습니다.`); renderAll(); return; } p.draft = { ...p.draft, [key]: value }; renderAll(); } function revertField(name: string, key: EditableKey): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; p.draft = revertDraft(p, key); renderAll(); } function regenerateOne(name: string): void { const i = state.piers.findIndex((q) => q.input.name === name); if (i < 0) return; state.piers[i] = regenerateEntry(state.piers[i]); setupScene(); renderAll(); const msg = state.piers[i].model ? '형상을 다시 생성했습니다.' : '형상을 생성하지 못했습니다. 알림을 확인하세요.'; setStatus(msg); } function renderEditCard(): void { const pier = activePier(); const btn = $('edit-regen'); if (!pier) { $('edit-fields').replaceChildren(); $('edit-summary').textContent = '교각을 선택하면 입력을 고칠 수 있습니다.'; btn.textContent = '다시 생성 · 변경 0'; btn.disabled = true; return; } const status = fieldRows($('edit-fields'), pier, EDITABLE_FIELDS.map((f) => f.key)); $('edit-summary').textContent = status.pending.length ? `미반영 변경 ${status.pending.length}건. 3D 그림은 생성본을 유지합니다.` : status.overrides.length ? `기본값과 ${status.overrides.length}건 다릅니다. 생성본이 최신입니다.` : '기본값과 같습니다.'; btn.textContent = `다시 생성 · 변경 ${status.pending.length}`; btn.disabled = status.pending.length === 0; } /** One row per input key: value in the draft, the imported default beside it, and a revert button when overridden. */ function fieldRows(box: HTMLDivElement, pier: Pier, keys: EditableKey[]): { overrides: EditableKey[]; pending: EditableKey[] } { box.replaceChildren(); const overrides = overrideKeys(pier); const pending = pendingKeys(pier); for (const f of EDITABLE_FIELDS.filter((x) => keys.includes(x.key))) { const changed = overrides.includes(f.key); const row = el('div', undefined, changed ? 'edit-row changed' : 'edit-row'); const id = `edit-${f.key}`; const label = el('label', `${f.label} (${f.unit})`); label.htmlFor = id; const input = el('input'); input.id = id; input.type = 'number'; input.step = 'any'; input.value = String(pier.draft[f.key]); input.addEventListener('change', () => editField(pier.input.name, f.key, input.value)); row.append(label, input, el('span', `기본 ${pier.defaults[f.key]}`, 'muted small')); if (changed) { const revert = el('button', '되돌리기', 'btn small'); revert.type = 'button'; revert.addEventListener('click', () => revertField(pier.input.name, f.key)); row.appendChild(revert); } box.appendChild(row); } return { overrides, pending }; } /** Footing length of the generated model, which the 열별 본수 limits depend on. */ function footingLengthOf(p: Pier): number | undefined { return p.model?.footings[0]?.length; } /** 확대기초·말뚝 그림 (2단계, 승인 2026-10-23): plan and side views drawn from the model; θ is shown as a note. */ /** * The 말뚝 layout (승인 2026-10-23 추천안): the 열별 본수 card on the left, the plan beside it, the side view below. * The card and the plan share one row; on narrow screens they stack. */ function renderFootingDrawings(box: HTMLDivElement, pier: Pier): void { if (!pier.model) return; const grid = el('div', undefined, 'footing-grid'); const cardCol = el('div', undefined, 'footing-card-col'); renderPileRows(cardCol, pier); const plan = el('figure', undefined, 'footing-fig'); plan.innerHTML = footingPlanSvg(pier.model); plan.appendChild(el('figcaption', '평면 · 1열이 아래, 열은 교축과 직각 방향 줄', 'muted small')); // The side view (정면) sits to the right of the plan, on the same scale (승인 2026-10-23). const side = el('figure', undefined, 'footing-fig'); side.innerHTML = footingSideSvg(pier.model); side.appendChild(el('figcaption', '정면 · 끊음 표시: 기둥과 말뚝 일부 생략 · 점선 = 경사 선택 시 상면 (L2, h2)', 'muted small')); grid.append(cardCol, plan, side); box.appendChild(grid); box.appendChild(el('p', '기초 회전각 θ는 교각축 대비 회전으로, 생성 규칙은 spec에 추가 후 적용합니다 (지금은 0°).', 'muted small')); } /** 말뚝 규격 (승인 2026-10-23): 강관 and PHC from their lists by outer diameter and wall; the inner diameter, area and weight are read. */ function renderPileSpec(box: HTMLDivElement, pier: Pier): void { const kind = pier.draft.pileKind ?? '강관'; const list = kind === '강관' ? STEEL_PILES : PHC_PILES; const cur = findPileSpec(kind, pier.draft.pileOuterMm ?? 609.6, pier.draft.pileThicknessMm ?? 12); const wrap = el('div', undefined, 'pile-spec'); const kindSel = el('select'); kindSel.id = 'pile-kind'; for (const k of ['강관', 'PHC'] as const) { const o = el('option', k === '강관' ? '강관 말뚝' : 'PHC 말뚝'); o.value = k; kindSel.appendChild(o); } kindSel.value = kind; kindSel.addEventListener('change', () => { const first = (kindSel.value === '강관' ? STEEL_PILES : PHC_PILES)[0]; setPileSpec(pier.input.name, kindSel.value as '강관' | 'PHC', first.D, first.t); }); const sizeSel = el('select'); sizeSel.id = 'pile-size'; for (const p of list) { const o = el('option', `${p.D} × ${p.t}`); o.value = `${p.D}|${p.t}`; sizeSel.appendChild(o); } if (cur) sizeSel.value = `${cur.D}|${cur.t}`; sizeSel.addEventListener('change', () => { const [D, t] = sizeSel.value.split('|').map(Number); setPileSpec(pier.input.name, kind, D, t); }); const info = cur ? `내경 ${cur.inner} mm · 단면적 ${cur.area} cm² · 단위중량 ${cur.unitWeight} ${kind === '강관' ? 'kg/m' : 'kN/m'}` : '목록에 없는 규격입니다.'; wrap.append(el('label', '말뚝 종류 '), kindSel, el('label', ' 외경 × 두께 '), sizeSel, el('p', info, 'muted small')); box.appendChild(wrap); } function setPileSpec(name: string, kind: '강관' | 'PHC', D: number, t: number): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; p.draft = { ...p.draft, pileKind: kind, pileOuterMm: D, pileThicknessMm: t }; state.piers = state.piers.map((q) => (q === p ? regenerateEntry(q) : q)); setupScene(); renderAll(); } /** 열별 본수 −/+ per 열 (spec §5.4). Shows the spacing, the total, and a warning below the minimum spacing. */ function renderPileRows(box: HTMLDivElement, pier: Pier): void { const footingLength = footingLengthOf(pier); if (pier.input.foundationType !== '말뚝기초' || footingLength === undefined) return; const g = pileGeometry(footingLength); let counts: number[]; try { counts = resolveRowCounts(pier.draft.pileRows, g); } catch (err) { box.appendChild(el('p', (err as Error).message, 'warn-text')); return; } const layout = rowLayout(counts, g); const wrap = el('div', undefined, 'pile-rows'); wrap.appendChild(el('p', `열별 본수 · 합계 ${totalPiles(counts)}본`, 'lbl')); counts.forEach((count, j) => { const row = el('div', undefined, layout[j].belowMinimum ? 'pile-row warn' : 'pile-row'); const minus = el('button', '−', 'btn small'); minus.type = 'button'; minus.setAttribute('aria-label', `열 ${j + 1} 본수 줄이기`); minus.addEventListener('click', () => setPileRow(pier.input.name, j, -1)); const plus = el('button', '+', 'btn small'); plus.type = 'button'; plus.setAttribute('aria-label', `열 ${j + 1} 본수 늘리기`); plus.addEventListener('click', () => setPileRow(pier.input.name, j, 1)); row.append(el('span', `열 ${j + 1}`), minus, el('span', `${count}본`, 'num'), plus, el('span', `간격 ${Math.round(layout[j].spacing * 1000)} mm`, 'num')); if (layout[j].belowMinimum) row.append(el('span', `최소 ${Math.round(g.minSpacing * 1000)} mm보다 좁습니다`, 'warn-text')); wrap.appendChild(row); }); wrap.appendChild(el('p', layoutText(counts, g), 'num')); box.appendChild(wrap); } function setPileRow(name: string, row: number, delta: number): void { const p = state.piers.find((q) => q.input.name === name); const length = p && footingLengthOf(p); if (!p || length === undefined) return; const counts = resolveRowCounts(p.draft.pileRows, pileGeometry(length)); p.draft = { ...p.draft, pileRows: adjustRow(counts, row, delta) }; renderAll(); } /** 형상 선택칩 (plan.md C5): the default is pressed; every other option is shown and disabled with the reason. */ function renderShapeOptions(member: MemberId, pier?: Pier): void { const box = $('member-options'); box.replaceChildren(); SHAPE_GROUPS[member].forEach((group, gi) => { const row = el('div', undefined, 'shape-row'); row.appendChild(el('span', group.title, 'lbl')); for (const option of group.options) { const chip = el('button', option.label, 'chip-opt'); chip.type = 'button'; // The ground 위치 is stored in the pier's draft, so its pressed chip is the chosen one (승인 2026-10-26). const chosen = member === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : member === 'bearing' && pier ? (pier.draft.bearingLayout ?? '분리') : undefined; chip.setAttribute('aria-pressed', String(chosen !== undefined ? option.label === chosen : option.isDefault)); chip.disabled = !isSelectable(option); if (chip.disabled) chip.title = UNDEFINED_REASON; // 일체 needs every front–back pair within 100 mm (승인 2026-10-26); the reason is shown on the chip. if (member === 'bearing' && option.label === '일체' && pier?.model) { const check = integratedCheck(pier.model.blocks); chip.disabled = !check.allowed; chip.title = check.allowed ? '' : `편심 100 mm 초과 쌍 ${check.overLimit.length}개 → 분리 우선`; } chip.addEventListener('click', () => { try { choose(member, gi, option.label); if (member === 'ground' && pier) editGround(pier.input.name, 'groundPosition', option.label); if (member === 'bearing' && pier) editBearingLayout(pier.input.name, option.label as '분리' | '일체'); } catch (err) { setError((err as Error).message); } }); row.appendChild(chip); } box.appendChild(row); }); } // --- ② 형상계획 (plan.md C1) ------------------------------------------------------------- /** * The 3D viewer block (views, canvas, part toggles) is shared: it sits in ① and moves into the top slot of * ② or ③ while that screen is shown. Moving the element keeps the WebGL context. */ const viewerHome: { parent: Node | null; next: Node | null } = { parent: null, next: null }; function captureViewerHome(): void { const block = $('viewer-block'); viewerHome.parent = block.parentElement; viewerHome.next = block.nextElementSibling; } function placeViewer(stage: Stage): void { const block = $('viewer-block'); const slot = stage === 'plan' ? $('slot-plan') : stage === 'rebar' ? $('slot-rebar') : null; if (slot) slot.appendChild(block); else if (viewerHome.parent) viewerHome.parent.insertBefore(block, viewerHome.next); renderRebarColor(); resize(); } /** The 철근표 group that a bar name belongs to. */ function memberOfBar(name: string): RebarRow['member'] { if (name === 'S1' || name === 'S2') return 'bearing'; if (name.startsWith('B')) return 'footing'; if (name.startsWith('P') || name.includes('커플러')) return 'column'; return 'coping'; } /** Click on a pier in 전체 보기 makes it the active pier and returns to the single-pier view. */ function wireAllPiersPicking(): void { const dom = renderer.domElement; let down: { x: number; y: number } | null = null; dom.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; }); dom.addEventListener('pointerup', (e) => { if (!down || !allPiers) return; if (Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) return; // a drag rotates the view const rect = dom.getBoundingClientRect(); const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1); const ray = new THREE.Raycaster(); ray.setFromCamera(ndc, camera); const hit = ray.intersectObjects(allPiers.piers.children, true).find((h) => h.object.visible); let node: THREE.Object3D | null = hit?.object ?? null; while (node && !node.userData.pier) node = node.parent; if (!node) return; state.active = node.userData.pier as string; state.allView = false; state.rebarSelection = new Set(); setupScene(); renderAll(); }); } /** Click on a 3D bar selects its 철근표 row (on ③ only). */ function wireRebarPicking(): void { const dom = renderer.domElement; let down: { x: number; y: number } | null = null; dom.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; }); dom.addEventListener('pointerup', (e) => { if (!down || state.stage !== 'rebar' || !rebar3d) return; if (Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) return; // a drag rotates the view const rect = dom.getBoundingClientRect(); const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1); const ray = new THREE.Raycaster(); ray.setFromCamera(ndc, camera); const hit = ray.intersectObjects(rebar3d.group.children, false).find((h) => h.object.visible); if (!hit) return; const name = hit.object.userData.name as string; state.rebarSelection = new Set([name]); state.rebarMember = memberOfBar(name); renderAll(); }); } function setStage(stage: Stage): void { // ① opens on 전체 보기 (default). The button is gone, so 전체 보기 returns each time ① is entered. if (stage === 'shape' && state.stage !== 'shape') state.allView = true; const previous = state.stage; state.stage = stage; // Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout. // Entering or leaving ② changes which objects are drawn (visibility.ts), so the scene is rebuilt then. if ((allPiers !== null) !== multiSceneOn() || stage === 'plan' || previous === 'plan') setupScene(); if (state.scene) setRebarView(state.scene, stage === 'rebar'); $('view-shape').hidden = stage !== 'shape'; $('view-plan').hidden = stage !== 'plan'; $('view-rebar').hidden = stage !== 'rebar'; $('view-quantity').hidden = stage !== 'quantity'; 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. const nav: [string, Stage][] = [['nav-shape', 'shape'], ['nav-plan', 'plan'], ['nav-rebar', 'rebar'], ['nav-quantity', 'quantity']]; for (const [id, key] of nav) { const btn = $(id); btn.classList.toggle('current', stage === key); btn.setAttribute('aria-current', String(stage === key)); } renderAll(); } // --- ③ 철근배근 (plan C5 · ③ screen v1) -------------------------------------------------- const REBAR_MEMBERS: { id: RebarRow['member']; label: string }[] = [ { id: 'bearing', label: '받침블럭' }, { id: 'coping', label: '코핑' }, { id: 'column', label: '기둥' }, { id: 'footing', label: '확대기초' }, ]; /** 3D colour basis buttons on ③ and their legend (spec §6.7). */ function renderRebarColor(): void { const colorLabel: Record = { role: '역할', diameter: '직경', member: '부재' }; $('color-state').textContent = `${colorLabel[state.rebarColor]} ▾`; // The colour control lives in the 3D window and is shown only on ③. $('color-info').hidden = state.stage !== 'rebar'; for (const b of document.querySelectorAll('#rebar-color button')) { b.setAttribute('aria-pressed', String(b.dataset.color === state.rebarColor)); } const legend = $('rebar-legend'); legend.replaceChildren(); const items = rebar3d?.legend(state.rebarColor) ?? []; if (state.rebarColor === 'role') { legend.appendChild(el('li', '역할별 색 (명세 §6.7)', 'muted small')); } for (const item of items) { const li = el('li', undefined, 'rebar-legend-item'); const swatch = el('span', undefined, 'swatch'); swatch.style.background = item.color; li.append(swatch, el('span', item.label)); legend.appendChild(li); } } for (const b of document.querySelectorAll('#rebar-color button')) { b.addEventListener('click', () => { state.rebarColor = b.dataset.color as RebarColorMode; rebar3d?.setColorMode(state.rebarColor); renderRebarColor(); }); } /** 받침블럭 배근 설정 (승인 2026-10-26, 시안 ③): the six inputs (ranges shown), the 적용 블럭, and the C–C section. */ const BLOCK_REBAR_FIELDS: { key: keyof BlockRebarSettings; label: string }[] = [ { key: 'coverTop', label: '상면 피복 (철근 중심, mm)' }, { key: 'coverSide', label: '측면 피복 (mm)' }, { key: 'firstGap', label: '첫 철근 최소 여유 (mm)' }, { key: 'spacingS1', label: '간격 S1 (mm)' }, { key: 'spacingS2', label: '간격 S2 (mm)' }, { key: 'legLength', label: '다리 길이 (mm)' }, ]; function setBlockRebar(name: string, key: keyof BlockRebarSettings, value: number): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; if (!Number.isFinite(value)) { setError('받침블럭 철근 값은 숫자로 입력하세요. 값을 되돌렸습니다.'); renderAll(); return; } p.draft = { ...p.draft, blockRebar: { ...p.draft.blockRebar, [key]: value } }; regenerateOne(name); } function renderBlockRebarSettings(box: HTMLDivElement, pier: Pier): void { if (!pier.model) return; const wrap = el('div', undefined, 'bearing-ref'); const current: BlockRebarSettings = { ...DEFAULT_BLOCK_REBAR, ...pier.draft.blockRebar }; wrap.appendChild(el('p', '받침블럭 배근 설정 (기본값 = P1 · 범위는 칸 옆에 표시)', 'lbl')); // 단면 쌍 (the same selection as ②): the C–C section cuts through one pair. const pairList = pairEccentricities(pier.model.blocks); const chipRow = el('div', undefined, 'opts'); pairList.forEach((p, i) => { const chip = el('button', `${PAIR_CHIP_LABELS[p.frontNo - 1] ?? p.frontNo}·${PAIR_CHIP_LABELS[p.backNo - 1] ?? p.backNo}`, 'btn small'); chip.type = 'button'; chip.setAttribute('aria-pressed', String(i === bearingPairIndex)); chip.addEventListener('click', () => { bearingPairIndex = i; renderAll(); }); chipRow.appendChild(chip); }); wrap.appendChild(chipRow); const grid = el('div', undefined, 'edit-fields'); for (const f of BLOCK_REBAR_FIELDS) { const row = el('div', undefined, 'edit-row'); const label = el('label', f.label); const input = el('input'); input.type = 'number'; input.step = '1'; input.value = String(current[f.key]); input.addEventListener('change', () => setBlockRebar(pier.input.name, f.key, Number(input.value))); const [lo, hi] = BLOCK_REBAR_RANGES[f.key]; row.append(label, input, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_BLOCK_REBAR[f.key]}`, 'muted small')); grid.appendChild(row); } wrap.appendChild(grid); const n = pier.model.integratedBlocks.length; wrap.appendChild(el('p', `적용 블럭: ${n > 0 ? `일체 ${n}쌍` : `분리 ${pier.model.blocks.length}개`} (전체)`, 'muted small')); const fig = el('figure', undefined, 'ground-fig'); const pairIdx = Math.min(bearingPairIndex, Math.max(0, pairEccentricities(pier.model.blocks).length - 1)); // 단면 C–C / 평면 (승인 2026-10-26): the two views of the same settings; the 평면 shows the legs as dots and the row numbers. const viewRow = el('div', undefined, 'opts'); for (const [v, label] of [['section', '단면 C–C'], ['plan', '평면']] as const) { const b = el('button', label, 'btn small'); b.type = 'button'; b.setAttribute('aria-pressed', String(bearingRebarView === v)); b.addEventListener('click', () => { bearingRebarView = v; renderAll(); }); viewRow.appendChild(b); } wrap.appendChild(viewRow); fig.innerHTML = bearingRebarView === 'plan' ? blockRebarPlanSvg(pier.model, pairIdx) : blockRebarSectionSvg(pier.model, pairIdx); fig.appendChild(el('figcaption', bearingRebarView === 'plan' ? '평면 · 선택한 쌍 · S2 선(교각축 방향), S1 선(교축 방향) · 다리는 점 · 번호는 표의 행 번호' : '단면 C–C · 교축 방향 · 선택한 쌍 · S2 점(상면), S1 선과 다리(⊓) · 치수 mm', 'muted small')); wrap.appendChild(fig); box.appendChild(wrap); } /** 코핑 배근 (승인 2026-10-26, 추천): 정면, and the 단면·폐합 at a station the user chooses (default mid-length). */ let copingView: 'front' | 'section' = 'front'; /** 코핑 배근 입력 (승인 2026-10-26, 제안 = RAG/코핑_입력범위_제안.md). Order follows the ③ 코핑 배근 panel. */ const COPING_REBAR_FIELDS: { key: keyof CopingRebarSettings; label: string }[] = [ { key: 'cover', label: '피복 (철근 중심, mm)' }, { key: 'mainSpacing', label: '주철근 기본간격 (mm)' }, { key: 'mainEndGap', label: '주철근 끝단 최소 여유 (mm)' }, { key: 'mainLayerGap', label: '상면 주철근 단 간격 · 세로 (mm, 가로는 d 고정)' }, { key: 'firstShearGap', label: '첫 전단철근 최소 여유 (피복 다음, mm)' }, { key: 'shearLegs', label: '전단철근 다리 수' }, { key: 'spacingS', label: '전단철근 간격 · 경사 (mm)' }, { key: 'spacingL', label: '전단철근 간격 · Level (mm)' }, { key: 'sideSpacing', label: '측면 수평 간격 (mm)' }, { key: 'sideEndGap', label: '측면 위 끝 아래 최소 여유 (mm)' }, { key: 'c14UpperSpacing', label: 'C14 간격 위 2줄 (mm)' }, { key: 'c14LowerSpacing', label: 'C14 간격 아래 줄 (mm)' }, ]; /** 적용: the values go to the draft and the model is rebuilt only when every check passes (CP01·CP02). */ function applyCopingRebar(name: string, next: CopingRebarSettings, splice: TopSpliceSettings): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; p.draft = { ...p.draft, copingRebar: next, topSplice: splice }; regenerateOne(name); } /** A row of toggle buttons (aria-pressed), as on the 배치 chips of ②. */ function toggleRow(options: [T, string][], current: T, onPick: (v: T) => void): HTMLDivElement { const row = el('div', undefined, 'opts'); for (const [v, label] of options) { const b = el('button', label, 'btn small'); b.type = 'button'; b.setAttribute('aria-pressed', String(v === current)); b.addEventListener('click', () => onPick(v)); row.appendChild(b); } return row; } function renderCopingSettings(box: HTMLDivElement, pier: Pier): void { if (!pier.model) return; const wrap = el('div', undefined, 'bearing-ref'); const current: CopingRebarSettings = { ...DEFAULT_COPING_REBAR, ...pier.draft.copingRebar }; wrap.appendChild(el('p', '코핑 배근 입력 (기본값 = 코핑철근.md · 적용을 눌러야 반영)', 'lbl')); const grid = el('div', undefined, 'edit-fields'); const controls = new Map(); for (const f of COPING_REBAR_FIELDS) { const row = el('div', undefined, 'edit-row'); const label = el('label', f.label); let control: HTMLInputElement | HTMLSelectElement; if (f.key === 'shearLegs') { // 다리 수는 짝수만 고르도록 목록으로 둔다 (CP02). const sel = el('select'); for (const n of [4, 6, 8]) { const opt = el('option', String(n)); opt.value = String(n); sel.appendChild(opt); } sel.value = String(current.shearLegs); control = sel; } else { const input = el('input'); input.type = 'number'; input.step = 'any'; input.value = String(current[f.key]); control = input; } control.id = `coping-${f.key}`; label.htmlFor = control.id; controls.set(f.key, control); const [lo, hi] = COPING_REBAR_RANGES[f.key]; const changed = current[f.key] !== DEFAULT_COPING_REBAR[f.key] ? ' · 기본과 다름' : ''; row.append(label, control, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_COPING_REBAR[f.key]}${changed}`, 'muted small')); grid.appendChild(row); } wrap.appendChild(grid); // 상면 주철근(C1–C6) 이음 (승인 2026-10-26): 무시 (사양 기본) / 반영, and the 위치 when 반영. let splice: TopSpliceSettings = { ...DEFAULT_TOP_SPLICE, ...pier.draft.topSplice }; const spliceBox = el('div', undefined, 'bearing-ref'); const drawSplice = (): void => { spliceBox.replaceChildren(el('p', '상면 주철근(C1–C6) 이음', 'muted small'), toggleRow([['ignore', '이음 무시'], ['apply', '반영']] as const, splice.mode, (v) => { splice = { ...splice, mode: v }; drawSplice(); })); if (splice.mode === 'apply') { spliceBox.appendChild(toggleRow([['center', '기둥 중심'], ['end', '단부']] as const, splice.at, (v) => { splice = { ...splice, at: v }; drawSplice(); })); } }; drawSplice(); wrap.appendChild(spliceBox); const msg = el('p', undefined, 'muted small'); const apply = el('button', '적용', 'btn small'); apply.type = 'button'; apply.addEventListener('click', () => { const next = { ...current }; for (const f of COPING_REBAR_FIELDS) next[f.key] = Number(controls.get(f.key)?.value); const issues = copingSettingsIssues(next); if (issues.length > 0) { msg.textContent = issues.map((i) => i.message).join(' · '); setError('코핑 배근 입력을 확인하세요. 값은 반영되지 않았습니다.'); return; } applyCopingRebar(pier.input.name, next, splice); }); wrap.append(msg, apply); box.appendChild(wrap); } /** 기둥 배근 입력 (승인 2026-10-26, 시안 ③ 기둥 삽도). The 주철근 하단 갈고리 is fixed. The 띠철근 간격 is three inputs (K3 · K5 · K4). */ const COLUMN_REBAR_FIELDS: { key: keyof ColumnRebarSettings; label: string }[] = [ { key: 'c', label: 'P1 중심 피복 c (mm)' }, { key: 'ctcP3', label: '띠철근 간격 P3 (mm)' }, { key: 'ctcP5', label: '띠철근 간격 P5 (mm)' }, { key: 'ctcP4', label: '띠철근 간격 P4 (mm)' }, { key: 'ctcP6', label: '크로스타이 P6 간격 (mm)' }, { key: 'ctcCoupler', label: '커플러 간격 ctcOfcoupler (mm)' }, { key: 'dzCp2', label: 'P2 커플러 상승 Δz (mm)' }, { key: 'nDiv', label: '주철근 원주 등분 수 n_div' }, ]; /** 적용: the values go to the draft and the model is rebuilt only when every check passes (CL01·CL02). */ function applyColumnRebar(name: string, next: ColumnRebarSettings): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; p.draft = { ...p.draft, columnRebar: next }; regenerateOne(name); } function renderColumnSettings(box: HTMLDivElement, pier: Pier): void { if (!pier.model) return; const wrap = el('div', undefined, 'bearing-ref'); const current: ColumnRebarSettings = { ...DEFAULT_COLUMN_REBAR, ...pier.draft.columnRebar }; wrap.appendChild(el('p', '기둥 배근 입력 (기본값 = 기둥철근 (1).md · 적용을 눌러야 반영)', 'lbl')); const grid = el('div', undefined, 'edit-fields'); const inputs = new Map(); for (const f of COLUMN_REBAR_FIELDS) { const row = el('div', undefined, 'edit-row'); const label = el('label', f.label); const input = el('input'); input.type = 'number'; input.step = 'any'; input.value = String(current[f.key]); input.id = `column-${f.key}`; label.htmlFor = input.id; inputs.set(f.key, input); const [lo, hi] = COLUMN_REBAR_RANGES[f.key]; const changed = current[f.key] !== DEFAULT_COLUMN_REBAR[f.key] ? ' · 기본과 다름' : ''; row.append(label, input, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_COLUMN_REBAR[f.key]}${changed}`, 'muted small')); grid.appendChild(row); } wrap.appendChild(grid); // Read-only results of the current model (계산 값). const input = columnInputFor(pier.model); const col = columnRebar(input); wrap.appendChild(el('p', `P1 외측 r ${col.r1} · P2 내측 r ${col.r2} mm · 주철근 P1 ${col.mainP1}본 · P2 ${col.mainP2}본 · 주철근 커플러 ${col.couplers}개`, 'muted small')); // 정면 | 단면 A·B·C (승인 2026-10-26): 치수는 입력과 계산 결과, 간섭(B11–P4)은 검토 제외. let columnView: 'front' | 'section' = 'front'; const figBox = el('div'); const drawColumnFigure = (): void => { figBox.replaceChildren(toggleRow([['front', '정면'], ['section', '단면 A·B·C']] as const, columnView, (v) => { columnView = v; drawColumnFigure(); })); if (columnView === 'front') { const fig = el('figure', undefined, 'ground-fig'); fig.innerHTML = columnElevationSvg(input, col); fig.appendChild(el('figcaption', '정면 · 높이 기준 = 확대기초 상면 (mm) · 간섭(B11–P4)은 검토 제외', 'muted small')); figBox.appendChild(fig); return; } const captions: Record = { A: 'A 일반 · P3 첫 단 · 주철근 P1·P2 · 띠철근 P3 (겹침 표시)', B: 'B 확대기초 안 · P4 첫 단 · 반원 2개 · 커플러 2개 · P6 4본', C: 'C 단부 · P5 첫 단 · 커플러 1개 (θ + Δθ/2)', }; // 단면 A·B·C in one row (승인 2026-10-26): no wrapping, scrolls sideways on a narrow window. const row = el('div'); row.style.display = 'flex'; row.style.flexWrap = 'nowrap'; row.style.gap = '12px'; row.style.overflowX = 'auto'; for (const kind of ['A', 'B', 'C'] as const) { const fig = el('figure', undefined, 'ground-fig'); fig.style.margin = '0'; fig.style.flex = '0 0 300px'; fig.innerHTML = columnSectionSvg(kind, input, col); fig.appendChild(el('figcaption', captions[kind], 'muted small')); row.appendChild(fig); } figBox.appendChild(row); }; drawColumnFigure(); wrap.appendChild(figBox); const msg = el('p', undefined, 'muted small'); const apply = el('button', '적용', 'btn small'); apply.type = 'button'; apply.addEventListener('click', () => { const next = { ...current }; for (const f of COLUMN_REBAR_FIELDS) next[f.key] = Number(inputs.get(f.key)?.value); const issues = columnSettingsIssues(next); if (issues.length > 0) { msg.textContent = issues.map((i) => i.message).join(' · '); setError('기둥 배근 입력을 확인하세요. 값은 반영되지 않았습니다.'); return; } applyColumnRebar(pier.input.name, next); }); wrap.append(msg, apply); box.appendChild(wrap); } /** 확대기초 배근 입력 (승인 2026-10-26, 시안 ③ 확대기초). Fixed rules (k = 2, Δ₂ = 100, 전단 4s · 엇갈림 2s, cycle 교대) are shown, not edited. */ const FOOTING_REBAR_FIELDS: { key: keyof FootingRebarSettings; label: string }[] = [ { key: 'cTop', label: '상면 피복 (철근 중심, mm)' }, { key: 'cSide', label: '측면 피복 (mm)' }, { key: 'cBottom', label: '하면 피복 (mm)' }, { key: 'eMin', label: '첫 철근 최소 여유 (mm)' }, { key: 'sL', label: '격자 실간격 · L 방향 B1–B4 · B9 · B11 (mm)' }, { key: 'sW', label: '격자 실간격 · W 방향 B5–B8 · B12 (mm)' }, { key: 'sV', label: 'B10 높이 간격 s_v (mm)' }, { key: 'eTop', label: 'B5 아래 최소 여유 e_top (mm)' }, { key: 'n', label: 'B11·B12 연직간격 배수 n (B10 간격의 n배)' }, { key: 'kB11', label: 'B11 수평간격 배수 k (L 방향, k·sL)' }, { key: 'kB12', label: 'B12 수평간격 배수 k (W 방향, k·sW)' }, ]; /** 적용: the values go to the draft and the model is rebuilt only when every check passes (FT01·FT02). */ function applyFootingRebar(name: string, next: FootingRebarSettings): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; p.draft = { ...p.draft, footingRebar: next }; regenerateOne(name); } function renderFootingSettings(box: HTMLDivElement, pier: Pier): void { if (!pier.model) return; const wrap = el('div', undefined, 'bearing-ref'); const current: FootingRebarSettings = { ...DEFAULT_FOOTING_REBAR, ...pier.draft.footingRebar }; wrap.appendChild(el('p', '확대기초 배근 입력 (기본값 = 확대기초철근 (1).md · 적용을 눌러야 반영)', 'lbl')); const grid = el('div', undefined, 'edit-fields'); const inputs = new Map(); for (const f of FOOTING_REBAR_FIELDS) { const row = el('div', undefined, 'edit-row'); const label = el('label', f.label); const input = el('input'); input.type = 'number'; input.step = 'any'; input.value = String(current[f.key]); input.id = `footing-${f.key}`; label.htmlFor = input.id; inputs.set(f.key, input); const [lo, hi] = FOOTING_REBAR_RANGES[f.key]; const changed = current[f.key] !== DEFAULT_FOOTING_REBAR[f.key] ? ' · 기본과 다름' : ''; row.append(label, input, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_FOOTING_REBAR[f.key]}${changed}`, 'muted small')); grid.appendChild(row); } wrap.appendChild(grid); // Fixed rules and derived values (읽기 전용). Derived from the current settings of the first footing. const fs = current; const fixed = el('div', undefined, 'muted small'); const foot = pier.model.footings[0]; const H = foot ? (foot.top - foot.bottom) * 1000 : undefined; if (H === undefined) { fixed.textContent = '확대기초 없음 · 확대기초 규칙을 표시하지 않습니다'; } else { const r = footingRuleValues(H, fs); fixed.textContent = [ `상면·2단 간격 B1·B3 = 2·sL ${r.upper2L} mm · B5·B7 = 2·sW ${r.upper2W} mm (규칙)`, `하면 1·2단 간격 Δ₂ = ${r.delta2} mm (고정)`, `B2 디리 ${r.b2Leg} mm · B6 디리 ${r.b6Leg} mm (자동, B1·B5 높이까지)`, `B9 간격 4·sL ${r.b9Pitch} · 행 엇갈림 2·sL ${r.b9Stagger} mm (규칙)`, `B11 평면 간격 k·sL ${r.b11Pitch} · B12 평면 간격 k·sW ${r.b12Pitch} mm · 연직 n·s_v ${r.assemblyVertical} mm · 조립 단 ${r.assemblyLevels}단 (자동)`, 'cycle 배치: 교대 배치 (고정)', ].join(' · '); } wrap.appendChild(fixed); const msg = el('p', undefined, 'muted small'); const apply = el('button', '적용', 'btn small'); apply.type = 'button'; apply.addEventListener('click', () => { const next = { ...current }; for (const f of FOOTING_REBAR_FIELDS) next[f.key] = Number(inputs.get(f.key)?.value); const issues = footingSettingsIssues(next); if (issues.length > 0) { msg.textContent = issues.map((i) => i.message).join(' · '); setError('확대기초 배근 입력을 확인하세요. 값은 반영되지 않았습니다.'); return; } applyFootingRebar(pier.input.name, next); }); wrap.append(msg, apply); // 확대기초 단면 탭 (승인 2026-10-26): 교각축 직각 단면 (폭 W) and 교각축 단면 (길이 L) work; the other tabs are next steps. const footingModel = pier.model; let footingView: 'W' | 'L' | 'D' | 'B10' = 'W'; const tabs = el('div'); tabs.style.display = 'flex'; tabs.style.flexWrap = 'wrap'; tabs.style.gap = '6px'; const figBox = el('div'); const drawFootingFigure = (): void => { tabs.replaceChildren(); for (const [view, label, enabled] of [['W', '교각축 직각 단면 (폭 W)', true], ['L', '교각축 단면 (길이 L)', true], ['D', '다리 배치 상세', true], ['B10', 'B10 모서리', true]] as const) { const b = el('button', label, 'btn small'); b.type = 'button'; b.setAttribute('aria-pressed', String(view === footingView)); b.disabled = !enabled; if (!enabled) b.title = '다음 단계에서 추가'; if (enabled) b.addEventListener('click', () => { footingView = view as 'W' | 'L' | 'D' | 'B10'; drawFootingFigure(); }); tabs.appendChild(b); } figBox.replaceChildren(); if (footingView === 'D') { // 다리 배치 상세 (승인 2026-10-27): leg shape, leg length and leg top per bar; splices where the bar exceeds the 12000 standard. const table = el('table', undefined, 'grid-table'); for (const h of ['번호', '규격', '다리 형태', '다리 길이 (mm)', '다리 끝 높이 (하면 기준, mm)', '전체 길이 (mm)', '이음']) table.appendChild(el('th', h)); for (const r of footingLegRows(footingModel)) { const tr = el('tr'); tr.append(el('td', r.bar), el('td', r.size), el('td', r.shape), el('td', r.legLength === null ? '—' : r.legLength.toFixed(0)), el('td', r.legTop === null ? '—' : r.legTop.toFixed(0)), el('td', r.total.toFixed(0)), el('td', r.splice ? '겹침 필요 (12000 초과)' : '—')); table.appendChild(tr); } figBox.appendChild(table); figBox.appendChild(el('p', '다리 길이는 12d 기준 (Q-B5 확인 필요). B11·B12 갈고리는 l_h 246.', 'muted small')); return; } const sectionFig = el('figure', undefined, 'ground-fig'); const captions = { W: '교각축 직각 단면 (폭 W) · 실제 비율 · 다리 생략', L: '교각축 단면 (길이 L) · 실제 비율 · 다리 생략', B10: 'B10 모서리 · 개략도 (단면 비율 아님)' } as const; sectionFig.innerHTML = footingView === 'W' ? footingSectionSvg(footingModel) : footingView === 'L' ? footingSectionLSvg(footingModel) : footingCornerSvg(footingModel); sectionFig.appendChild(el('figcaption', captions[footingView], 'muted small')); figBox.appendChild(sectionFig); }; drawFootingFigure(); wrap.append(tabs, figBox); box.appendChild(wrap); } function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void { if (!pier.model || !rebar3d) return; const L = pier.model.copingL; const wrap = el('div', undefined, 'bearing-ref'); const row = el('div', undefined, 'opts'); for (const [v, label] of [['front', '정면'], ['section', '단면 · 폐합 구성']] as const) { const b = el('button', label, 'btn small'); b.type = 'button'; b.setAttribute('aria-pressed', String(copingView === v)); b.addEventListener('click', () => { copingView = v; renderAll(); }); row.appendChild(b); } wrap.appendChild(row); const fig = el('figure', undefined, 'ground-fig'); if (copingView === 'front') { fig.innerHTML = copingFrontSvg(rebar3d.records, L * 1000, pier.model.columns[0]?.s ?? 0); fig.appendChild(el('figcaption', '정면 · 교축 방향에서 본 코핑 배근 (3D 철근과 같은 데이터)', 'muted small')); } else { // 단면 · 폐합 at two fixed stations side by side (승인 2026-10-26): the 단부 (first shear station) and the first column centre. const sEnd = copingEndStation(rebar3d.records); const sCol = pier.model.columns[0]?.s ?? L / 2; const grid = el('div', undefined, 'footing-grid'); for (const [st, label] of [[sEnd, `단부 s = ${sEnd.toFixed(2)} m`], [sCol, `기둥 중심 s = ${sCol.toFixed(2)} m`]] as const) { const f = el('figure', undefined, 'footing-fig'); f.innerHTML = copingSectionSvg(rebar3d.records, st, pier.model.copingRefTop, pier.model.copingB * 1000); f.appendChild(el('figcaption', label, 'muted small')); grid.appendChild(f); } wrap.appendChild(grid); box.appendChild(wrap); return; } wrap.appendChild(fig); box.appendChild(wrap); } function renderRebar(): void { const pier = activePier(); // 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②. renderPierRows($('rebar-pier-items'), 'rebar'); const tabs = $('rebar-tabs'); tabs.replaceChildren(); for (const m of REBAR_MEMBERS) { const li = el('li'); const btn = el('button', m.label, 'member'); btn.type = 'button'; btn.setAttribute('aria-pressed', String(m.id === state.rebarMember)); btn.addEventListener('click', () => { state.rebarMember = m.id; state.rebarSelection = new Set(); renderAll(); }); li.appendChild(btn); tabs.appendChild(li); } const box = $('rebar-table'); box.replaceChildren(); const issues = $('rebar-issues'); issues.replaceChildren(); if (!pier?.model) { $('rebar-note').textContent = '교각을 생성하면 철근표가 나옵니다.'; return; } const { rows, warnings } = rebarSummary(pier.model, state.material, state.fckByMember); $('rebar-note').textContent = `${pier.input.name} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`; if (state.rebarMember === 'bearing') renderBlockRebarSettings(box, pier); if (state.rebarMember === 'coping' && rebar3d) renderCopingDrawings(box, pier); if (state.rebarMember === 'coping') renderCopingSettings(box, pier); if (state.rebarMember === 'column') renderColumnSettings(box, pier); if (state.rebarMember === 'footing') renderFootingSettings(box, pier); const shown = rows.filter((r) => r.member === state.rebarMember); box.appendChild(rebarSelectBar(shown.map((r) => r.name))); const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); for (const h of ['선택', '구분', '번호', '규격', '강도', '간격', '실간격', '위치', '방향', '형상', '길이 (mm)', '개수', '근거', '비고']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); for (const r of shown) { const tr = el('tr'); const nameCell = el('td', r.name, 'bar-no'); // Clicking a row selects that bar alone; clicking it again clears it (시안: 번호를 누르면 그 철근만 강조). tr.addEventListener('click', () => { state.rebarSelection = state.rebarSelection.size === 1 && state.rebarSelection.has(r.name) ? new Set() : new Set([r.name]); renderAll(); }); if (state.rebarSelection.has(r.name)) tr.classList.add('hl'); const check = el('input'); check.type = 'checkbox'; check.checked = state.rebarSelection.has(r.name); check.setAttribute('aria-label', `${r.name} 선택`); // The checkbox changes the selection on its own; its click must not reach the row handler. check.addEventListener('click', (ev) => ev.stopPropagation()); check.addEventListener('change', () => { const next = new Set(state.rebarSelection); if (check.checked) next.add(r.name); else next.delete(r.name); state.rebarSelection = next; renderAll(); }); const pick = el('td'); pick.appendChild(check); tr.append( pick, el('td', ROLE[r.name] ?? '—'), nameCell, el('td', r.size), el('td', /^[DHS]\d/.test(r.size) ? `SD${fyOf(state.material, r.size)}` : '—'), el('td', r.spacing), el('td', r.actual), el('td', r.position), el('td', r.direction), el('td', r.shape), el('td', r.length === null ? (r.pieces ? '여러 길이' : '—') : String(Math.round(r.length * 10) / 10), undefined), el('td', String(r.count)), basisCell(r.basis), el('td', r.note), ); body.appendChild(tr); } table.appendChild(body); box.appendChild(table); // Alerts: [보기] goes to the member, [입력] goes to the 공통 설정 input (시안 ③ 알림). for (const w of warnings) { const li = el('li', `${w.code} ${w.message} `); const btn = el('button', w.target === 'common' ? '입력' : '보기', 'btn small'); btn.type = 'button'; btn.addEventListener('click', () => { if (w.target === 'common') { $('mat-fck').focus(); $('mat-fck').scrollIntoView({ block: 'center' }); } else { // [보기]: go to the member and highlight the bar the alert is about. state.rebarMember = w.target; state.rebarSelection = new Set(w.bar ? [w.bar] : []); renderAll(); } }); li.appendChild(btn); issues.appendChild(li); } if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.')); // Warning count on each member tab (시안: 부재 탭 배지). const counts = (member: RebarRow['member']) => warnings.filter((w) => w.target === member).length; for (const [i, m] of REBAR_MEMBERS.entries()) { const badge = tabs.children[i]?.querySelector('button'); const n = counts(m.id); if (badge && n > 0) badge.appendChild(el('span', String(n), 'badge warn')); } $('step3-status').textContent = `계산됨 · 경고 ${warnings.length}`; renderMaterial(); // 전단철근 구간 표 (시안 ③ 코핑 탭): segments of C9 / C10 / C11 along the coping, in metres. if (state.rebarMember === 'coping') { const seg = shearSegments(pier.model.copingL * 1000, pier.model.columns.map((c) => c.s * 1000), pier.model.copingGeom); const st = el('table', undefined, 'grid-table rebar-table'); const sh = el('thead'); const shr = el('tr'); for (const h of ['구간', '시작 (m)', '끝 (m)', '간격 (mm)', '개수', '번호']) shr.appendChild(el('th', h)); sh.appendChild(shr); st.appendChild(sh); const sb = el('tbody'); for (const g of seg) { const tr = el('tr'); tr.append( el('td', g.segment), el('td', (g.from / 1000).toFixed(3)), el('td', (g.to / 1000).toFixed(3)), el('td', String(g.spacing)), el('td', String(g.count)), el('td', g.bar), ); sb.appendChild(tr); } st.appendChild(sb); box.appendChild(el('p', '전단철근 구간 (끝면 기준, 철근 위치의 처음과 끝)', 'lbl')); box.appendChild(st); } // Table and 3D model point at each other: the focused bar is highlighted in both. renderQuantityLink(box, rows); rebar3d?.show(state.rebarSelection.size ? state.rebarSelection : null, state.rebarIsolate); const focused = box.querySelector('tr.hl'); if (focused && state.rebarSelection.size) focused.scrollIntoView({ block: 'center' }); } /** ③ keeps only a link to the 철근재료표 screen ④ (승인 2026-10-23). */ function renderQuantityLink(box: HTMLElement, rows: RebarRow[]): void { const q = buildQuantity(rows, state.material); const line = el('p', `철근재료표 · 합계 ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg`, 'pnote'); const link = el('button', '④ 재료표에서 보기', 'btn small'); link.type = 'button'; link.addEventListener('click', () => setStage('quantity')); box.append(line, link); } /** ④ 재료표: the 철근재료표 of each target pier, bars by 강종 → 직경 → 번호, with subtotals and the totals. */ /** The piers the 재료표 shows: 전체, or the pier chosen in the list. */ function quantityTargets(): Pier[] { const names = new Set(quantityTargetNames(state.piers.map((p) => ({ name: p.input.name, hasModel: p.model !== null })), state.quantityAll, state.active)); return state.piers.filter((p) => names.has(p.input.name)); } /** Length cell of a 재료표 line: the length, or 여러 길이 with the pieces on hover. */ function lengthCell(l: QuantityLine): HTMLTableCellElement { if (l.length !== null) return el('td', String(Math.round(l.length * 10) / 10)); const cell = el('td', `여러 길이 (${l.pieces.length}종)`); cell.title = l.pieces.map((p) => `${Math.round(p.length)} × ${p.count}`).join(' · '); return cell; } function quantityTable(q: Quantity): HTMLTableElement { const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); for (const h of ['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); for (const g of q.groups) { for (const d of g.diameters) { for (const l of d.lines) { body.appendChild(el('tr')).append( el('td', `SD${g.grade}`), el('td', l.name, 'bar-no'), el('td', l.shape), el('td', l.size), el('td', String(l.count)), lengthCell(l), el('td', l.totalM.toFixed(1)), el('td', l.kgPerM.toFixed(3)), el('td', l.kg.toFixed(1)), ); } body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', ''), el('td', `${d.size} 소계`), el('td', ''), el('td', d.size), el('td', ''), el('td', ''), el('td', d.totalM.toFixed(1)), el('td', ''), el('td', d.kg.toFixed(1))); } body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1))); } table.appendChild(body); return table; } function renderQuantityScreen(): void { const body = $('quantity-body'); body.replaceChildren(); renderPierRows($('quantity-pier-items'), 'quantity'); const allButton = $('quantity-all'); const generated = state.piers.filter((p) => p.model).length; allButton.textContent = `전체 합계 (${generated}기)`; allButton.setAttribute('aria-pressed', String(state.quantityAll)); const targets = quantityTargets(); $('step4-status').textContent = targets.length ? (state.quantityAll ? `${targets.length}기` : targets[0].input.name) : '예정'; $('quantity-note').textContent = targets.length ? `${state.quantityAll ? `전체 ${targets.length}기` : targets[0].input.name} · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 교각 목록에서 고른 교각만 보며, 전체를 누르면 모든 교각을 보여 줍니다.` : '계산된 교각이 없습니다. 교각을 생성하세요.'; const entries = targets.flatMap((pier) => pier.model ? [{ pier, q: buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material) }] : [], ); // 전체 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) { body.appendChild(el('h3', `${pier.input.name} · ${pier.input.type}`)); if (q.missing.length) body.appendChild(el('p', `길이 미산정 ${q.missing.length}개는 합계에서 뺐습니다: ${q.missing.join(', ')}`, 'muted small')); body.appendChild(quantityTable(q)); body.appendChild(el('p', `합계 ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg (${q.ton.toFixed(3)} ton) · 커플러 ${q.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음'}`, 'pnote')); } } /** 교량 전체 집계: the 강종·직경 totals over every pier on ④ (전체). */ function bridgeTotalBlock(total: QuantityTotal, pierCount: number): HTMLElement { const wrap = el('section', undefined, 'quantity-total'); wrap.appendChild(el('h3', `교량 전체 집계 · ${pierCount}기`)); const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); for (const h of ['강종', '직경', '개수', '총길이 (m)', '총중량 (kg)']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); for (const r of total.rows) { body.appendChild(el('tr')).append(el('td', `SD${r.grade}`), el('td', r.size), el('td', String(r.count)), el('td', r.totalM.toFixed(1)), el('td', r.kg.toFixed(1))); } for (const g of total.gradeKg) { body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1))); } table.appendChild(body); wrap.appendChild(table); const couplers = total.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음'; const missing = total.missingCount ? ` · 길이 미산정 ${total.missingCount}개 제외` : ''; wrap.appendChild(el('p', `합계 ${total.kg.toFixed(1)} kg · 할증 포함 ${total.kgWithWaste.toFixed(1)} kg (${total.ton.toFixed(3)} ton) · 커플러 ${couplers}${missing}`, 'pnote')); return wrap; } /** Excel: one sheet per pier and a 합계 sheet (승인 2026-10-23). */ function exportQuantityWorkbook(): void { const targets = quantityTargets(); if (!targets.length) return; const wb = XLSX.utils.book_new(); const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton']]; for (const pier of targets) { if (!pier.model) continue; const q = buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material); const sheet = pier.input.name.replace(/[[\]:*?/\\]/g, '_').slice(0, 31); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(quantityRows(q)), sheet); summary.push([pier.input.name, Math.round(q.kg * 10) / 10, Math.round(q.kgWithWaste * 10) / 10, Math.round(q.ton * 1000) / 1000]); } XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), '합계'); XLSX.writeFile(wb, `${state.project.bridge || '교량'}_철근재료표.xlsx`); } $('quantity-all').addEventListener('click', () => { state.quantityAll = true; renderAll(); }); $('quantity-export').addEventListener('click', exportQuantityWorkbook); $('quantity-print').addEventListener('click', () => window.print()); /** Above the 철근표: selection count, select-all / clear for the shown member, and 선택만 보기 (승인 2026-10-23). */ function rebarSelectBar(names: string[]): HTMLDivElement { const bar = el('div', undefined, 'rebar-select'); const picked = names.filter((n) => state.rebarSelection.has(n)).length; bar.appendChild(el('span', `선택 ${picked} / ${names.length}`, 'muted small')); const all = el('button', picked === names.length && names.length > 0 ? '전체 해제' : '전체 선택', 'btn small'); all.type = 'button'; all.addEventListener('click', () => { state.rebarSelection = picked === names.length ? new Set() : new Set(names); renderAll(); }); const iso = el('button', '선택만 보기', 'btn small'); iso.type = 'button'; iso.setAttribute('aria-pressed', String(state.rebarIsolate)); iso.addEventListener('click', () => { state.rebarIsolate = !state.rebarIsolate; renderAll(); }); bar.append(all, iso); return bar; } /** 구분 (role) of each bar, as in the 시안 ③ table. */ const ROLE: Record = { S1: '받침블럭', S2: '받침블럭', C1: '주철근', C2: '주철근', C3: '주철근', C4: '주철근', C5: '주철근', C6: '주철근', C7: '주철근', C8: '주철근', C9: '전단·띠', C10: '전단·띠', C11: '전단·띠', C12: '측면 수평', C13: '측면 수평', C14: '직각 수평', C15: '계단부', C16: '계단부', P1: '주철근', P2: '주철근', '주철근 커플러': '조립', '띠철근 커플러': '조립', 커플러: '조립', P3: '띠철근', P5: '띠철근', P4: '띠철근', P6: '크로스타이', B1: '주철근', B2: '주철근', B3: '주철근', B4: '주철근', B5: '주철근', B6: '주철근', B7: '주철근', B8: '주철근', 'B10-1': '측면 수평', 'B10-2': '측면 수평', B11: '조립 수평', B12: '조립 수평', B9: '전단·띠', }; /** 근거 badge (시안 ③): 입력 / 기본값 / 계산 / 가정. */ function basisCell(b: RebarRow['basis']): HTMLElement { const label = { input: '입력', default: '기본값', calc: '계산', assumed: '가정' }[b]; return el('td', label, `basis basis-${b}`); } /** ③ 공통 설정 inputs. Changes apply on 'change' and regenerate the rebar screen. */ function renderMaterial(): void { const m = state.material; const assumed = isAssumedMaterial(m); $('mat-fck').value = String(m.fck); for (const k of ['d', 'h', 's'] as const) { $(`mat-grade-${k}`).value = String(m.grade[k.toUpperCase() as GradeKey]); } for (const k of ['small16', 'main25', 'main35', 'main51'] as const) { $(`bend-${k}`).value = String(m.bend[k]); } $('mat-waste').value = String(m.waste); $('mat-note').textContent = assumed ? 'fck 가정값 (I02): 입력을 바꾸면 정착·이음 길이가 달라집니다.' : '입력값을 사용합니다.'; } /** Sets or clears one member's fck. An empty value clears it, so the member uses the 공통 fck again. */ function setMemberFck(member: FckMember, raw: string): void { const next = { ...state.fckByMember }; if (raw.trim() === '') { // 되돌리기: back to the imported default, not to the 공통 value. next[member] = IMPORTED_FCK[member]; } else { const value = Number(raw); if (!Number.isFinite(value) || value <= 0) { setError('부재별 fck는 0보다 큰 숫자여야 합니다.'); renderPlan(); return; } next[member] = value; } state.fckByMember = next; setupScene(); renderAll(); } /** fck row on a ② card for a member that has its own fck: value, the 공통 value beside it, and a revert when set. */ function renderMemberFck(box: HTMLDivElement, member: FckMember): void { const own = state.fckByMember[member]; // Orange = differs from the imported default. fck changes only the rebar, so it is not counted in 변경 n건. const changed = fckChanged(state.fckByMember, member); const row = el('div', undefined, changed ? 'edit-row changed' : 'edit-row'); const id = `member-fck-${member}`; const label = el('label', 'fck (MPa)'); label.htmlFor = id; const input = el('input'); input.id = id; input.type = 'number'; input.step = 'any'; input.value = String(own ?? state.material.fck); input.addEventListener('change', () => setMemberFck(member, input.value)); row.append(label, input, el('span', `기본 ${IMPORTED_FCK[member]} · 배근에만 반영`, 'muted small')); if (changed) { const revert = el('button', '되돌리기', 'btn small'); revert.type = 'button'; revert.addEventListener('click', () => setMemberFck(member, '')); row.appendChild(revert); } box.appendChild(row); } /** 재료표 할증 (%) on 공통 설정. Zero or more. */ function setWaste(raw: string): void { const value = Number(raw); if (!Number.isFinite(value) || value < 0) { setError('재료표 할증은 0 이상의 숫자여야 합니다.'); renderMaterial(); return; } state.material = { ...state.material, waste: value }; renderAll(); } /** Bend factor (× diameter) input on 공통 설정. Must be a positive number. */ function setBend(key: keyof BendFactors, raw: string): void { const value = Number(raw); if (!Number.isFinite(value) || value <= 0) { setError('굽힘 내면반경 배수는 0보다 큰 숫자여야 합니다.'); renderMaterial(); return; } state.material = { ...state.material, bend: { ...state.material.bend, [key]: value } }; setupScene(); renderAll(); } function setMaterial(key: 'fck' | GradeKey, raw: string): void { const value = Number(raw); const valid = key === 'fck' ? Number.isFinite(value) && value > 0 : (GRADE_CHOICES as readonly number[]).includes(value); if (!valid) { setError(key === 'fck' ? '공통 설정 fck는 0보다 큰 숫자여야 합니다.' : '철근 강도는 SD300 · SD400 · SD500 중 하나여야 합니다.'); renderMaterial(); return; } state.material = key === 'fck' ? { ...state.material, fck: value } : { ...state.material, grade: { ...state.material.grade, [key]: value } }; // The 3D bars depend on the material too (their lengths), so the scene is rebuilt with it. setupScene(); renderAll(); } /** * 받침 배치 (승인 2026-10-26, read-only): C4·C5 from the model (coping edge ↔ bearing centre, 교각축 방향) and the * eccentricity of each front–back pair (일체 allowed up to 100 mm, otherwise 분리 first). */ /** 상세 보기 pair shown in the plan (screen state only, 승인 2026-10-26). */ let bearingPairIndex = 0; /** ③ 받침블럭 view: the C–C section or the plan (승인 2026-10-26). */ let bearingRebarView: 'section' | 'plan' = 'section'; function renderBearingLayout(wrap: HTMLDivElement, pier: Pier): void { if (!pier.model) return; // 상세 보기 (승인 2026-10-26): pair chips (①·⑥ …), then the 삽도 of the pair (plan, section, solplate). Values are the model's (2026-11-03). const pairList = pairEccentricities(pier.model.blocks); if (bearingPairIndex >= pairList.length) bearingPairIndex = 0; wrap.appendChild(el('p', '상세 보기 (전열 · 후열 쌍)', 'lbl')); const chips = el('div', undefined, 'opts'); pairList.forEach((p, i) => { const chip = el('button', `${PAIR_CHIP_LABELS[p.frontNo - 1] ?? p.frontNo}·${PAIR_CHIP_LABELS[p.backNo - 1] ?? p.backNo}`, 'btn small'); chip.type = 'button'; chip.setAttribute('aria-pressed', String(i === bearingPairIndex)); chip.addEventListener('click', () => { bearingPairIndex = i; renderAll(); }); chips.appendChild(chip); }); wrap.appendChild(chips); const current = pairList[bearingPairIndex]; if (current) { // 삽도 set: the plan on the left and the section on its right (the solplate table carries its values). const sketch = el('div', undefined, 'bearing-sketch'); const fig = el('figure', undefined, 'ground-fig plan-fig'); fig.innerHTML = bearingPlanSvg(pier.model, bearingPairIndex); fig.appendChild(el('figcaption', '교량받침 삽도 · 평면 (방위각 회전 생략) · C1–C3 교축, C4·C5 교각축, C6 블록 폭', 'muted small')); sketch.appendChild(fig); const section = el('figure', undefined, 'ground-fig'); section.innerHTML = bearingSectionSvg(pier.model, bearingPairIndex); section.appendChild(el('figcaption', '교량받침 삽도 · 단면 · E = h1 + h2 + h3 + h4 · S1 = 받침중심 ↔ 코핑 연단, S2 = 받침 사이', 'muted small')); sketch.appendChild(section); wrap.appendChild(sketch); } const pairs = pairEccentricities(pier.model.blocks); const forced = pairs.filter((p) => !p.allowed).length; wrap.appendChild(el('p', `쌍 편심 (전열 k · 후열 k, 교각축 방향, 한계 ${PAIR_ECCENTRICITY_LIMIT_MM} mm): 최대 ${Math.max(...pairs.map((p) => p.eccentricityMm)).toFixed(0)} mm · ${forced === 0 ? '모든 쌍 일체 가능' : `${forced}쌍은 편심 초과 → 분리 우선`}`, 'muted small')); } /** Text of one tracked value in the 변경 list. */ function valueText(key: EditableKey, v: unknown): string { if (v === undefined || v === null) return '없음'; if (key === 'strataRows') { const rows = v as StrataRow[]; return rows.length ? rows.map((r) => `${r.name} ${r.bottomEl}`).join(' / ') : '없음'; } if (typeof v === 'boolean') return v ? '예' : '아니오'; if (typeof v === 'object') return JSON.stringify(v); return String(v); } /** Sets 받침 배치 (분리 / 일체) in the draft (applied by 다시 생성). */ function editBearingLayout(name: string, value: '분리' | '일체'): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; p.draft = { ...p.draft, bearingLayout: value }; renderAll(); } /** Sets one 지반·지층 input in the draft (applied to the model by 다시 생성). */ function editGround(name: string, key: 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl', value: unknown): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; const numeric = key !== 'groundPosition' && key !== 'groundWaterOn'; if (numeric && value !== undefined && !Number.isFinite(value)) { setError(`${FIELD_LABELS[key]}은(는) 숫자로 입력하세요. 값을 되돌렸습니다.`); renderAll(); return; } p.draft = { ...p.draft, [key]: value }; renderAll(); } function setStrataRows(name: string, rows: StrataRow[]): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; p.draft = { ...p.draft, strataRows: rows }; renderAll(); } /** * ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력). * The ground model import and the 지반고 apply stay at the bottom, as before. */ function renderGroundModel(box: HTMLDivElement, pier: Pier): void { const name = pier.input.name; const d = pier.draft; const wrap = el('div', undefined, 'ground-model'); // 위치 is the 형상 옵션 of this member (the chips above the card); only the default is selectable until Q6. const position = d.groundPosition ?? '육상'; if (position === '하상') { // 하상 (승인 2026-10-26): the 설계홍수위 DHWL and the 세굴 깊이 replace the groundwater; the groundwater value is kept. wrap.appendChild(el('p', '설계홍수위 DHWL · 세굴', 'lbl')); const scourRow = el('div', undefined, 'ground-water'); scourRow.style.display = 'flex'; scourRow.style.flexWrap = 'wrap'; scourRow.style.alignItems = 'center'; scourRow.style.gap = '8px'; for (const [key, label, value] of [['floodLevelEl', '설계홍수위 EL (m)', d.floodLevelEl], ['scourDepthM', '세굴 깊이 (m)', d.scourDepthM]] as const) { const input = el('input'); input.type = 'number'; input.step = 'any'; input.placeholder = label; input.value = value === undefined ? '' : String(value); input.addEventListener('change', () => editGround(name, key, input.value === '' ? undefined : Number(input.value))); scourRow.append(el('span', label, 'muted small'), input); } wrap.appendChild(scourRow); } else if (position === '해상') { // 해상 (승인 2026-10-26, 기준면 MSL): 고조위·저조위와 세굴. 파랑과 선박 하중은 이번 범위에서 제외. wrap.appendChild(el('p', `해상 · 기준면 ${SEA_DATUM} · 지반고 칸은 해저면 EL`, 'lbl')); const seaRow = el('div', undefined, 'ground-water'); seaRow.style.display = 'flex'; seaRow.style.flexWrap = 'wrap'; seaRow.style.alignItems = 'center'; seaRow.style.gap = '8px'; for (const [key, label, value] of [['highWaterEl', '고조위 HWL (m)', d.highWaterEl], ['lowWaterEl', '저조위 LWL (m)', d.lowWaterEl], ['scourDepthM', '세굴 깊이 (m)', d.scourDepthM]] as const) { const input = el('input'); input.type = 'number'; input.step = 'any'; input.placeholder = label; input.value = value === undefined ? '' : String(value); input.addEventListener('change', () => editGround(name, key, input.value === '' ? undefined : Number(input.value))); seaRow.append(el('span', label, 'muted small'), input); } wrap.appendChild(seaRow); wrap.appendChild(el('p', `부식 여유 기준 ${CORROSION_ALLOWANCE_MM} mm (확인 필요) · 파랑과 선박 하중은 이번 범위에서 제외`, 'muted small')); } else { wrap.appendChild(el('p', '지하수위', 'lbl')); const water = el('div', undefined, 'ground-water'); water.style.display = 'flex'; water.style.flexWrap = 'wrap'; water.style.alignItems = 'center'; water.style.gap = '8px'; const check = el('input'); check.type = 'checkbox'; check.checked = d.groundWaterOn === true; check.id = 'ground-water-on'; check.addEventListener('change', () => editGround(name, 'groundWaterOn', check.checked)); const checkLabel = el('label', ' 지하수위 고려'); checkLabel.prepend(check); const level = el('input'); level.type = 'number'; level.step = 'any'; level.id = 'ground-water-el'; level.placeholder = '지하수위 EL (m)'; level.value = d.groundWaterEl === undefined ? '' : String(d.groundWaterEl); level.disabled = !check.checked; level.addEventListener('change', () => editGround(name, 'groundWaterEl', level.value === '' ? undefined : Number(level.value))); water.append(checkLabel, level, el('span', 'm', 'muted small')); wrap.appendChild(water); } // 지층 행 (승인 2026-10-26): one row per layer, from the top down; the EL is the layer's bottom EL. const rows = d.strataRows ?? []; wrap.appendChild(el('p', `지층 행 (위에서 아래 · 하단 EL m · 지반고 ${d.groundEl.toFixed(3)} 아래)`, 'lbl')); const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); for (const h of ['지층', '하단 EL (m)', 'N값 (타수 · 타수/관입량 mm)', '']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); rows.forEach((row, i) => { const tr = el('tr'); const select = el('select'); for (const s of STRATA_NAMES) { const o = el('option', s); o.value = s; select.appendChild(o); } if (!(STRATA_NAMES as readonly string[]).includes(row.name)) { const o = el('option', row.name); o.value = row.name; select.appendChild(o); } select.value = row.name; select.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, name: select.value } : r)))); const bottomInput = el('input'); bottomInput.type = 'number'; bottomInput.step = '0.001'; bottomInput.value = String(row.bottomEl); bottomInput.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, bottomEl: Number(bottomInput.value) } : r)))); // N값 as text: a blow count or "타수/관입량 mm" (승인 2026-10-26). Empty means not known yet. const nInput = el('input'); nInput.type = 'text'; nInput.placeholder = '예: 23 · 50/5'; nInput.value = row.nValue ?? ''; nInput.addEventListener('change', () => { const v = nInput.value.trim(); setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, nValue: v === '' ? undefined : v } : r))); }); if (parseNValue(row.nValue).kind === 'invalid') nInput.classList.add('warn-text'); const del = el('button', '삭제', 'btn small'); del.type = 'button'; del.addEventListener('click', () => setStrataRows(name, rows.filter((_, j) => j !== i))); tr.append(...[select, bottomInput, nInput, del].map((x) => { const td = el('td'); td.appendChild(x); return td; })); body.appendChild(tr); }); table.appendChild(body); wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table); const add = el('button', '지층 행 추가', 'btn small'); add.type = 'button'; add.addEventListener('click', () => { const last = rows[rows.length - 1]; const next = last ? (STRATA_NAMES as readonly string[])[Math.min((STRATA_NAMES as readonly string[]).indexOf(last.name) + 1, STRATA_NAMES.length - 1)] : STRATA_NAMES[0]; setStrataRows(name, [...rows, { name: next, bottomEl: Number(((last?.bottomEl ?? d.groundEl) - 2).toFixed(3)) }]); }); wrap.appendChild(add); // 지반 모델의 지층 기둥 (Python, 시추 자료가 있을 때): one click fills the rows (승인 2026-10-26). if (groundModel) { const modelRows = strataRowsFor(groundModel, name); if (modelRows) { const fromModel = el('button', `지반 모델의 지층 ${modelRows.length}개 불러오기`, 'btn small'); fromModel.type = 'button'; fromModel.addEventListener('click', () => setStrataRows(name, modelRows.map((r) => ({ ...r })))); wrap.appendChild(fromModel); } else { wrap.appendChild(el('p', '지반 모델에 이 교각의 지층이 없습니다 (시추 자료가 있으면 만들어집니다).', 'muted small')); } } // 단면 (삽도): the footings and piles are the generated model; the layers and water level are the inputs. if (pier.model) { const fig = el('figure', undefined, 'ground-fig'); fig.innerHTML = groundSectionSvg(d, pier.model); fig.appendChild(el('figcaption', '단면 · 기초와 말뚝은 생성본, 지층과 지하수위는 입력값', 'muted small')); wrap.appendChild(fig); } // ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button. wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl')); const importInput = el('input'); importInput.type = 'file'; importInput.accept = '.json'; importInput.id = 'ground-import'; importInput.addEventListener('change', async () => { const file = importInput.files?.[0]; if (!file) return; try { const imported = parseGroundModel(await file.text()); await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage)); groundModel = imported; groundMapMode = groundModel.mapLayers?.length ? 'satellite' : 'none'; setGroundMapMode(groundMapMode); state.visible.ground = true; const mapStatus = groundModel.mapLayers?.length ? '2D 지도·위성사진을 지반면 위에 굴곡대로 표시합니다.' : '포함된 지도 이미지가 없습니다.'; setStatus(`${file.name}에서 교각 ${groundModel.piers.length}개의 지반 모델 값을 불러왔습니다. 3D 지반 표시를 켰습니다. ${mapStatus}`); setupScene(); renderAll(); } catch (err) { setError(`지반 모델을 읽지 못했습니다: ${(err as Error).message}`); } finally { importInput.value = ''; } }); const importLabel = el('label', '지반 모델 파일 불러오기 (json)'); importLabel.appendChild(importInput); wrap.appendChild(importLabel); if (!groundModel) { wrap.appendChild(el('p', '지반 모델을 불러오면 이 교각의 지반고를 엑셀 값과 비교하고 적용할 수 있습니다.', 'muted small')); } else { const entry = groundModel.piers.find((q) => q.id === pier.input.name); const center = groundElFor(groundModel, pier.input.name); if (!entry) { wrap.appendChild(el('p', `지반 모델에 ${pier.input.name} 교각이 없습니다.`, 'warn-text')); } else if (center === undefined) { wrap.appendChild(el('p', '지형 자료 범위 밖이라 지반 모델 값이 없습니다. 입력된 지반고를 그대로 씁니다.', 'warn-text')); } else { const diff = entry.excelEl === null ? '—' : `${center - entry.excelEl >= 0 ? '+' : ''}${(center - entry.excelEl).toFixed(3)} m`; wrap.appendChild(el('p', `지형 계산 ${center.toFixed(3)} m · 엑셀 ${entry.excelEl?.toFixed(3) ?? '—'} m · 차이 ${diff}`)); const apply = el('button', `지반고 ${center.toFixed(3)} m 적용`, 'btn small'); apply.type = 'button'; apply.disabled = pier.draft.groundEl === center; apply.addEventListener('click', () => editField(pier.input.name, 'groundEl', String(center))); wrap.appendChild(apply); } wrap.appendChild(el('p', '불러온 지반 모델의 지반고는 3D에 바로 표시됩니다. [적용]은 설계 입력값을 변경하며, 변경한 입력으로 형상을 보려면 다시 생성하세요.', 'muted small')); } box.appendChild(wrap); } function renderBearingRef(box: HTMLDivElement, pier: Pier): void { const wrap = el('div', undefined, 'bearing-ref'); // 받침 종류·용량 (승인 2026-10-26: 글자 입력, 기본 탄성받침). Saved in the project; no rule yet, so it does not change the model. wrap.appendChild(el('p', '받침 종류·용량 (글자 입력 · 기본 탄성받침)', 'lbl')); const typeInput = el('input'); typeInput.type = 'text'; typeInput.id = 'bearing-type'; typeInput.value = pier.draft.bearingType ?? '탄성받침'; typeInput.addEventListener('change', () => { const v = typeInput.value.trim(); const p = state.piers.find((q) => q.input.name === pier.input.name); if (!p) return; p.draft = { ...p.draft, bearingType: v === '' ? undefined : v }; renderAll(); }); wrap.appendChild(typeInput); renderBearingLayout(wrap, pier); renderSolplateRule(wrap, pier); renderElevationTable(wrap, pier); renderSolplateTable(wrap, pier); box.appendChild(wrap); } /** 교량받침 ELEVATION TABLE (모델값, mm · EL m, 2026-11-03): one row per bearing, from the model only. */ function renderElevationTable(wrap: HTMLDivElement, pier: Pier): void { if (!pier.model) return; wrap.appendChild(el('p', '교량받침 ELEVATION TABLE (모델값 · mm · EL m)', 'lbl')); const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); for (const h of ['No', '열', 'EL.X', '교좌면 EL', 'h1 블록', 'h2 몰탈', 'h3 받침', 'h4 솔플', 'E', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'S1 (본수·길이)', 'S2 (본수·길이)']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); const dash = (v: number | null) => (v === null ? '—' : String(v)); const bars = (b: BarSummary | null) => (b === null ? '—' : `${b.count} · ${b.length}`); for (const r of elevationRows(pier.model)) { const tr = el('tr'); const cells = [String(r.no), r.row, r.elx.toFixed(3), r.seatEl.toFixed(3), String(r.h1), String(r.h2), String(r.h3), String(r.h4), String(r.E), String(r.C1), dash(r.C2), dash(r.C3), String(r.C4), String(r.C5), String(r.C6), bars(r.S1), bars(r.S2)]; for (const c of cells) tr.appendChild(el('td', c)); body.appendChild(tr); } table.appendChild(body); wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table); } /** 솔플레이트 TABLE (모델값, mm, 2026-11-03): S, L, W, T, T1, T2 and the check T1 − T2 ≈ L·|S|/100. */ function renderSolplateTable(wrap: HTMLDivElement, pier: Pier): void { if (!pier.model) return; wrap.appendChild(el('p', '솔플레이트 TABLE (모델값 · mm)', 'lbl')); const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); for (const h of ['No', 'S (%)', 'L (교직)', 'W (교축)', 'T (중심)', 'T1 (−측)', 'T2 (+측)', '검산 T1 − T2 ≈ L·|S|/100']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); for (const r of solplateRows(pier.model)) { const tr = el('tr'); const cells = [String(r.no), r.S.toFixed(2), String(r.L), String(r.W), String(r.T), r.T1.toFixed(1), r.T2.toFixed(1), `${r.checkLhs.toFixed(1)} ≈ ${r.checkRhs.toFixed(1)} ${r.ok ? '일치' : '불일치'}`]; for (const c of cells) tr.appendChild(el('td', c)); body.appendChild(tr); } table.appendChild(body); wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table); } /** Sets one pier's solplate rule (C안, 2026-11-03) and rebuilds that pier. */ function setSolplateRule(name: string, rule: SolplateRule): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; p.draft = { ...p.draft, solplate: rule }; state.piers = state.piers.map((q) => (q === p ? regenerateEntry(q) : q)); setupScene(); renderAll(); } /** 솔플레이트 규칙 (C안, 2026-11-03): per pier. The plan L × W and the slope S are inputs; 받침높이 H is fixed (211 mm, not on the UI). T and h3 follow. */ function renderSolplateRule(wrap: HTMLDivElement, pier: Pier): void { const rule = pier.draft.solplate ?? SOLPLATE_RULE_DEFAULT; wrap.appendChild(el('p', `솔플레이트 규칙 (이 교각 · 기본 1000 × 1100 · −0.5 %) · 받침높이 H ${rule.heightMm} mm 고정 (솔플레이트 포함)`, 'lbl')); const fields: [keyof SolplateRule, string][] = [ ['lengthMm', '솔플레이트 교직 L (mm)'], ['widthMm', '솔플레이트 교축 W (mm)'], ['slopePct', '종단 기울기 S (%)'], ]; const grid = el('div', undefined, 'edit-row'); for (const [key, label] of fields) { const id = `sol-${key}`; const lab = el('label', label); lab.htmlFor = id; const input = el('input'); input.id = id; input.type = 'number'; input.step = 'any'; input.value = String(rule[key]); if (rule[key] !== SOLPLATE_RULE_DEFAULT[key]) input.classList.add('changed-input'); input.addEventListener('change', () => setSolplateRule(pier.input.name, { ...rule, [key]: Number(input.value) })); grid.append(lab, input, el('span', `기본 ${SOLPLATE_RULE_DEFAULT[key]}`, 'muted small')); } wrap.appendChild(grid); const issues = solplateRuleIssues(rule); if (issues.length > 0) { wrap.appendChild(el('p', issues.join(' '), 'warn-text')); return; } const plan = solplatePlan(rule); wrap.appendChild(el('p', `중심 두께 T = ${plan.T} mm (T2 ≥ 22 mm) · 받침 본체 h3 = H − T = ${plan.h3} mm · T1 = ${plan.T1.toFixed(1)} mm · T2 = ${plan.T2.toFixed(1)} mm`, 'muted small')); } /** ② 코핑 상세 (2단계, 읽기 전용): d_IP2 and the 계단 구간 표 from the model (승인 2026-10-23). */ function renderCopingInfo(box: HTMLDivElement, pier: Pier): void { const m = pier.model; if (!m) return; const wrap = el('div', undefined, 'coping-info'); wrap.appendChild(el('p', `d_IP2 (자동) ${m.dIp2.toFixed(3)} m`, 'num')); wrap.appendChild(el('p', '계단 구간 (BP1 기준 m · EL m · mm)', 'lbl')); const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); for (const h of ['구간', '시작', '끝', '전열 상면 EL', '후열 상면 EL', '횡방향 단차 (mm)', '받침블럭 높이 전열 / 후열 (mm)']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); const front = m.blocks.filter((b) => b.row === '전열'); const back = m.blocks.filter((b) => b.row === '후열'); const unequalRows = front.length !== back.length; const rearSteps = m.backSegments ?? m.segments; const topAt = (steps: typeof m.segments, s: number): number => (steps.find(seg => s >= seg.s0 && s < seg.s1) ?? steps[steps.length-1]).top; const boundaries = [...new Set([0,m.copingL,...m.segments.slice(1).map(seg=>seg.s0),...rearSteps.slice(1).map(seg=>seg.s0)])].sort((a,b)=>a-b); const rows = unequalRows ? boundaries.slice(1).map((s1,k)=>({s0:boundaries[k],s1,top:topAt(m.segments,(boundaries[k]+s1)/2)})) : m.segments; rows.forEach((seg, k) => { const f = unequalRows ? front.find(b=>b.g>=seg.s0 && b.gb.g>=seg.s0 && b.g('members'); members.replaceChildren(); for (const m of MEMBERS) { const li = el('li'); const btn = el('button', m.label, 'member'); btn.type = 'button'; btn.setAttribute('aria-pressed', String(m.id === state.member)); const changed = pier ? overrideKeys(pier).filter((k) => m.keys.includes(k)).length : 0; btn.append(el('span', changed ? `변경 ${changed}` : '변경 0', 'badge')); btn.addEventListener('click', () => { state.member = m.id; renderAll(); }); li.appendChild(btn); members.appendChild(li); } const member = MEMBERS.find((m) => m.id === state.member)!; $('member-title').textContent = member.label; // Default and current selection are shown separately (spec §5.1). Only defaults are selectable today. // 선택 shows the pier's own 위치 for the ground; the other members still choose only their defaults. const selected = member.id === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : member.id === 'bearing' && pier ? (pier.draft.bearingLayout ?? '분리') : defaultSummary(member.id); $('member-summary').textContent = `기본: ${defaultSummary(member.id)} · 선택: ${selected}`; renderShapeOptions(member.id, pier); $('member-note').textContent = member.note; if (member.id === 'bearing' && pier?.model) { // Row skew of the bearing blocks (계산값): the block sides follow the bearing row (승인 2026-10-23). const front = pier.model.blocks.find((b) => b.row === '전열'); const back = pier.model.blocks.find((b) => b.row === '후열'); $('member-note').textContent += ` 받침 열 기울기(교축 직각 기준, 계산값): 전열 ${front?.rowSkewDeg.toFixed(2) ?? '—'}° · 후열 ${back?.rowSkewDeg.toFixed(2) ?? '—'}°.`; } const regen = $('member-regen'); const fields = $('member-fields'); if (!pier) { fields.replaceChildren(); regen.textContent = '다시 생성 · 변경 0'; regen.disabled = true; } else { fieldRows(fields, pier, member.keys); if (member.id === 'footing') { renderPileSpec(fields, pier); renderFootingDrawings(fields, pier); } if (member.id === 'coping') renderCopingInfo(fields, pier); if (member.id === 'bearing') renderBearingRef(fields, pier); if (member.id === 'ground') renderGroundModel(fields, pier); if (FCK_MEMBERS.includes(member.id as FckMember)) renderMemberFck(fields, member.id as FckMember); regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`; regen.disabled = pendingKeys(pier).length === 0; } const changes = $('changes'); changes.replaceChildren(); const pending = pier ? pendingKeys(pier) : []; for (const key of pier ? overrideKeys(pier) : []) { const li = el('li', `${FIELD_LABELS[key]}: 기본 ${valueText(key, pier!.defaults[key])} → 현재 ${valueText(key, pier!.draft[key])}${pending.includes(key) ? ' (미반영)' : ''}`); const revert = el('button', '되돌리기', 'btn small'); revert.type = 'button'; revert.addEventListener('click', () => revertField(pier!.input.name, key)); li.appendChild(revert); changes.appendChild(li); } if (changes.childElementCount === 0) changes.appendChild(el('li', '기본값과 같습니다.')); const issues = $('plan-issues'); issues.replaceChildren(); for (const i of pier?.issues ?? []) issues.appendChild(el('li', `${i.code} ${i.message}`)); if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.')); } // --- project file (plan B3·B4·B5) -------------------------------------------------------- /** Everything that a save writes except the history, so that a save itself does not look like a change. */ function snapshot(): string { return JSON.stringify({ project: state.project, source: state.source, piers: state.piers.map((p) => ({ input: p.input, defaults: p.defaults, draft: p.draft })), material: state.material, fckByMember: state.fckByMember, groundModel, abutments: state.abutments, abutDefaults: state.abutDefaults, }); } function isDirty(): boolean { return state.piers.length > 0 && snapshot() !== state.savedSnapshot; } function currentProjectText(): string { return serializeProject({ schemaVersion: SCHEMA_VERSION, project: { ...state.project }, source: state.source, piers: state.piers.map((p) => ({ id: p.input.name, input: p.input, defaults: p.defaults, draft: p.draft })), history: state.history, common: state.material, fckByMember: state.fckByMember, groundModel: groundModel ?? undefined, abutments: state.abutments, abutDefaults: state.abutDefaults, }); } function renderSaveStatus(): void { const saveBtn = $('save-project'); saveBtn.disabled = state.piers.length === 0; const status = $('save-status'); if (state.piers.length === 0) { status.textContent = '저장 전'; } else if (state.savedSnapshot === null || isDirty()) { status.textContent = state.savedSnapshot === null ? '저장 전 · 변경 있음' : '변경 있음 · 미저장'; } else { status.textContent = '저장됨'; } } /** 저장 button and Ctrl+S: the first save opens the project dialog (시안 ⑤); later saves write the same file. */ function exportBearingCoordinatesTxt(): void { const generated = state.piers.filter((p) => p.model !== null); if (!generated.length) { setError('생성된 교각이 없습니다. 엑셀을 불러오고 교각 형상을 생성하세요.'); return; } const f = (value: number): string => value.toFixed(6); const lines: string[] = [ '교량 받침 좌표 정보', `좌표계: 엑셀 원본 좌표 (X=북, Y=동), 방위각도 엑셀 원본 기준`, `생성 교각 수: ${generated.length}`, '', ]; for (const pier of generated) { const input = pier.input; lines.push(`[교각 ${input.name}]`); lines.push('받침번호\t열\tX(m)\tY(m)\tEL(m)\t방위각(deg)'); const bearings = [...input.bearings].sort((a, b) => a.no - b.no); for (const bearing of bearings) { lines.push([ String(bearing.no), bearing.row, f(bearing.y), f(bearing.x), f(bearing.z), f(swapXYAzimuth(bearing.azimuthDeg)), ].join('\t')); } lines.push(''); } const bridge = (state.project.bridge || generated[0].input.name).replace(/[\\/:*?"<>|]/g, '_'); const filename = `${bridge}_교각받침좌표.txt`; const blob = new Blob(['\uFEFF' + lines.join('\r\n')], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); setStatus(`${filename} 파일을 저장했습니다.`); } function saveProject(): void { if (state.piers.length === 0) return; if (!state.savedOnce) { openSaveDialog(); return; } writeProjectFile(); } function projectFileName(): string { return `${state.project.bridge || state.project.name || '교각설계'}_교각설계.pzr.json`; } function openSaveDialog(): void { $('sv-name').value = state.project.name; $('sv-bridge').value = state.project.bridge; $('sv-client').value = state.project.client; $('sv-author').value = state.project.author; $('sv-memo').value = state.project.memo; $('sv-file').textContent = projectFileName(); $('save-dialog').showModal(); $('sv-name').focus(); } function confirmSaveDialog(): void { const name = $('sv-name').value.trim(); if (!name) { $('sv-name').focus(); setError('프로젝트명을 입력하세요.'); return; } state.project = { name, bridge: $('sv-bridge').value.trim(), client: $('sv-client').value.trim(), author: $('sv-author').value.trim(), crs: CRS, memo: $('sv-memo').value.trim(), }; $('save-dialog').close(); writeProjectFile(); } function writeProjectFile(): void { state.savedOnce = true; state.history = [...state.history, { version: String(state.history.length + 1), note: '저장', at: new Date().toISOString() }]; const text = currentProjectText(); const name = projectFileName(); const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); state.savedSnapshot = snapshot(); clearBackup().catch(() => undefined); renderHeader(); setStatus(`${name} 파일을 저장했습니다.`); } /** Opens a project from its text. A backup stays unsaved until the user saves it. */ function openProjectText(text: string, saved: boolean): void { const parsed = parseProject(text); state.project = { ...parsed.project }; state.source = parsed.source; state.history = parsed.history; state.material = materialFrom(parsed.common); state.fckByMember = { ...parsed.fckByMember }; state.abutments = parsed.abutments ?? []; state.abutDefaults = parsed.abutDefaults ?? JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[]; groundModel = parsed.groundModel ?? null; groundMapMode = groundModel?.mapLayers?.length ? 'satellite' : 'none'; setGroundMapMode(groundMapMode); state.piers = parsed.piers.map((p) => ({ ...buildEntry(p.input, false), defaults: p.defaults ?? p.input, draft: p.draft ?? p.input, })); state.active = state.piers[0]?.input.name ?? null; state.filter = 'all'; state.pending = null; state.savedSnapshot = saved ? snapshot() : null; setupScene(); renderAll(); } async function openProjectFile(file: File): Promise { try { openProjectText(await file.text(), true); state.savedOnce = true; clearBackup().catch(() => undefined); setStatus(`${file.name}을 열었습니다. ${state.piers.length}기를 원본 엑셀 없이 다시 생성했습니다.`); } catch (err) { setError(`프로젝트 파일을 열지 못했습니다: ${(err as Error).message}`); } } function scheduleBackup(): void { window.clearTimeout(backupTimer); backupTimer = window.setTimeout(() => { if (!isDirty()) return; writeBackup(currentProjectText()).catch(() => setError('자동 백업을 저장하지 못했습니다. 브라우저 저장소 설정을 확인하세요.')); }, 800); } async function checkBackup(): Promise { try { const backup = await readBackup(); if (!backup) return; pendingBackup = backup; $('backup-text').textContent = `저장되지 않은 작업이 있습니다 (${new Date(backup.at).toLocaleString('ko-KR')}).`; $('backup-banner').hidden = false; } catch { // Storage unavailable (private window, blocked): no recovery is offered, and the app works without it. } } // --- import dialog (시안 ④) ------------------------------------------------------------ function selectable(p: Pending, e: Pier): boolean { return !!e.model && sheetOk(p.sheets, e.input.sheet); } function renderDialog(): void { const p = state.pending; const sheetsBox = $('sheets'); sheetsBox.replaceChildren(); $('file-name').textContent = p ? `선택된 파일: ${p.fileName}` : ''; if (!p) { sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.')); renderPreview([]); updateApply(); return; } for (const sheet of p.sheets) { const li = el('li'); const head = el('div', undefined); const label = sheet.piers === 0 ? (sheet.ok ? '보조 표 시트 (IP·받침)' : '오류') : sheet.ok ? '선택 가능' : '선택 불가 (오류)'; head.append( el('span', `${sheet.name} · ${label}`, sheet.ok ? 'ok' : 'bad'), document.createTextNode(` · 교각 ${sheet.tables.pier}표 · IP ${sheet.tables.ip}표 · 받침 ${sheet.tables.bearing}표`), ); li.appendChild(head); for (const issue of sheet.issues) { const line = el('div', `${issue.code} ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`, 'small'); line.style.color = issue.level === 'E' ? 'var(--error)' : 'var(--muted)'; li.appendChild(line); } for (const entry of p.entries.filter((e) => e.input.sheet === sheet.name)) { const row = el('div', undefined, 'sheet-pier'); const check = el('input'); check.type = 'checkbox'; check.checked = entry.checked; check.disabled = !selectable(p, entry); check.setAttribute('aria-label', `${entry.input.name} 불러오기`); check.addEventListener('change', () => { entry.checked = check.checked; updateApply(); }); const name = el('span', `${entry.input.name} · ${entry.input.type}`, 'pier-name'); row.append(check, name); li.appendChild(row); } sheetsBox.appendChild(li); } renderPreview(p ? p.entries.map((e) => ({ input: e.input, checked: e.checked })) : []); updateApply(); } /** Preview of every pier in the file: one column per pier for the 교각 정보, all 받침·IP rows with a 교각 column. */ function renderPreview(items: { input: PierInput; checked: boolean }[]): void { const title = $('preview-title'); const pierTable = $('pv-pier'); const bearingBody = $('pv-bearings').tBodies[0]; const ipBody = $('pv-ips').tBodies[0]; pierTable.replaceChildren(); bearingBody.replaceChildren(); ipBody.replaceChildren(); if (items.length === 0) { title.textContent = '미리볼 교각이 없습니다.'; return; } const sheets = [...new Set(items.map((i) => i.input.sheet))].join(', '); title.textContent = `${items.length}기 · 시트 ${sheets}`; const head = pierTable.createTHead().insertRow(); head.appendChild(el('th', '항목')); for (const { input } of items) head.appendChild(el('th', input.name)); const rowsOf = (input: PierInput): Pair[] => [ ['교각형식', input.type], ['두부보 EL (코핑 상면)', f3(input.copingTopEl)], ['기초 하단 EL', f3(input.footingBottomEl)], ['지반고 EL', f3(input.groundEl)], ['기초 형식', input.foundationType], ['말뚝길이 (mm)', String(input.pileLengthMm)], ['두부보 폭 × 길이 (mm)', `${input.copingWidthMm} × ${input.copingLengthMm}`], ['기둥갯수', String(input.columnCount)], ['받침 · IP', `${input.bearings.length} · ${input.ips.length}`], ]; const labels = rowsOf(items[0].input).map(([k]) => k); const body = pierTable.createTBody(); labels.forEach((label, r) => { const tr = body.insertRow(); tr.appendChild(el('th', label)); for (const { input } of items) tr.appendChild(el('td', rowsOf(input)[r][1])); }); const included = body.insertRow(); included.appendChild(el('th', '불러오기')); for (const { checked } of items) included.appendChild(el('td', checked ? '예' : '아니오')); for (const { input } of items) { for (const b of input.bearings) { const tr = el('tr'); const azimuth = Number.isNaN(b.azimuthDeg) ? '오류' : `${b.azimuthDeg.toFixed(4)}°`; for (const v of [input.name, String(b.no), b.x.toFixed(3), b.y.toFixed(3), f3(b.z), azimuth, b.row]) tr.appendChild(el('td', v)); bearingBody.appendChild(tr); } for (const ip of input.ips) { const tr = el('tr'); for (const v of [input.name, ip.name, ip.x.toFixed(3), ip.y.toFixed(3), `${ip.angleDeg}°`]) tr.appendChild(el('td', v)); ipBody.appendChild(tr); } } } function updateApply(): void { const p = state.pending; const count = p ? p.entries.filter((e) => e.checked && selectable(p, e)).length : 0; const total = p ? p.entries.filter((e) => selectable(p, e)).length : 0; $('dlg-selected').textContent = `선택 ${count} / ${total}기`; const named = $('project-name').value.trim().length > 0; const apply = $('dlg-apply'); apply.textContent = `선택한 교각 ${count}기 형상 생성`; apply.disabled = count === 0 || !named; } function loadBytes(bytes: Uint8Array, fileName: string): void { const wb = XLSX.read(bytes, { type: 'array' }); const { piers, sheets } = parseWorkbook(wb); const abut = parseAbutments(wb); const entries = piers.map((input) => buildEntry(input, false)); // A sheet is selectable only if every pier in it builds. Show the reason next to the sheet. for (const sheet of sheets) { const own = entries.filter((e) => e.input.sheet === sheet.name); for (const e of own) sheet.issues.push(...e.issues.filter((i) => i.level === 'E')); sheet.ok = sheet.ok && own.every((e) => e.model !== null); } const pending: Pending = { fileName, entries, sheets, abutments: abut.abutments, abutIssues: abut.issues }; for (const e of entries) e.checked = selectable(pending, e); state.pending = pending; renderDialog(); } function applyImport(): void { const p = state.pending; const name = $('project-name').value.trim(); if (!p || !name) return; state.project = { ...state.project, name, bridge: $('bridge-name').value.trim() }; state.piers = p.entries.filter((e) => e.checked && selectable(p, e)); state.abutments = p.abutments; state.abutDefaults = JSON.parse(JSON.stringify(p.abutments)) as AbutRecord[]; state.active = state.piers[0]?.input.name ?? null; state.filter = 'all'; state.pending = null; state.source = { fileName: p.fileName, sheets: p.sheets.map((sheet) => sheet.name) }; state.history = []; state.savedSnapshot = null; state.savedOnce = false; $('import-dialog').close(); document.querySelector('.landing-overlay')?.classList.remove('modal-open'); const appRoot = document.querySelector('.app'); if (appRoot) { appRoot.hidden = false; appRoot.setAttribute('aria-hidden', 'false'); } const homeButton = document.querySelector('#home-from-app'); if (homeButton) homeButton.hidden = false; document.querySelector('.landing-overlay')?.setAttribute('hidden', 'true'); setupScene(); renderAll(); const abutNote = state.abutments.length > 0 ? ` 교대 ${state.abutments.length}기를 읽었습니다${p.abutIssues.some((i) => i.level === 'E') ? ' (입력 오류 있음)' : ''}.` : ''; setStatus(`${state.piers.length}기 형상을 생성했습니다.${abutNote} 정보·경고는 오른쪽 알림에서 확인하세요.`); } function openImport(): void { const landing = document.querySelector('.landing-overlay'); if (landing && !landing.hidden) return; const dlg = $('import-dialog'); if (!dlg.open) dlg.showModal(); $('project-name').focus(); } async function fetchSample(): Promise { const res = await fetch(new URL('samples/운곡교P1입력.xlsx', document.baseURI)); if (!res.ok) throw new Error(`샘플 응답 ${res.status}`); return new Uint8Array(await res.arrayBuffer()); } declare global { interface Window { loadBytesToApp?: (bytes: Uint8Array, fileName: string) => void; openSampleProject?: () => Promise; openDrawingFlow?: () => void; } } window.loadBytesToApp = loadBytes; window.openSampleProject = async () => { try { const bytes = await fetchSample(); const app = document.querySelector('.app'); const landing = document.querySelector('.landing-overlay'); const homeButton = document.querySelector('#home-from-app'); if (app) { app.hidden = false; app.setAttribute('aria-hidden', 'false'); } if (landing) landing.hidden = true; if (homeButton) homeButton.hidden = false; window.loadBytesToApp?.(bytes, '샘플 P1 (운곡교P1입력.xlsx)'); } catch (error) { setError(error instanceof Error ? error.message : '샘플 교량 모델을 불러오지 못했습니다.'); } }; window.openDrawingFlow = () => { const app = document.querySelector('.app'); const landing = document.querySelector('.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[] { return state.piers.filter((p) => !p.model || pendingKeys(p).length > 0); } function regenerateChanged(): void { const names = new Set(generationTargets().map((p) => p.input.name)); let errors = 0; state.piers = state.piers.map((p) => { if (!names.has(p.input.name)) return p; const next = regenerateEntry(p); if (!next.model) errors++; return next; }); setupScene(); renderAll(); setStatus(`${names.size}기 형상을 생성했습니다.${errors ? ` 오류 ${errors}기는 생성되지 않았습니다.` : ''}`); } // --- events ----------------------------------------------------------------------------- $('open-import').addEventListener('click', openImport); $('dlg-close').addEventListener('click', () => { $('import-dialog').close(); document.querySelector('.landing-overlay')?.classList.remove('modal-open'); }); $('dlg-cancel').addEventListener('click', () => { $('import-dialog').close(); document.querySelector('.landing-overlay')?.classList.remove('modal-open'); }); $('dlg-apply').addEventListener('click', applyImport); $('regen').addEventListener('click', regenerateChanged); $('nav-shape').addEventListener('click', () => setStage('shape')); $('nav-plan').addEventListener('click', () => setStage('plan')); $('nav-rebar').addEventListener('click', () => setStage('rebar')); $('nav-quantity').addEventListener('click', () => setStage('quantity')); $('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value)); $('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value)); for (const k of ['small16', 'main25', 'main35', 'main51'] as const) { $(`bend-${k}`).addEventListener('change', (ev) => setBend(k, (ev.target as HTMLInputElement).value)); } for (const k of ['D', 'H', 'S'] as const) { $(`mat-grade-${k.toLowerCase()}`).addEventListener('change', (ev) => setMaterial(k, (ev.target as HTMLSelectElement).value)); } $('member-regen').addEventListener('click', () => { const pier = activePier(); if (pier) regenerateOne(pier.input.name); }); $('cp-items-reset').addEventListener('click', () => { const src = activePier(); copyItemChoice = new Set(src ? copyItemsWithOverrides(src) : []); renderCopyItems(); renderCopyPreview(); }); $('cp-clear').addEventListener('click', () => { copySelection = new Set(); renderCopyTargets(); renderCopyPreview(); }); $('cp-regen').addEventListener('change', renderCopyPreview); $('cp-apply').addEventListener('click', applyCopyPanel); $('sv-save').addEventListener('click', confirmSaveDialog); $('sv-cancel').addEventListener('click', () => $('save-dialog').close()); $('sv-close').addEventListener('click', () => $('save-dialog').close()); $('edit-regen').addEventListener('click', () => { const pier = activePier(); if (pier) regenerateOne(pier.input.name); }); $('project-name').addEventListener('input', updateApply); $('pick-file').addEventListener('click', () => $('file').click()); $('download-template').addEventListener('click', () => { XLSX.writeFile(buildTemplateWorkbook(), '교각입력템플릿.xlsx'); }); $('file').addEventListener('change', async (ev) => { const file = (ev.target as HTMLInputElement).files?.[0]; if (!file) return; try { loadBytes(new Uint8Array(await file.arrayBuffer()), file.name); } catch (err) { setError(`엑셀을 읽지 못했습니다: ${(err as Error).message}`); } }); /** The project's standing input and names (RAG/운곡교P1입력.xlsx → 프로젝트 운곡교P1, 교량 운곡교). */ const SAMPLE_PROJECT = { name: '운곡교P1', bridge: '운곡교' } as const; function fillSampleNames(): void { $('project-name').value = SAMPLE_PROJECT.name; $('bridge-name').value = SAMPLE_PROJECT.bridge; // Setting .value does not fire 'input', so the apply button must be refreshed here. updateApply(); } $('pick-sample').addEventListener('click', async () => { try { loadBytes(await fetchSample(), '샘플 P1 (운곡교P1입력.xlsx)'); fillSampleNames(); } catch (err) { setError(`샘플을 불러오지 못했습니다: ${(err as Error).message}`); } }); const drop = $('drop'); drop.addEventListener('dragover', (ev) => { ev.preventDefault(); drop.classList.add('over'); }); drop.addEventListener('dragleave', () => drop.classList.remove('over')); drop.addEventListener('drop', async (ev) => { ev.preventDefault(); drop.classList.remove('over'); const file = ev.dataTransfer?.files?.[0]; if (!file) return; try { loadBytes(new Uint8Array(await file.arrayBuffer()), file.name); } catch (err) { setError(`엑셀을 읽지 못했습니다: ${(err as Error).message}`); } }); $('type-filter').addEventListener('change', (ev) => { state.filter = (ev.target as HTMLSelectElement).value as 'all' | PierType; renderPierList(); }); $('save-project').addEventListener('click', saveProject); $('open-project').addEventListener('click', () => $('project-file').click()); $('project-file').addEventListener('change', (ev) => { const file = (ev.target as HTMLInputElement).files?.[0]; if (file) void openProjectFile(file); (ev.target as HTMLInputElement).value = ''; }); window.addEventListener('keydown', (ev) => { if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 's') { ev.preventDefault(); saveProject(); } }); window.addEventListener('beforeunload', (ev) => { if (isDirty()) ev.preventDefault(); }); $('restore-backup').addEventListener('click', () => { if (!pendingBackup) return; try { openProjectText(pendingBackup.text, false); setStatus('자동 백업을 복구했습니다. 저장하지 않은 변경이 있습니다.'); } catch (err) { setError(`백업을 복구하지 못했습니다: ${(err as Error).message}`); } pendingBackup = null; $('backup-banner').hidden = true; }); $('discard-backup').addEventListener('click', () => { pendingBackup = null; $('backup-banner').hidden = true; clearBackup().catch(() => undefined); }); // --- start ------------------------------------------------------------------------------ captureViewerHome(); wireRebarPicking(); wireAllPiersPicking(); buildPartToggles(); buildViewButtons(); document.querySelectorAll('input[name="ground-map-mode"]').forEach((input) => { input.addEventListener('change', () => setGroundMapMode(input.value as 'none' | 'ground' | 'satellite')); }); setGroundMapMode('none'); renderDefaults(); renderDialog(); renderAll(); void checkBackup(); // Deep link for the bundled sample: ?sample=1 loads P1 directly and skips the dialog. if (new URLSearchParams(window.location.search).get('sample') === '1') { fetchSample() .then((bytes) => { loadBytes(bytes, '샘플 P1'); fillSampleNames(); applyImport(); }) .catch((err: Error) => setError(`샘플을 불러오지 못했습니다: ${err.message}`)); } // --- 3D band: collapsible view menu, origin toggle, and remembered panel states (승인 2026-10-23) --- /** Open or close the view menu or the origin note, and keep the buttons' aria-expanded in step. */ function setMenuOpen(which: 'views' | 'origin', open: boolean): void { const panel = $(which); panel.hidden = !open; $(`${which}-toggle`).setAttribute('aria-expanded', String(open)); } /** Counts the bar parts shown on the 3D view in the 부재 표시 summary. */ function updatePartsCount(): void { const boxes = document.querySelectorAll('#parts input[type="checkbox"]'); const on = Array.from(boxes).filter((b) => b.checked).length; $('parts-count').textContent = `${on}/${boxes.length} ▾`; } /** Panel open states remembered per viewer; the storage can be missing or blocked, so every access is guarded. */ const PANEL_KEY = 'pierzainer.panel.'; function restorePanel(id: string): void { const panel = $(id); try { panel.open = localStorage.getItem(PANEL_KEY + id) === '1'; } catch { panel.open = false; } panel.addEventListener('toggle', () => { try { localStorage.setItem(PANEL_KEY + id, panel.open ? '1' : '0'); } catch { /* storage unavailable: the panel still works */ } }); } restorePanel('parts-info'); restorePanel('color-info'); $('all-toggle').addEventListener('click', () => { state.allView = !state.allView; setupScene(); renderAll(); }); $('views-toggle').addEventListener('click', () => setMenuOpen('views', $('views').hidden === true)); $('origin-toggle').addEventListener('click', () => setMenuOpen('origin', $('origin').hidden === true)); document.addEventListener('click', (ev) => { const target = ev.target as Node; if (!$('views-menu').contains(target)) setMenuOpen('views', false); if (!$('origin-toggle').contains(target) && !$('origin').contains(target)) setMenuOpen('origin', false); }); document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { setMenuOpen('views', false); setMenuOpen('origin', false); } });