Files
Pier_v1/src/main.ts
T
ypshimandClaude Haiku 5.5 4048c326e3 feat(view): read the bearing ELEVATION TABLE from a DXF drawing (교각도(2) P1) by position
The reader takes TEXT and MTEXT entities (x, y, text). The EL.X header row names the columns; the No column holds the
bearing numbers; each value is the nearest entry under its header on the same row. On the 운곡교 교각도(2) drawing all ten
bearings read back the reference values (EL.X, h1, h2). The 도면 불러오기 button accepts .dxf as well as sheets. The test
fixture holds only the table's text entries.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-23 14:34:33 +09:00

2135 lines
92 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import './style.css';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import * as XLSX from 'xlsx';
import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
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, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
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, fyOf, GRADE_CHOICES, 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 { parseDrawingRows } from './core/drawing-import';
import { parseBearingElevationTable, readDxfTexts } from './core/dxf-table';
import { P1_COPING_GEOM } from './core/coping-geom';
import { applyBearingRules, BEARING_ELEVATION_REF, BEARING_RULES_DEFAULT, type BearingBasis, type BearingRules, type DrawingBearing, DRAWING_TOL_MM, drawingCopingCheck, SOLPLATE_REF, SOLPLATE_T_RULE, solplateTs, withDrawingBasis } from './core/bearing-ref';
/** 공통 규칙 of the bearing card (2단계). Screen state for now; not yet saved to the project file. */
let bearingRules: BearingRules = { ...BEARING_RULES_DEFAULT };
/** 받침 기준 for the project (Q16, 승인 2026-10-23): 'excel' by default. Drawing values per pier name. */
let bearingBasis: BearingBasis = 'excel';
let bearingDrawing: Record<string, DrawingBearing[]> = {};
import { allVisibleChecked, setVisibleChecked, 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 } from './core/types';
import { buildPierScene, setRebarView, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
/** 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'] },
{ 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: [], note: '받침 상세(C3)는 Q16 결정 후 추가합니다. 지금은 표시만 합니다.' },
{ id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm', 'copingH1Mm', 'copingH2Mm', 'levelWidthMm'], note: '폭, 길이, 끝단 높이 h1, 테이퍼 높이 h2, Level 폭을 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' },
{ id: 'column', label: '기둥', keys: [], note: '기둥 지름 입력은 다음 단계에서 추가합니다. 높이와 위치는 계산값입니다.' },
{ id: 'footing', label: '확대기초 · 말뚝', keys: ['pileLengthMm'], note: '말뚝 길이를 입력합니다. 열별 본수는 다음 단계에서 추가합니다.' },
{ id: 'ground', label: '지반·지층', keys: [], note: '지반고는 입력값을 표시만 합니다. 지층 입력은 다음 단계에서 추가합니다.' },
];
interface Pending {
fileName: string;
entries: Pier[];
sheets: SheetReport[];
preview: string | null;
}
const state = {
piers: [] as Pier[],
active: null as string | null,
view: 'iso' as ViewKind,
visible: Object.fromEntries(PART_GROUPS.flatMap((g) => g.parts).map((p) => [p, true])) as Record<PartName, boolean>,
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,
/** ③ 공통 설정 (fck · fy). */
material: { ...DEFAULT_MATERIAL } as Material,
/** ② 부재별 fck. A member without an entry uses the 공통 fck (③). */
fckByMember: {} as FckByMember,
/** Bar highlighted on ③ after [보기] (시안 ③ 알림). */
/** Bars selected on ③ (철근표 checkboxes, 3D click, [보기]). */
rebarSelection: new Set<string>(),
/** 선택만 보기: 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;
let backupTimer: number | undefined;
const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
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 = $<HTMLDivElement>('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 = $<HTMLDivElement>('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();
function animate(): void {
controls.update();
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,
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;
// The model uses the drawing EL.X when the project basis is 'drawing' and this pier has drawing values (Q16).
const drawing = bearingBasis === 'drawing' ? bearingDrawing[input.name] : undefined;
const modelInput = drawing?.length ? withDrawingBasis(input, drawing) : input;
if (validateInput(input, issues)) {
try {
model = buildPier(modelInput);
validatePier(input, model, issues);
} 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 = $<HTMLDivElement>('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;
if (state.scene) state.scene.parts[p].visible = input.checked;
}
});
label.append(input, document.createTextNode(group.label));
row.appendChild(label);
}
box.appendChild(row);
}
function buildViewButtons(): void {
const box = $<HTMLDivElement>('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);
}
}
function setView(kind: ViewKind): void {
state.view = kind;
document.querySelectorAll<HTMLButtonElement>('#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);
if (!state.scene) return;
const pier = activePier();
if (!pier?.model) return;
const { position, target } = viewPosition(state.scene, kind, pier.model);
camera.position.copy(position);
controls.target.copy(target);
controls.update();
}
function setupScene(): void {
if (state.scene) world.remove(state.scene.root);
state.scene = null;
if (rebar3d) world.remove(rebar3d.group);
rebar3d = null;
const pier = activePier();
if (pier?.model) {
rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember);
rebar3d.group.visible = state.stage === 'rebar';
rebar3d.setColorMode(state.rebarColor);
world.add(rebar3d.group);
const scene = buildPierScene(pier.model);
for (const key of Object.keys(state.visible) as PartName[]) {
scene.parts[key].visible = state.visible[key];
}
world.add(scene.root);
setRebarView(scene, state.stage === 'rebar');
state.scene = scene;
setView(state.view);
}
}
// --- main list (시안 ① 교각 목록) ----------------------------------------------------
function renderPierList(): void {
const select = $<HTMLSelectElement>('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($<HTMLUListElement>('pier-items'), 'shape');
renderPierRows($<HTMLUListElement>('plan-pier-items'), 'plan');
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
$<HTMLParagraphElement>('pier-empty').hidden = state.piers.length > 0;
$<HTMLButtonElement>('select-all').textContent = allVisibleChecked(state.piers, state.filter) ? '선택 해제' : '전체 선택';
}
/** 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);
for (const pier of visible) {
const li = el('li');
const check = el('input');
check.type = 'checkbox';
check.checked = pier.checked;
check.disabled = !pier.model;
check.setAttribute('aria-label', `${pier.input.name} 선택`);
check.addEventListener('change', () => {
pier.checked = check.checked;
renderAll();
});
// 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';
name.setAttribute('aria-pressed', String(pier.input.name === state.active));
name.addEventListener('click', () => {
state.active = pier.input.name;
state.rebarSelection = new Set();
setupScene();
renderAll();
});
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(check, name, badge);
list.appendChild(li);
}
}
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 checked = state.piers.filter((p) => p.checked).length;
$('step2-status').textContent = total ? `변경 ${total}건 · 재생성 필요` : '예정';
const regen = $<HTMLButtonElement>('regen');
regen.disabled = checked === 0;
regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성';
$('bulk-count').textContent = `선택 ${checked}기`;
$<HTMLButtonElement>('bulk-regen').disabled = checked === 0;
$<HTMLButtonElement>('bulk-copy').disabled = !activePier();
}
// --- results panel (시안 ① 입력값 · 계산 결과) ----------------------------------------
type Pair = [string, string];
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)})` : '';
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 = $<HTMLUListElement>('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 = $<HTMLUListElement>('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();
if (state.active !== lastScrolledPier) {
lastScrolledPier = state.active;
document.querySelector<HTMLButtonElement>('.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 dialog. */
let copySelection = new Set<string>();
function copyTargets(): Pier[] {
const src = activePier();
const sameType = $<HTMLInputElement>('cp-same-type').checked;
return state.piers.filter((p) => p !== src && (!sameType || p.input.type === src?.input.type));
}
/** Targets that are both visible in the dialog and ticked. Hidden targets are never changed. */
function selectedCopyTargets(): Pier[] {
return copyTargets().filter((t) => copySelection.has(t.input.name));
}
function chosenCopyItems(): CopyItem[] {
return Array.from(document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]'))
.filter((i) => i.checked)
.map((i) => i.dataset.item as CopyItem);
}
function renderCopyTargets(): void {
const list = $<HTMLUListElement>('cp-targets');
list.replaceChildren();
for (const t of copyTargets()) {
const li = el('li');
const check = el('input');
check.type = 'checkbox';
check.checked = copySelection.has(t.input.name);
check.setAttribute('aria-label', `${t.input.name} 대상`);
check.addEventListener('change', () => {
if (check.checked) copySelection.add(t.input.name);
else copySelection.delete(t.input.name);
renderCopyPreview();
});
li.append(check, el('span', `${t.input.name} · ${t.input.type} · ${t.input.foundationType}`));
list.appendChild(li);
}
}
function renderCopyPreview(): void {
const src = activePier();
const items = chosenCopyItems();
const body = $<HTMLTableElement>('cp-preview').tBodies[0];
body.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 row = el('tr');
row.append(
el('td', t.input.name),
el('td', t.input.type),
el('td', String(plan.changes.length)),
el('td', String(skipped.length)),
el('td', [...labels, ...skipped].join(' · ') || '바뀌는 값 없음'),
);
body.appendChild(row);
}
const n = selected.length;
$('cp-summary').textContent = `${src?.input.name ?? ''} → ${n}기 · 항목 ${items.length}개 · 건너뜀 ${skippedTotal}건`;
const apply = $<HTMLButtonElement>('cp-apply');
apply.textContent = `대상 ${applied}기에 적용`;
apply.disabled = applied === 0;
}
function openCopyDialog(): void {
const src = activePier();
if (!src) return;
$('cp-source').textContent = `${src.input.name} · ${src.input.type} · ${src.input.foundationType}`;
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
i.checked = (DEFAULT_COPY_ITEMS as string[]).includes(i.dataset.item ?? '');
}
copySelection = new Set(state.piers.filter((p) => p.checked && p !== src).map((p) => p.input.name));
renderCopyTargets();
renderCopyPreview();
$<HTMLDialogElement>('copy-dialog').showModal();
}
function applyCopyDialog(): void {
const src = activePier();
if (!src) return;
const items = chosenCopyItems();
const regen = $<HTMLInputElement>('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;
});
$<HTMLDialogElement>('copy-dialog').close();
setupScene();
renderAll();
setStatus(`${applied}기에 속성을 복사했습니다. 변경 ${changes}건${regen ? ' (형상 다시 생성)' : ' · 재생성 필요'}.`);
}
// --- input cards and change tracking (plan B1 · C2) -------------------------------------
function pendingTotal(): number {
return state.piers.filter((p) => p.checked).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 = $<HTMLButtonElement>('edit-regen');
if (!pier) {
$<HTMLDivElement>('edit-fields').replaceChildren();
$('edit-summary').textContent = '교각을 선택하면 입력을 고칠 수 있습니다.';
btn.textContent = '다시 생성 · 변경 0';
btn.disabled = true;
return;
}
const status = fieldRows($<HTMLDivElement>('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;
}
/** 열별 본수 −/+ 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): void {
const box = $<HTMLDivElement>('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';
chip.setAttribute('aria-pressed', String(option.isDefault));
chip.disabled = !isSelectable(option);
if (chip.disabled) chip.title = UNDEFINED_REASON;
chip.addEventListener('click', () => {
try {
choose(member, gi, option.label);
} 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 = $<HTMLElement>('viewer-block');
viewerHome.parent = block.parentElement;
viewerHome.next = block.nextElementSibling;
}
function placeViewer(stage: Stage): void {
const block = $<HTMLElement>('viewer-block');
const slot = stage === 'plan' ? $<HTMLElement>('slot-plan') : stage === 'rebar' ? $<HTMLElement>('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 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 {
state.stage = stage;
if (state.scene) setRebarView(state.scene, stage === 'rebar');
$<HTMLElement>('view-shape').hidden = stage !== 'shape';
$<HTMLElement>('view-plan').hidden = stage !== 'plan';
$<HTMLElement>('view-rebar').hidden = stage !== 'rebar';
$<HTMLElement>('view-quantity').hidden = stage !== 'quantity';
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 = $<HTMLButtonElement>(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<RebarColorMode, string> = { role: '역할', diameter: '직경', member: '부재' };
$('color-state').textContent = `${colorLabel[state.rebarColor]} ▾`;
// The colour control lives in the 3D window and is shown only on ③.
$<HTMLDetailsElement>('color-info').hidden = state.stage !== 'rebar';
for (const b of document.querySelectorAll<HTMLButtonElement>('#rebar-color button')) {
b.setAttribute('aria-pressed', String(b.dataset.color === state.rebarColor));
}
const legend = $<HTMLUListElement>('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<HTMLButtonElement>('#rebar-color button')) {
b.addEventListener('click', () => {
state.rebarColor = b.dataset.color as RebarColorMode;
rebar3d?.setColorMode(state.rebarColor);
renderRebarColor();
});
}
function renderRebar(): void {
const pier = activePier();
// 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②.
renderPierRows($<HTMLUListElement>('rebar-pier-items'), 'rebar');
const checked = state.piers.filter((p) => p.checked).length;
$('rebar-bulk-count').textContent = `선택 ${checked}기`;
$<HTMLButtonElement>('rebar-bulk-copy').disabled = !pier;
const tabs = $<HTMLUListElement>('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 = $<HTMLDivElement>('rebar-table');
box.replaceChildren();
const issues = $<HTMLUListElement>('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} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`;
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') {
$<HTMLInputElement>('mat-fck').focus();
$<HTMLInputElement>('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. */
function quantityTargets(): Pier[] {
const checked = state.piers.filter((p) => p.checked && p.model);
if (checked.length) return checked;
const active = activePier();
return active?.model ? [active] : [];
}
/** 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(l.length));
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 = $<HTMLDivElement>('quantity-body');
body.replaceChildren();
renderPierRows($<HTMLUListElement>('quantity-pier-items'), 'quantity');
const targets = quantityTargets();
$('step4-status').textContent = targets.length ? `${targets.length}기` : '예정';
$('quantity-note').textContent = targets.length
? `${targets.length}기 · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 체크한 교각 기준이고, 체크가 없으면 현재 교각입니다.`
: '계산된 교각이 없습니다. 교각을 생성하세요.';
let kg = 0;
let kgWithWaste = 0;
for (const pier of targets) {
if (!pier.model) continue;
const q = buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material);
kg += q.kg;
kgWithWaste += q.kgWithWaste;
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'));
}
if (targets.length > 1) {
body.appendChild(el('p', `전체 ${targets.length}기 합계 ${kg.toFixed(1)} kg · 할증 포함 ${kgWithWaste.toFixed(1)} kg (${(kgWithWaste / 1000).toFixed(3)} ton)`, 'lbl'));
}
}
/** 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`);
}
$<HTMLButtonElement>('quantity-export').addEventListener('click', exportQuantityWorkbook);
$<HTMLButtonElement>('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<string, string> = {
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);
$<HTMLInputElement>('mat-fck').value = String(m.fck);
for (const k of ['d', 'h', 's'] as const) {
$<HTMLSelectElement>(`mat-grade-${k}`).value = String(m.grade[k.toUpperCase() as GradeKey]);
}
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
$<HTMLInputElement>(`bend-${k}`).value = String(m.bend[k]);
}
$<HTMLInputElement>('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() === '') {
delete next[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];
// Not the orange "changed" style: fck changes only the rebar, so it is not a pending shape change (변경 n건).
const row = el('div', undefined, '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));
const note = own === undefined ? `공통 ${state.material.fck}` : `공통 ${state.material.fck} · 배근에만 반영`;
row.append(label, input, el('span', note, 'muted small'));
if (own !== undefined) {
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();
}
/** ② 교량받침 reference (1단계, 읽기 전용): the drawing's ELEVATION and 솔플레이트 tables, not used in the model (Q16). */
/** Rebuilds every pier from its draft, so that a change of basis or drawing values reaches the model. */
function regenerateAllPiers(): void {
state.piers = state.piers.map((p) => regenerateEntry(p));
setupScene();
}
/** ② 받침 기준 (Q16, 승인 2026-10-23): project-wide basis, and the drawing EL.X / h1 / h2 for this pier. */
function renderBearingBasis(wrap: HTMLDivElement, pier: Pier): void {
wrap.appendChild(el('p', '받침 기준 (프로젝트 전체 · 기본: 엑셀 z)', 'lbl'));
const sel = el('select');
sel.id = 'bearing-basis';
for (const [value, label] of [['excel', '엑셀 z (기본)'], ['drawing', '도면 EL.X']] as const) {
const o = el('option', label);
o.value = value;
sel.appendChild(o);
}
sel.value = bearingBasis;
sel.addEventListener('change', () => {
bearingBasis = sel.value as BearingBasis;
regenerateAllPiers();
renderAll();
});
wrap.appendChild(sel);
const name = pier.input.name;
const current = new Map((bearingDrawing[name] ?? []).map((d) => [d.no, d]));
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['No', '열', 'EL.X (m)', 'h1 (mm)', 'h2 (mm)', '코핑 상면 차이 (mm)']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
// The reference is the Excel-basis model: the drawing EL.X − h1 − h2 must reproduce its coping tops within 5 mm.
const checks = bearingBasis === 'drawing' && bearingDrawing[name]?.length ? drawingCopingCheck(buildPier(pier.input), bearingDrawing[name]) : [];
const checkOf = new Map(checks.map((c) => [c.no, c]));
const readTable = (): DrawingBearing[] => {
const rows: DrawingBearing[] = [];
body.querySelectorAll<HTMLTableRowElement>('tr').forEach((tr) => {
const inputs = tr.querySelectorAll<HTMLInputElement>('input');
const [elx, h1, h2] = Array.from(inputs).map((i) => Number(i.value));
const no = Number(tr.dataset.no);
if ([elx, h1, h2].every((v) => Number.isFinite(v) && v > 0)) rows.push({ no, elx, h1, h2 });
});
return rows;
};
const commit = () => {
bearingDrawing = { ...bearingDrawing, [name]: readTable() };
regenerateAllPiers();
renderAll();
};
for (const b of [...pier.input.bearings].sort((x, y) => x.no - y.no)) {
const d = current.get(b.no);
const tr = el('tr');
tr.dataset.no = String(b.no);
const cells: (string | HTMLElement)[] = [String(b.no), b.row];
for (const [value, step] of [[d?.elx, '0.001'], [d?.h1, '1'], [d?.h2, '1']] as const) {
const input = el('input');
input.type = 'number';
input.step = step;
input.value = value === undefined ? '' : String(value);
input.addEventListener('change', commit);
cells.push(input);
}
const c = checkOf.get(b.no);
cells.push(c === undefined ? '—' : `${c.diffMm.toFixed(1)}${c.ok ? '' : ` (허용 ${DRAWING_TOL_MM} 초과)`}`);
tr.append(
...cells.map((x) => {
const td = el('td');
if (typeof x === 'string') td.textContent = x;
else td.appendChild(x);
return td;
}),
);
body.appendChild(tr);
}
table.appendChild(body);
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
// 도면 불러오기 (승인 2026-10-23): a sheet with No, EL.X, h1, h2 (xlsx or csv) fills this pier's drawing values.
const importInput = el('input');
importInput.type = 'file';
importInput.accept = '.xlsx,.xls,.csv,.dxf';
importInput.id = 'drawing-import';
importInput.addEventListener('change', async () => {
const file = importInput.files?.[0];
if (!file) return;
try {
// A DXF drawing: the 교량받침 ELEVATION TABLE is read by position. Otherwise a sheet with No, EL.X, h1, h2.
let drawingRows: DrawingBearing[];
if (file.name.toLowerCase().endsWith('.dxf')) {
const r = parseBearingElevationTable(readDxfTexts(await file.text()));
if (r.errors.length) {
setError(`도면 불러오기 실패: ${r.errors.slice(0, 3).join(' ')}`);
return;
}
drawingRows = r.drawing;
} else {
const wb = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, blankrows: false, defval: '' });
const r = parseDrawingRows(rows);
if (r.errors.length) {
setError(`도면 불러오기 실패: ${r.errors.slice(0, 3).join(' ')}`);
return;
}
drawingRows = r.rows;
}
bearingDrawing = { ...bearingDrawing, [name]: drawingRows };
regenerateAllPiers();
renderAll();
setStatus(`${file.name}에서 받침 ${drawingRows.length}개의 도면 값을 불러왔습니다.`);
} catch (err) {
setError(`도면 파일을 읽지 못했습니다: ${(err as Error).message}`);
} finally {
importInput.value = '';
}
});
const importLabel = el('label', '도면 파일 불러오기 (xlsx·csv)');
importLabel.appendChild(importInput);
wrap.appendChild(importLabel);
const fill = el('button', '도면값 채우기 (참조 값)', 'btn small');
fill.type = 'button';
fill.addEventListener('click', () => {
bearingDrawing = { ...bearingDrawing, [name]: BEARING_ELEVATION_REF.map((r) => ({ no: r.no, elx: r.elx, h1: r.h1, h2: r.h2 })) };
regenerateAllPiers();
renderAll();
});
wrap.appendChild(fill);
if (bearingBasis === 'drawing' && !bearingDrawing[name]?.length) {
wrap.appendChild(el('p', '도면 값이 없어서 엑셀 z로 계산 중입니다.', 'warn-text'));
}
}
function renderBearingRef(box: HTMLDivElement, pier: Pier): void {
const m = pier.model;
const wrap = el('div', undefined, 'bearing-ref');
renderBearingBasis(wrap, pier);
wrap.appendChild(el('p', '공통 규칙 (입력 · 기본값 = 도면값)', 'lbl'));
const rules: [keyof BearingRules, string][] = [['h2', '모르타르 h2 (mm)'], ['h3', '받침 높이 h3 (mm)'], ['T', '솔플레이트 중심 T (mm)'], ['margin', '솔플 여유 (mm)']];
const grid = el('div', undefined, 'edit-row');
for (const [key, label] of rules) {
const id = `bear-${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(bearingRules[key]);
const changed = bearingRules[key] !== BEARING_RULES_DEFAULT[key];
if (changed) input.classList.add('changed-input');
input.addEventListener('change', () => {
const v = Number(input.value);
const min = key === 'margin' ? 0 : 0.0001;
if (!Number.isFinite(v) || v < min) {
setError(`${label}은(는) ${key === 'margin' ? '0 이상' : '0보다 큰'} 숫자여야 합니다.`);
renderAll();
return;
}
bearingRules = { ...bearingRules, [key]: v };
renderAll();
});
grid.append(lab, input, el('span', `기본 ${BEARING_RULES_DEFAULT[key]}`, 'muted small'));
}
wrap.appendChild(grid);
wrap.appendChild(el('p', '규칙 적용 값 (E = h1 + h2 + h3 + h4, L = A + 여유, W = B + 여유, T1·T2 = T ± L·|S|/2)', 'lbl'));
const ct = el('table', undefined, 'grid-table rebar-table');
const ch = el('thead');
const chr = el('tr');
for (const h of ['No', '열', 'E', 'L', 'W', 'T1 (−측)', 'T2 (+측)', '도면 대비']) chr.appendChild(el('th', h));
ch.appendChild(chr);
ct.appendChild(ch);
const cb = el('tbody');
for (const r of BEARING_ELEVATION_REF) {
const v = applyBearingRules(r, bearingRules);
const d = applyBearingRules(r, BEARING_RULES_DEFAULT);
const same = v.E === d.E && v.L === d.L && v.W === d.W && v.T1 === d.T1 && v.T2 === d.T2;
cb.appendChild(el('tr')).append(
el('td', String(r.no)), el('td', r.row), el('td', String(v.E)), el('td', String(v.L)), el('td', String(v.W)),
el('td', String(v.T1)), el('td', String(v.T2)), el('td', same ? '같음' : '규칙 변경'),
);
}
ct.appendChild(cb);
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(ct);
wrap.appendChild(el('p', '교량받침 ELEVATION TABLE (도면값 · 읽기 전용, mm · EL m)', 'lbl'));
wrap.appendChild(el('p', '엑셀 z를 EL.X로 씁니다(몰탈 50 mm 포함 받침블럭 상면, Q16 결정 2026-10-23). 도면 EL.X와의 차이는 "도면 ≠ 엑셀"로만 표시합니다.', 'muted small'));
const front = m?.blocks.filter((b) => b.row === '전열') ?? [];
const backBlocks = m?.blocks.filter((b) => b.row === '후열') ?? [];
const et = el('table', undefined, 'grid-table rebar-table');
const eh = el('thead');
const ehr = el('tr');
for (const h of ['No', '열', '엑셀 z (= EL.X, 모델)', '도면 EL.X', '도면 − 엑셀 (mm)', 'E', 'A', 'B', 'C1', 'S1']) ehr.appendChild(el('th', h));
eh.appendChild(ehr);
et.appendChild(eh);
const eb = el('tbody');
for (const r of BEARING_ELEVATION_REF) {
// Excel z is EL.X (Q16 decision 2026-10-23, option 1); the drawing's EL.X is shown for comparison only.
const block = r.row === '전열' ? front[r.no - 1] : backBlocks[r.no - 6];
const excelZ = block?.padTop;
const diffMm = excelZ === undefined ? NaN : Math.round((r.elx - excelZ) * 1000);
eb.appendChild(el('tr')).append(
el('td', String(r.no)), el('td', r.row), el('td', excelZ === undefined ? '—' : excelZ.toFixed(3)), el('td', r.elx.toFixed(3)),
el('td', Number.isNaN(diffMm) ? '—' : String(diffMm)), el('td', String(r.E)),
el('td', String(r.A)), el('td', String(r.B)), el('td', String(r.C1)), el('td', String(r.S1)),
);
}
et.appendChild(eb);
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(et);
wrap.appendChild(el('p', '솔플레이트 TABLE (도면값, mm)', 'lbl'));
const st = el('table', undefined, 'grid-table rebar-table');
const sh = el('thead');
const shr = el('tr');
for (const h of ['No', 'S (%)', 'L', 'W', 'T (중심)', 'T1 (−측)', 'T2 (+측)', '식 검산']) shr.appendChild(el('th', h));
sh.appendChild(shr);
st.appendChild(sh);
const sb = el('tbody');
for (const r of SOLPLATE_REF) {
const calc = solplateTs(SOLPLATE_T_RULE, r.L, r.s);
const ok = calc.T1 === r.T1 && calc.T2 === r.T2;
sb.appendChild(el('tr')).append(
el('td', String(r.no)), el('td', r.s.toFixed(3)), el('td', String(r.L)), el('td', String(r.W)),
el('td', String(r.T)), el('td', String(r.T1)), el('td', String(r.T2)), el('td', ok ? '일치' : `식 ${calc.T1} / ${calc.T2}`),
);
}
st.appendChild(sb);
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(st);
box.appendChild(wrap);
}
/** ② 코핑 상세 (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 === '후열');
m.segments.forEach((seg, k) => {
const f = front[k];
const b = back[k];
const backTop = b ? b.bottom : seg.top;
const step = Math.round((seg.top - backTop) * 1000);
// Block height includes the 50 mm pad (spec Q16): from the coping top to the pad top.
const heights = f && b ? `${Math.round((f.padTop - f.bottom) * 1000)} / ${Math.round((b.padTop - b.bottom) * 1000)}` : '—';
body.appendChild(el('tr')).append(
el('td', String(k + 1)), el('td', seg.s0.toFixed(3)), el('td', seg.s1.toFixed(3)),
el('td', seg.top.toFixed(3)), el('td', backTop.toFixed(3)), el('td', String(step)), el('td', heights),
);
});
table.appendChild(body);
wrap.appendChild(table);
wrap.appendChild(el('p', '기본 외 형상 규칙(교좌면 모따기·라운드, 2·3주형 헌치)은 spec 2단계 확정 후 추가합니다.', 'muted small'));
box.appendChild(wrap);
}
function renderPlan(): void {
const pier = activePier();
const members = $<HTMLUListElement>('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.
$('member-summary').textContent = `기본: ${defaultSummary(member.id)} · 선택: ${defaultSummary(member.id)}`;
renderShapeOptions(member.id);
$('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 = $<HTMLButtonElement>('member-regen');
const fields = $<HTMLDivElement>('member-fields');
if (!pier) {
fields.replaceChildren();
regen.textContent = '다시 생성 · 변경 0';
regen.disabled = true;
} else {
fieldRows(fields, pier, member.keys);
if (member.id === 'footing') renderPileRows(fields, pier);
if (member.id === 'coping') renderCopingInfo(fields, pier);
if (member.id === 'bearing') renderBearingRef(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 = $<HTMLUListElement>('changes');
changes.replaceChildren();
const pending = pier ? pendingKeys(pier) : [];
for (const key of pier ? overrideKeys(pier) : []) {
const li = el('li', `${FIELD_LABELS[key]}: 기본 ${pier!.defaults[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 = $<HTMLUListElement>('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,
bearingBasis,
bearingDrawing,
});
}
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,
bearingBasis,
bearingDrawing,
});
}
function renderSaveStatus(): void {
const saveBtn = $<HTMLButtonElement>('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 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 {
$<HTMLInputElement>('sv-name').value = state.project.name;
$<HTMLInputElement>('sv-bridge').value = state.project.bridge;
$<HTMLInputElement>('sv-client').value = state.project.client;
$<HTMLInputElement>('sv-author').value = state.project.author;
$<HTMLTextAreaElement>('sv-memo').value = state.project.memo;
$('sv-file').textContent = projectFileName();
$<HTMLDialogElement>('save-dialog').showModal();
$<HTMLInputElement>('sv-name').focus();
}
function confirmSaveDialog(): void {
const name = $<HTMLInputElement>('sv-name').value.trim();
if (!name) {
$<HTMLInputElement>('sv-name').focus();
setError('프로젝트명을 입력하세요.');
return;
}
state.project = {
name,
bridge: $<HTMLInputElement>('sv-bridge').value.trim(),
client: $<HTMLInputElement>('sv-client').value.trim(),
author: $<HTMLInputElement>('sv-author').value.trim(),
crs: CRS,
memo: $<HTMLTextAreaElement>('sv-memo').value.trim(),
};
$<HTMLDialogElement>('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 };
bearingBasis = parsed.bearingBasis ?? 'excel';
bearingDrawing = Object.fromEntries(Object.entries(parsed.bearingDrawing ?? {}).map(([k, v]) => [k, [...v]]));
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<void> {
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<void> {
try {
const backup = await readBackup();
if (!backup) return;
pendingBackup = backup;
$('backup-text').textContent = `저장되지 않은 작업이 있습니다 (${new Date(backup.at).toLocaleString('ko-KR')}).`;
$<HTMLDivElement>('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 = $<HTMLUListElement>('sheets');
sheetsBox.replaceChildren();
$('file-name').textContent = p ? `선택된 파일: ${p.fileName}` : '';
if (!p) {
sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.'));
renderPreview(null);
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('button', `${entry.input.name} · ${entry.input.type}`, 'pier-name');
name.type = 'button';
name.setAttribute('aria-pressed', String(p.preview === entry.input.name));
name.addEventListener('click', () => {
p.preview = entry.input.name;
renderDialog();
});
row.append(check, name);
li.appendChild(row);
}
sheetsBox.appendChild(li);
}
const preview = p.entries.find((e) => e.input.name === p.preview) ?? p.entries.find((e) => selectable(p, e)) ?? null;
renderPreview(preview ? preview.input : null);
updateApply();
}
function renderPreview(input: PierInput | null): void {
const title = $('preview-title');
const pierTable = $<HTMLTableElement>('pv-pier').tBodies[0];
const bearingBody = $<HTMLTableElement>('pv-bearings').tBodies[0];
const ipBody = $<HTMLTableElement>('pv-ips').tBodies[0];
pierTable.replaceChildren();
bearingBody.replaceChildren();
ipBody.replaceChildren();
if (!input) {
title.textContent = '미리볼 교각이 없습니다.';
return;
}
title.textContent = `${input.name} · 시트 ${input.sheet}`;
const pairs: Pair[] = [
['교각명', input.name],
['교각형식', 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)],
];
for (const [k, v] of pairs) {
const tr = el('tr');
tr.append(el('th', k), el('td', v));
pierTable.appendChild(tr);
}
for (const b of input.bearings) {
const tr = el('tr');
const azimuth = Number.isNaN(b.azimuthDeg) ? '오류' : `${b.azimuthDeg.toFixed(4)}°`;
for (const v of [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 [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 = $<HTMLInputElement>('project-name').value.trim().length > 0;
const apply = $<HTMLButtonElement>('dlg-apply');
apply.textContent = `선택한 교각 ${count}기 형상 생성`;
apply.disabled = count === 0 || !named;
}
function loadBytes(bytes: Uint8Array, fileName: string): void {
const { piers, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' }));
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, preview: null };
for (const e of entries) e.checked = selectable(pending, e);
pending.preview = entries.find((e) => selectable(pending, e))?.input.name ?? entries[0]?.input.name ?? null;
state.pending = pending;
renderDialog();
}
function applyImport(): void {
const p = state.pending;
const name = $<HTMLInputElement>('project-name').value.trim();
if (!p || !name) return;
state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };
state.piers = p.entries.filter((e) => e.checked && selectable(p, e));
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;
$<HTMLDialogElement>('import-dialog').close();
setupScene();
renderAll();
setStatus(`${state.piers.length}기 형상을 생성했습니다. 정보·경고는 오른쪽 알림에서 확인하세요.`);
}
function openImport(): void {
const dlg = $<HTMLDialogElement>('import-dialog');
if (!dlg.open) dlg.showModal();
$<HTMLInputElement>('project-name').focus();
}
async function fetchSample(): Promise<Uint8Array> {
const res = await fetch(new URL('samples/운곡교P1입력.xlsx', document.baseURI));
if (!res.ok) throw new Error(`샘플 응답 ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}
function regenerateChecked(): void {
let errors = 0;
let count = 0;
state.piers = state.piers.map((p) => {
if (!p.checked) return p;
count++;
const next = regenerateEntry(p);
if (!next.model) errors++;
return next;
});
setupScene();
renderAll();
setStatus(`${count}기 형상을 생성했습니다.${errors ? ` 오류 ${errors}기는 생성되지 않았습니다.` : ''}`);
}
// --- events -----------------------------------------------------------------------------
$<HTMLButtonElement>('open-import').addEventListener('click', openImport);
$<HTMLButtonElement>('dlg-close').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
$<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-copy').addEventListener('click', openCopyDialog);
$<HTMLButtonElement>('rebar-bulk-copy').addEventListener('click', openCopyDialog);
$<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shape'));
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
$<HTMLButtonElement>('nav-quantity').addEventListener('click', () => setStage('quantity'));
$<HTMLInputElement>('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value));
$<HTMLInputElement>('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value));
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
$<HTMLInputElement>(`bend-${k}`).addEventListener('change', (ev) => setBend(k, (ev.target as HTMLInputElement).value));
}
for (const k of ['D', 'H', 'S'] as const) {
$<HTMLSelectElement>(`mat-grade-${k.toLowerCase()}`).addEventListener('change', (ev) => setMaterial(k, (ev.target as HTMLSelectElement).value));
}
$<HTMLButtonElement>('member-regen').addEventListener('click', () => {
const pier = activePier();
if (pier) regenerateOne(pier.input.name);
});
$<HTMLInputElement>('cp-same-type').addEventListener('change', () => {
renderCopyTargets();
renderCopyPreview();
});
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
i.addEventListener('change', renderCopyPreview);
}
$<HTMLInputElement>('cp-regen').addEventListener('change', renderCopyPreview);
$<HTMLButtonElement>('cp-apply').addEventListener('click', applyCopyDialog);
$<HTMLButtonElement>('cp-cancel').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
$<HTMLButtonElement>('cp-close').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
$<HTMLButtonElement>('sv-save').addEventListener('click', confirmSaveDialog);
$<HTMLButtonElement>('sv-cancel').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
$<HTMLButtonElement>('sv-close').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
$<HTMLButtonElement>('edit-regen').addEventListener('click', () => {
const pier = activePier();
if (pier) regenerateOne(pier.input.name);
});
$<HTMLInputElement>('project-name').addEventListener('input', updateApply);
$<HTMLButtonElement>('pick-file').addEventListener('click', () => $<HTMLInputElement>('file').click());
$<HTMLButtonElement>('download-template').addEventListener('click', () => {
XLSX.writeFile(buildTemplateWorkbook(), '교각입력템플릿.xlsx');
});
$<HTMLInputElement>('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 {
$<HTMLInputElement>('project-name').value = SAMPLE_PROJECT.name;
$<HTMLInputElement>('bridge-name').value = SAMPLE_PROJECT.bridge;
// Setting .value does not fire 'input', so the apply button must be refreshed here.
updateApply();
}
$<HTMLButtonElement>('pick-sample').addEventListener('click', async () => {
try {
loadBytes(await fetchSample(), '샘플 P1 (운곡교P1입력.xlsx)');
fillSampleNames();
} catch (err) {
setError(`샘플을 불러오지 못했습니다: ${(err as Error).message}`);
}
});
const drop = $<HTMLDivElement>('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}`);
}
});
$<HTMLSelectElement>('type-filter').addEventListener('change', (ev) => {
state.filter = (ev.target as HTMLSelectElement).value as 'all' | PierType;
renderPierList();
});
$<HTMLButtonElement>('select-all').addEventListener('click', () => {
state.piers = setVisibleChecked(state.piers, state.filter, !allVisibleChecked(state.piers, state.filter));
renderAll();
});
$<HTMLButtonElement>('save-project').addEventListener('click', saveProject);
$<HTMLButtonElement>('open-project').addEventListener('click', () => $<HTMLInputElement>('project-file').click());
$<HTMLInputElement>('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();
});
$<HTMLButtonElement>('restore-backup').addEventListener('click', () => {
if (!pendingBackup) return;
try {
openProjectText(pendingBackup.text, false);
setStatus('자동 백업을 복구했습니다. 저장하지 않은 변경이 있습니다.');
} catch (err) {
setError(`백업을 복구하지 못했습니다: ${(err as Error).message}`);
}
pendingBackup = null;
$<HTMLDivElement>('backup-banner').hidden = true;
});
$<HTMLButtonElement>('discard-backup').addEventListener('click', () => {
pendingBackup = null;
$<HTMLDivElement>('backup-banner').hidden = true;
clearBackup().catch(() => undefined);
});
// --- start ------------------------------------------------------------------------------
captureViewerHome();
wireRebarPicking();
buildPartToggles();
buildViewButtons();
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 = $<HTMLElement>(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<HTMLInputElement>('#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 = $<HTMLDetailsElement>(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');
$<HTMLButtonElement>('views-toggle').addEventListener('click', () => setMenuOpen('views', $<HTMLDivElement>('views').hidden === true));
$<HTMLButtonElement>('origin-toggle').addEventListener('click', () => setMenuOpen('origin', $<HTMLSpanElement>('origin').hidden === true));
document.addEventListener('click', (ev) => {
const target = ev.target as Node;
if (!$<HTMLDivElement>('views-menu').contains(target)) setMenuOpen('views', false);
if (!$<HTMLButtonElement>('origin-toggle').contains(target) && !$<HTMLSpanElement>('origin').contains(target)) setMenuOpen('origin', false);
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') {
setMenuOpen('views', false);
setMenuOpen('origin', false);
}
});