Files
Pier_v1/src/main.ts
T
ypshimandClaude Haiku 5.5 789f62adf0 refactor(plan): 형상계획 ②의 교대 기본 정보 입력칸 제거, 삽도에 EL 표시
- ②의 교대 편집 영역에서 기본 정보 6칸과 기본값 복원 버튼을 뺌. 기본 정보 편집과 복원은 ①에만 남김
- ②의 삽도에 교좌면·기초 하단·지반고 값을 읽기 전용 라벨로 표시 (abut-section-svg.ts의 AbutLevels)
- 날개벽 형식은 ②의 날개벽 카드에 그대로 둠
- 테스트 631건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 15:31:54 +09:00

3608 lines
167 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 { 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<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,
/** 전체 보기 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<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;
/** 전체 보기: 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<string, THREE.Group>();
let groundMapGroup: THREE.Group | null = null;
let groundMapMode: 'none' | 'ground' | 'satellite' = 'none';
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();
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 = $<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;
setPartVisible(p, 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);
}
}
/** 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;
}
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<void> {
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 = $<HTMLDetailsElement>('ground-map-info');
panel.hidden = !groundModel?.mapLayers?.length;
document.querySelectorAll<HTMLInputElement>('input[name="ground-map-mode"]').forEach((input) => {
input.checked = input.value === mode;
});
}
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);
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);
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 = $<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');
renderAbutEditor();
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
$<HTMLParagraphElement>('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', input: 'abut-panel-plan-input', 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);
$<HTMLElement>('pier-info').hidden = Boolean(rec);
// ② hides the pier cards (부재 목록, 코핑 카드, 속성 복사) while an abutment is selected (style.css .abut-selected).
$<HTMLElement>('view-plan').classList.toggle('abut-selected', Boolean(rec));
for (const slot of [ABUT_SLOTS.shape, ABUT_SLOTS.plan]) {
const box = $<HTMLElement>(slot.root);
box.hidden = !rec;
if (rec) fillAbutSlot(slot, rec);
}
if (rec) {
renderAbutMembers(rec);
applyAbutMemberCard(rec);
} else {
$<HTMLUListElement>('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 = $<HTMLUListElement>('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) => $<HTMLElement>(id).closest<HTMLElement>('details') ?? $<HTMLElement>(id);
group('abut-panel-plan-bearings').hidden = member !== 'bearing';
group('abut-panel-plan-shape').hidden = member !== 'shape';
$<HTMLElement>('abut-panel-plan-wing').hidden = member !== 'wing';
$<HTMLElement>('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 {
$<HTMLElement>(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));
};
fillList($<HTMLDListElement>(slot.input), abutInputRows(rec));
fillList($<HTMLDListElement>(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 = $<HTMLButtonElement>(slot.toggle);
toggle.textContent = state.abutEditOpen ? '닫기' : '편집';
toggle.setAttribute('aria-expanded', String(state.abutEditOpen));
toggle.onclick = () => {
state.abutEditOpen = !state.abutEditOpen;
renderAll();
};
$<HTMLElement>(slot.editor).hidden = !state.abutEditOpen;
const basic = $<HTMLDivElement>(slot.basic);
const bearings = $<HTMLDivElement>(slot.bearings);
const shape = $<HTMLDivElement>(slot.shape);
const msg = $<HTMLParagraphElement>(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 ? $<HTMLDivElement>(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 = $<HTMLButtonElement>('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 = $<HTMLTableElement>('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 = $<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();
const allToggle = $<HTMLButtonElement>('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<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 panel. */
let copySelection = new Set<string>();
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<CopyItem>();
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 = $<HTMLButtonElement>('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 = $<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;
});
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 = $<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;
}
/** 확대기초·말뚝 그림 (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 = $<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';
// 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 = $<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 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');
$<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();
});
}
/** 받침블럭 배근 설정 (승인 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<T extends string>(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<keyof CopingRebarSettings, HTMLInputElement | HTMLSelectElement>();
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<keyof ColumnRebarSettings, HTMLInputElement>();
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<ColumnSection, string> = {
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<keyof FootingRebarSettings, HTMLInputElement>();
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($<HTMLUListElement>('rebar-pier-items'), 'rebar');
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} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`;
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') {
$<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. */
/** 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 = $<HTMLDivElement>('quantity-body');
body.replaceChildren();
renderPierRows($<HTMLUListElement>('quantity-pier-items'), 'quantity');
const allButton = $<HTMLButtonElement>('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`);
}
$<HTMLButtonElement>('quantity-all').addEventListener('click', () => {
state.quantityAll = true;
renderAll();
});
$<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() === '') {
// 되돌리기: 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.g<seg.s1) : front[k];
const b = unequalRows ? back.find(b=>b.g>=seg.s0 && b.g<seg.s1) : back[k];
const backTop = unequalRows ? topAt(rearSteps,(seg.s0+seg.s1)/2) : 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.
// 선택 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 = $<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') {
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 = $<HTMLUListElement>('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 = $<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,
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 = $<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 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 {
$<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 };
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<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([]);
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 = $<HTMLTableElement>('pv-pier');
const bearingBody = $<HTMLTableElement>('pv-bearings').tBodies[0];
const ipBody = $<HTMLTableElement>('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 = $<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 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 = $<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.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;
$<HTMLDialogElement>('import-dialog').close();
document.querySelector<HTMLElement>('.landing-overlay')?.classList.remove('modal-open');
const appRoot = document.querySelector<HTMLElement>('.app');
if (appRoot) {
appRoot.hidden = false;
appRoot.setAttribute('aria-hidden', 'false');
}
const homeButton = document.querySelector<HTMLButtonElement>('#home-from-app');
if (homeButton) homeButton.hidden = false;
document.querySelector<HTMLElement>('.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<HTMLDivElement>('.landing-overlay');
if (landing && !landing.hidden) return;
const dlg = $<HTMLDialogElement>('import-dialog');
if (!dlg.open) dlg.showModal();
$<HTMLInputElement>('project-name').focus();
}
async function fetchSample(): Promise<Uint8Array> {
const res = await fetch(new URL('samples/운곡교P1입력.xlsx', document.baseURI));
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<void>;
openDrawingFlow?: () => void;
}
}
window.loadBytesToApp = loadBytes;
window.openSampleProject = async () => {
try {
const bytes = await fetchSample();
const app = document.querySelector<HTMLElement>('.app');
const landing = document.querySelector<HTMLDivElement>('.landing-overlay');
const homeButton = document.querySelector<HTMLButtonElement>('#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<HTMLElement>('.app');
const landing = document.querySelector<HTMLDivElement>('.landing-overlay');
if (app) {
app.hidden = false;
app.setAttribute('aria-hidden', 'false');
}
if (landing) landing.hidden = true;
setStatus('도면 입력은 현재 개발 중입니다. 기존 도면 분석 모듈과 연결되기 전까지는 파일 선택 흐름만 유지합니다.');
};
/** Piers that need a (re)build: never generated, or with changes not yet in the 3D model. */
function generationTargets(): Pier[] {
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 -----------------------------------------------------------------------------
$<HTMLButtonElement>('open-import').addEventListener('click', openImport);
$<HTMLButtonElement>('dlg-close').addEventListener('click', () => {
$<HTMLDialogElement>('import-dialog').close();
document.querySelector<HTMLElement>('.landing-overlay')?.classList.remove('modal-open');
});
$<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => {
$<HTMLDialogElement>('import-dialog').close();
document.querySelector<HTMLElement>('.landing-overlay')?.classList.remove('modal-open');
});
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChanged);
$<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);
});
$<HTMLButtonElement>('cp-items-reset').addEventListener('click', () => {
const src = activePier();
copyItemChoice = new Set(src ? copyItemsWithOverrides(src) : []);
renderCopyItems();
renderCopyPreview();
});
$<HTMLButtonElement>('cp-clear').addEventListener('click', () => {
copySelection = new Set();
renderCopyTargets();
renderCopyPreview();
});
$<HTMLInputElement>('cp-regen').addEventListener('change', renderCopyPreview);
$<HTMLButtonElement>('cp-apply').addEventListener('click', applyCopyPanel);
$<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>('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();
wireAllPiersPicking();
buildPartToggles();
buildViewButtons();
document.querySelectorAll<HTMLInputElement>('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 = $<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>('all-toggle').addEventListener('click', () => {
state.allView = !state.allView;
setupScene();
renderAll();
});
$<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);
}
});