feat(abut): 교대 형상·입력 패널과 교각 받침·솔플레이트 개편

- 교대 형상(src/core/abut, 3D 장면, 목록·카메라·맞춤)과 교대 입력 패널 추가
- 교각 받침 기준 카드 제거, 솔플레이트 규칙을 교각별 입력으로 변경
- 받침 ELEVATION·솔플레이트 TABLE을 모델값으로 연결
- 교대 입력값 카드: 형식 행에 시점·종점 표시, 제목을 "A1 입력값 [입력]"으로 변경
- 테스트 614건 통과, 타입 검사 통과

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-03 14:51:34 +09:00
1 parent b1b2ad1ddb
commit 1d31d2f3a9
44 files changed
+2571 -120

No files matched your search

+289 -44
View File
@@ -4,6 +4,17 @@ 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 } from './core/abut/shape';
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';
@@ -24,7 +35,6 @@ import { CORROSION_ALLOWANCE_MM, parseNValue, SEA_DATUM, STRATA_NAMES } from './
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 { solplateDetailSvg } from './view/solplate-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';
@@ -106,10 +116,21 @@ 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 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>,
@@ -151,6 +172,9 @@ let pendingBackup: Backup | null = null;
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;
@@ -406,12 +430,25 @@ function setView(kind: ViewKind): void {
if (!scene) return;
const pier = activePier();
if (!pier?.model) return;
const { position, target } = viewPosition(scene, kind, pier.model);
camera.position.copy(position);
controls.target.copy(target);
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) 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[] {
@@ -446,6 +483,26 @@ function buildAllScene(): boolean {
}
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);
abutGroups.push(group);
abutGroupByName.set(rec.name, group);
world.add(group);
} catch {
// 입력 오류는 가져오기 때 알림으로 표시한다. 여기서는 그리지 않는다.
}
}
}
if (groundMapGroup) {
world.remove(groundMapGroup);
disposeTerrainMapGroup(groundMapGroup);
@@ -499,27 +556,222 @@ function renderPierList(): void {
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));
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: { 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);
for (const slot of [ABUT_SLOTS.shape, ABUT_SLOTS.plan]) {
const box = $<HTMLElement>(slot.root);
box.hidden = !rec;
if (rec) fillAbutSlot(slot, rec);
}
}
/** 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();
bearings.replaceChildren();
shape.replaceChildren();
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 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)));
basic.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));
});
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';
name.setAttribute('aria-pressed', String(pier.input.name === state.active));
// 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')
@@ -531,6 +783,11 @@ function renderPierRows(list: HTMLUListElement, mode: Stage): void {
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 {
@@ -691,6 +948,7 @@ 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;
@@ -2107,7 +2365,7 @@ let bearingRebarView: 'section' | 'plan' = 'section';
function renderBearingLayout(wrap: HTMLDivElement, pier: Pier): void {
if (!pier.model) return;
// 상세 보기 (승인 2026-10-26): pair chips (①·⑥ …) and the plan with the drawing values overlaid as dashed outlines.
// 상세 보기 (승인 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'));
@@ -2125,47 +2383,23 @@ function renderBearingLayout(wrap: HTMLDivElement, pier: Pier): void {
wrap.appendChild(chips);
const current = pairList[bearingPairIndex];
if (current) {
const fig = el('figure', undefined, 'ground-fig');
// 삽도 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', '평면 · 실선 = 모델(솔플레이트, 블록 윤곽) · C4·C5는 교축 방향, 받침 중심에서 블록 바깥 연단까지', 'muted small'));
wrap.appendChild(fig);
fig.appendChild(el('figcaption', '교량받침 삽도 · 평면 (방위각 회전 생략) · C1–C3 교축, C4·C5 교각축, C6 블록 폭', 'muted small'));
sketch.appendChild(fig);
// 단면 (elevation): the blocks, mortar, bearing body and solplates.
const section = el('figure', undefined, 'ground-fig');
section.innerHTML = bearingSectionSvg(pier.model, bearingPairIndex);
section.appendChild(el('figcaption', '단면 · EL.X는 모르타르 상단 · h3 = 받침 본체 · 솔플레이트 T1(−측)·T2(+측)', 'muted small'));
wrap.appendChild(section);
section.appendChild(el('figcaption', '교량받침 삽도 · 단면 · E = h1 + h2 + h3 + h4 · S1 = 받침중심 ↔ 코핑 연단, S2 = 받침 사이', 'muted small'));
sketch.appendChild(section);
// 솔플레이트 상세 for the two bearings of the pair (plan and taper).
const solGrid = el('div', undefined, 'footing-grid');
for (const no of [current.frontNo, current.backNo]) {
const block = pier.model.blocks.find((b) => b.no === no)!;
const fig2 = el('figure', undefined, 'footing-fig');
fig2.innerHTML = solplateDetailSvg(block);
fig2.appendChild(el('figcaption', `솔플레이트 상세 · ${no}번 · 두께 과장`, 'muted small'));
solGrid.appendChild(fig2);
}
wrap.appendChild(solGrid);
wrap.appendChild(sketch);
}
wrap.appendChild(el('p', `배치 규칙 (모델값 · 편심 한계 ${PAIR_ECCENTRICITY_LIMIT_MM} mm)`, 'lbl'));
const edges = bearingEdgeDistances(pier.model);
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['받침', '열', 'C4 (교각축 · 받침 ↔ 블록 연단, mm)', 'C5 (교각축 · 받침 ↔ 블록 연단, mm)']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
for (const e of edges) {
const tr = el('tr');
for (const v of [String(e.no), e.row, e.leftMm.toFixed(0), e.rightMm.toFixed(0)]) tr.appendChild(el('td', v));
body.appendChild(tr);
}
table.appendChild(body);
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
const pairs = pairEccentricities(pier.model.blocks);
const forced = pairs.filter((p) => !p.allowed).length;
wrap.appendChild(el('p', `쌍 편심 (전열 k · 후열 k, 교각축 방향): 최대 ${Math.max(...pairs.map((p) => p.eccentricityMm)).toFixed(0)} mm · ${forced === 0 ? '모든 쌍 일체 가능' : `${forced}쌍은 편심 초과 → 분리 우선`}`, 'muted small'));
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. */
@@ -2449,7 +2683,7 @@ function renderElevationTable(wrap: HTMLDivElement, pier: Pier): void {
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['No', '열', 'EL.X', 'h1 블록', 'h2 몰탈', 'h3 받침', 'h4 솔플', 'E', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'S1 (본수·길이)', 'S2 (본수·길이)']) hr.appendChild(el('th', h));
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');
@@ -2457,7 +2691,7 @@ function renderElevationTable(wrap: HTMLDivElement, pier: Pier): void {
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), 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)];
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);
}
@@ -2650,6 +2884,8 @@ function snapshot(): string {
material: state.material,
fckByMember: state.fckByMember,
groundModel,
abutments: state.abutments,
abutDefaults: state.abutDefaults,
});
}
@@ -2667,6 +2903,8 @@ function currentProjectText(): string {
common: state.material,
fckByMember: state.fckByMember,
groundModel: groundModel ?? undefined,
abutments: state.abutments,
abutDefaults: state.abutDefaults,
});
}
@@ -2797,6 +3035,8 @@ function openProjectText(text: string, saved: boolean): void {
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);
@@ -2965,7 +3205,9 @@ function updateApply(): void {
}
function loadBytes(bytes: Uint8Array, fileName: string): void {
const { piers, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' }));
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) {
@@ -2973,7 +3215,7 @@ function loadBytes(bytes: Uint8Array, fileName: string): void {
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 };
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();
@@ -2985,6 +3227,8 @@ function applyImport(): void {
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;
@@ -3004,7 +3248,8 @@ function applyImport(): void {
document.querySelector<HTMLElement>('.landing-overlay')?.setAttribute('hidden', 'true');
setupScene();
renderAll();
setStatus(`${state.piers.length}기 형상을 생성했습니다. 정보·경고는 오른쪽 알림에서 확인하세요.`);
const abutNote = state.abutments.length > 0 ? ` 교대 ${state.abutments.length}기를 읽었습니다${p.abutIssues.some((i) => i.level === 'E') ? ' (입력 오류 있음)' : ''}.` : '';
setStatus(`${state.piers.length}기 형상을 생성했습니다.${abutNote} 정보·경고는 오른쪽 알림에서 확인하세요.`);
}
function openImport(): void {