Files
Pier_v1/src/main.ts
T
ypshimandClaude Haiku 5.5 6319cccb8e feat(import): input template download with three sheets (spec 2.3 step 4)
- buildTemplateWorkbook: 교각 · IP · 받침 sheets with the headers the parser
  reads; value cells left empty so no blank-name rows raise E05
- dialog button 입력 템플릿 다운로드 (.xlsx)
- tests: sheet order, parser recognition, write-and-read round trip

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

591 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import './style.css';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import * as XLSX from 'xlsx';
import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
import { buildTemplateWorkbook } from './core/template';
import type { Issue, PierInput, PierType } from './core/types';
import { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
interface Pier {
input: PierInput;
model: PierModel | null;
issues: Issue[];
checked: boolean;
}
/** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */
const PART_GROUPS: { label: string; parts: PartName[] }[] = [
{ label: '받침·받침블럭', parts: ['blocks', 'pads'] },
{ label: '코핑', parts: ['coping'] },
{ label: '기둥', parts: ['columns'] },
{ label: '확대기초', parts: ['footings'] },
{ label: '말뚝', parts: ['piles'] },
{ label: '버림', parts: ['blinding'] },
{ label: '지반면', parts: ['ground'] },
];
const VIEWS: { kind: ViewKind; label: string }[] = [
{ kind: 'iso', label: '아이소' },
{ kind: 'front', label: '정면' },
{ kind: 'side', label: '측면' },
{ kind: 'top', label: '평면' },
];
const TYPES: PierType[] = ['T형', '2주형', '3주형'];
interface Pending {
fileName: string;
entries: Pier[];
sheets: SheetReport[];
preview: string | null;
}
const state = {
piers: [] as Pier[],
active: null as string | null,
view: 'iso' as ViewKind,
visible: Object.fromEntries(PART_GROUPS.flatMap((g) => g.parts).map((p) => [p, true])) as Record<PartName, boolean>,
scene: null as PierScene | null,
project: { name: '', bridge: '' },
filter: 'all' as 'all' | PierType,
pending: null as Pending | null,
};
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;
}
function setStatus(msg: string): void {
$('status').textContent = msg;
}
// --- 3D renderer -------------------------------------------------------------
const container = $<HTMLDivElement>('canvas');
const renderer = new THREE.WebGLRenderer({ antialias: true });
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);
resize();
function animate(): void {
controls.update();
renderer.render(world, camera);
requestAnimationFrame(animate);
}
animate();
// --- pier model ----------------------------------------------------------------
function buildEntry(input: PierInput, checked: boolean): Pier {
const issues: Issue[] = [];
let model: PierModel | null = null;
if (validateInput(input, issues)) {
try {
model = buildPier(input);
validatePier(input, model, issues);
} catch (err) {
issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message });
}
}
return { input, model, issues, checked };
}
function sheetOk(sheets: SheetReport[], name: string): boolean {
return sheets.find((s) => s.name === name)?.ok === true;
}
function activePier(): Pier | undefined {
return state.piers.find((p) => p.input.name === state.active);
}
// --- parts and views -------------------------------------------------------------
function buildPartToggles(): void {
const box = $<HTMLDivElement>('parts');
const row = el('div', undefined, 'parts-row');
for (const group of PART_GROUPS) {
const label = el('label');
const input = el('input');
input.type = 'checkbox';
input.checked = group.parts.every((p) => state.visible[p]);
input.addEventListener('change', () => {
for (const p of group.parts) {
state.visible[p] = input.checked;
if (state.scene) state.scene.parts[p].visible = input.checked;
}
});
label.append(input, document.createTextNode(group.label));
row.appendChild(label);
}
box.appendChild(row);
}
function buildViewButtons(): void {
const box = $<HTMLDivElement>('views');
for (const v of VIEWS) {
const btn = el('button', v.label, 'btn');
btn.type = 'button';
btn.setAttribute('aria-pressed', String(v.kind === state.view));
btn.addEventListener('click', () => setView(v.kind));
box.appendChild(btn);
}
}
function setView(kind: ViewKind): void {
state.view = kind;
document.querySelectorAll<HTMLButtonElement>('#views button').forEach((b, i) => {
b.setAttribute('aria-pressed', String(VIEWS[i].kind === kind));
});
if (!state.scene) return;
const pier = activePier();
if (!pier?.model) return;
const { position, target } = viewPosition(state.scene, kind, pier.model);
camera.position.copy(position);
controls.target.copy(target);
controls.update();
}
function setupScene(): void {
if (state.scene) world.remove(state.scene.root);
state.scene = null;
const pier = activePier();
if (pier?.model) {
const scene = buildPierScene(pier.model);
for (const key of Object.keys(state.visible) as PartName[]) {
scene.parts[key].visible = state.visible[key];
}
world.add(scene.root);
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})`;
}
const list = $<HTMLUListElement>('pier-items');
list.replaceChildren();
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
$<HTMLParagraphElement>('pier-empty').hidden = state.piers.length > 0;
for (const pier of visible) {
const li = el('li');
const check = el('input');
check.type = 'checkbox';
check.checked = pier.checked;
check.disabled = !pier.model;
check.setAttribute('aria-label', `${pier.input.name} 선택`);
check.addEventListener('change', () => {
pier.checked = check.checked;
renderAll();
});
const name = el('button', pier.input.name, 'pier-name');
name.type = 'button';
name.setAttribute('aria-pressed', String(pier.input.name === state.active));
name.addEventListener('click', () => {
state.active = pier.input.name;
setupScene();
renderAll();
});
const badge = pier.model
? el('span', `${pier.input.type}${pier.issues.some((i) => i.level !== 'I') ? ' · 경고' : ''}`, 'badge')
: el('span', '오류', 'badge error');
li.append(check, name, badge);
list.appendChild(li);
}
const allChecked = visible.length > 0 && visible.every((p) => p.checked);
$<HTMLButtonElement>('select-all').textContent = allChecked ? '선택 해제' : '전체 선택';
}
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 ? '생성됨' : '미생성';
$<HTMLButtonElement>('regen').disabled = !state.piers.some((p) => p.checked);
}
// --- results panel (시안 ① 입력값 · 계산 결과) ----------------------------------------
type Pair = [string, string];
function fillKv(dl: HTMLElement, pairs: Pair[]): void {
dl.replaceChildren();
for (const [k, v] of pairs) {
const row = el('div');
row.append(el('dt', k), el('dd', v));
dl.appendChild(row);
}
}
const f3 = (v: number) => v.toFixed(3);
const pt = (p: { e: number; n: number }) => `E ${p.e.toFixed(3)}, N ${p.n.toFixed(3)}`;
function renderPanels(): void {
const pier = activePier();
const m = pier?.model;
$('viewer-title').textContent = pier ? `${pier.input.name} · ${pier.input.type} 교각 형상` : '교각 3D 뷰';
$('origin').textContent = m ? `원점 = 교각 중심 (${pt(m.pivot)})` : '';
if (!pier) {
fillKv($('inputs'), []);
fillKv($('res-axis'), []);
fillKv($('res-column'), []);
fillKv($('res-found'), []);
$('issues').replaceChildren(el('li', '교각을 불러오면 알림이 표시됩니다.'));
$('issue-count').textContent = '';
return;
}
const inp = pier.input;
const front = inp.bearings.filter((b) => b.row === '전열').length;
const back = inp.bearings.filter((b) => b.row === '후열').length;
const ip2 = inp.ips.find((p) => p.name === 'IP4');
fillKv($('inputs'), [
['교각 / 기초 형식', `${inp.type} · ${inp.foundationType}`],
['코핑 상면 EL', `${f3(inp.copingTopEl)} m`],
['기초 하단 EL', `${f3(inp.footingBottomEl)} m`],
['지반고 EL', `${f3(inp.groundEl)} m`],
['코핑 폭 × 길이', `${inp.copingWidthMm} × ${inp.copingLengthMm} mm`],
['말뚝 길이', `${inp.pileLengthMm} mm`],
['IP', ip2 && ip2.angleDeg === 0 ? 'IP1–IP3 직선 (꺾임 0°)' : '꺾임 코핑'],
['받침', `전열 ${front} · 후열 ${back}`],
]);
if (!m) {
fillKv($('res-axis'), [['상태', '생성 불가 (알림 참조)']]);
fillKv($('res-column'), []);
fillKv($('res-found'), []);
} else {
fillKv($('res-axis'), [
['IP 길이 / 축 방위각', `${f3(m.lengthIp)} m / ${m.azimuthDeg.toFixed(2)}°`],
['받침블럭 높이 전열 / 후열', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)} mm`],
['계단 경계 (BP1 기준)', m.segments.slice(1).map((s) => f3(s.s0)).join(' / ') + ' m'],
['횡방향 단차', '없음'],
['코핑 하면 EL 끝단 / Level', `${f3(m.endBottom)} / ${f3(m.levelBottom)} m`],
]);
fillKv($('res-column'), [
['기둥 높이', `${f3(m.derived.columnHeight)} m`],
['기둥 중심', pt(m.pivot)],
]);
fillKv($('res-found'), [
['확대기초 상면 EL', m.footings.length ? `${f3(m.footings[0].top)} m` : '없음'],
['말뚝 배치', m.derived.pileLayout ?? '없음'],
['말뚝 수 / 선단 EL', `${m.derived.pileCount} 본 / ${m.derived.pileTipEl === null ? '없음' : f3(m.derived.pileTipEl) + ' m'}`],
['흙덮개', `${f3(m.derived.soilCover)} m`],
]);
}
const list = $<HTMLUListElement>('issues');
list.replaceChildren();
if (pier.issues.length === 0) list.appendChild(el('li', '알림 없음'));
for (const issue of pier.issues) {
const li = el('li');
li.append(el('span', issue.code, `code ${issue.level}`), document.createTextNode(` ${issue.message}`));
list.appendChild(li);
}
$('issue-count').textContent = `(${pier.issues.length}건)`;
}
function renderDefaults(): void {
const box = $<HTMLUListElement>('defaults');
box.replaceChildren();
const items = [
'[미확정] Q16 받침 EL·E 식: 현재 모델(블럭 높이 = EL 차) 적용',
'[미확정] Q5 말뚝 지름 600 mm (강관) 기본값',
'[미확정] Q4 직접기초 버림콘크리트 적용',
...NOT_IMPLEMENTED_RULES.map((r) => `미구현 규칙: ${r}`),
];
for (const text of items) box.appendChild(el('li', text));
}
function renderAll(): void {
renderHeader();
renderPierList();
renderPanels();
}
// --- import dialog (시안 ④) ------------------------------------------------------------
function selectable(p: Pending, e: Pier): boolean {
return !!e.model && sheetOk(p.sheets, e.input.sheet);
}
function renderDialog(): void {
const p = state.pending;
const sheetsBox = $<HTMLUListElement>('sheets');
sheetsBox.replaceChildren();
$('file-name').textContent = p ? `선택된 파일: ${p.fileName}` : '';
if (!p) {
sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.'));
renderPreview(null);
updateApply();
return;
}
for (const sheet of p.sheets) {
const li = el('li');
const head = el('div', undefined);
const label =
sheet.piers === 0 ? (sheet.ok ? '보조 표 시트 (IP·받침)' : '오류') : sheet.ok ? '선택 가능' : '선택 불가 (오류)';
head.append(
el('span', `${sheet.name} · ${label}`, sheet.ok ? 'ok' : 'bad'),
document.createTextNode(` · 교각 ${sheet.tables.pier}표 · IP ${sheet.tables.ip}표 · 받침 ${sheet.tables.bearing}표`),
);
li.appendChild(head);
for (const issue of sheet.issues) {
const line = el('div', `${issue.code} ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`, 'small');
line.style.color = issue.level === 'E' ? 'var(--error)' : 'var(--muted)';
li.appendChild(line);
}
for (const entry of p.entries.filter((e) => e.input.sheet === sheet.name)) {
const row = el('div', undefined, 'sheet-pier');
const check = el('input');
check.type = 'checkbox';
check.checked = entry.checked;
check.disabled = !selectable(p, entry);
check.setAttribute('aria-label', `${entry.input.name} 불러오기`);
check.addEventListener('change', () => {
entry.checked = check.checked;
updateApply();
});
const name = el('button', `${entry.input.name} · ${entry.input.type}`, 'pier-name');
name.type = 'button';
name.setAttribute('aria-pressed', String(p.preview === entry.input.name));
name.addEventListener('click', () => {
p.preview = entry.input.name;
renderDialog();
});
row.append(check, name);
li.appendChild(row);
}
sheetsBox.appendChild(li);
}
const preview = p.entries.find((e) => e.input.name === p.preview) ?? p.entries.find((e) => selectable(p, e)) ?? null;
renderPreview(preview ? preview.input : null);
updateApply();
}
function renderPreview(input: PierInput | null): void {
const title = $('preview-title');
const pierTable = $<HTMLTableElement>('pv-pier').tBodies[0];
const bearingBody = $<HTMLTableElement>('pv-bearings').tBodies[0];
const ipBody = $<HTMLTableElement>('pv-ips').tBodies[0];
pierTable.replaceChildren();
bearingBody.replaceChildren();
ipBody.replaceChildren();
if (!input) {
title.textContent = '미리볼 교각이 없습니다.';
return;
}
title.textContent = `${input.name} · 시트 ${input.sheet}`;
const pairs: Pair[] = [
['교각명', input.name],
['교각형식', input.type],
['두부보 EL (코핑 상면)', f3(input.copingTopEl)],
['기초 하단 EL', f3(input.footingBottomEl)],
['지반고 EL', f3(input.groundEl)],
['기초 형식', input.foundationType],
['말뚝길이 (mm)', String(input.pileLengthMm)],
['두부보 폭 × 길이 (mm)', `${input.copingWidthMm} × ${input.copingLengthMm}`],
['기둥갯수', String(input.columnCount)],
];
for (const [k, v] of pairs) {
const tr = el('tr');
tr.append(el('th', k), el('td', v));
pierTable.appendChild(tr);
}
for (const b of input.bearings) {
const tr = el('tr');
const azimuth = Number.isNaN(b.azimuthDeg) ? '오류' : `${b.azimuthDeg.toFixed(4)}°`;
for (const v of [String(b.no), b.x.toFixed(3), b.y.toFixed(3), f3(b.z), azimuth, b.row]) tr.appendChild(el('td', v));
bearingBody.appendChild(tr);
}
for (const ip of input.ips) {
const tr = el('tr');
for (const v of [ip.name, ip.x.toFixed(3), ip.y.toFixed(3), `${ip.angleDeg}°`]) tr.appendChild(el('td', v));
ipBody.appendChild(tr);
}
}
function updateApply(): void {
const p = state.pending;
const count = p ? p.entries.filter((e) => e.checked && selectable(p, e)).length : 0;
const total = p ? p.entries.filter((e) => selectable(p, e)).length : 0;
$('dlg-selected').textContent = `선택 ${count} / ${total}기`;
const named = $<HTMLInputElement>('project-name').value.trim().length > 0;
const apply = $<HTMLButtonElement>('dlg-apply');
apply.textContent = `선택한 교각 ${count}기 형상 생성`;
apply.disabled = count === 0 || !named;
}
function loadBytes(bytes: Uint8Array, fileName: string): void {
const { piers, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' }));
const entries = piers.map((input) => buildEntry(input, false));
// A sheet is selectable only if every pier in it builds. Show the reason next to the sheet.
for (const sheet of sheets) {
const own = entries.filter((e) => e.input.sheet === sheet.name);
for (const e of own) sheet.issues.push(...e.issues.filter((i) => i.level === 'E'));
sheet.ok = sheet.ok && own.every((e) => e.model !== null);
}
const pending: Pending = { fileName, entries, sheets, preview: null };
for (const e of entries) e.checked = selectable(pending, e);
pending.preview = entries.find((e) => selectable(pending, e))?.input.name ?? entries[0]?.input.name ?? null;
state.pending = pending;
renderDialog();
}
function applyImport(): void {
const p = state.pending;
const name = $<HTMLInputElement>('project-name').value.trim();
if (!p || !name) return;
state.project = { name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };
state.piers = p.entries.filter((e) => e.checked && selectable(p, e));
state.active = state.piers[0]?.input.name ?? null;
state.filter = 'all';
state.pending = null;
$<HTMLDialogElement>('import-dialog').close();
setupScene();
renderAll();
setStatus(`${state.piers.length}기 형상을 생성했습니다. 정보·경고는 오른쪽 알림에서 확인하세요.`);
}
function openImport(): void {
const dlg = $<HTMLDialogElement>('import-dialog');
if (!dlg.open) dlg.showModal();
$<HTMLInputElement>('project-name').focus();
}
async function fetchSample(): Promise<Uint8Array> {
const res = await fetch(new URL('samples/P1.xlsx', document.baseURI));
if (!res.ok) throw new Error(`샘플 응답 ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}
function regenerateChecked(): void {
let errors = 0;
let count = 0;
state.piers = state.piers.map((p) => {
if (!p.checked) return p;
count++;
const next = buildEntry(p.input, true);
if (!next.model) errors++;
return next;
});
setupScene();
renderAll();
setStatus(`${count}기 형상을 생성했습니다.${errors ? ` 오류 ${errors}기는 생성되지 않았습니다.` : ''}`);
}
// --- events -----------------------------------------------------------------------------
$<HTMLButtonElement>('open-import').addEventListener('click', openImport);
$<HTMLButtonElement>('dlg-close').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
$<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChecked);
$<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) {
setStatus(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
}
});
$<HTMLButtonElement>('pick-sample').addEventListener('click', async () => {
try {
loadBytes(await fetchSample(), '샘플 P1 (Pier ui_xxx.xlsx)');
} catch (err) {
setStatus(`샘플을 불러오지 못했습니다: ${(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) {
setStatus(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
}
});
$<HTMLSelectElement>('type-filter').addEventListener('change', (ev) => {
state.filter = (ev.target as HTMLSelectElement).value as 'all' | PierType;
renderPierList();
});
$<HTMLButtonElement>('select-all').addEventListener('click', () => {
const visible = state.piers.filter((p) => (state.filter === 'all' || p.input.type === state.filter) && p.model);
const all = visible.every((p) => p.checked);
for (const p of visible) p.checked = !all;
renderAll();
});
// --- start ------------------------------------------------------------------------------
buildPartToggles();
buildViewButtons();
renderDefaults();
renderDialog();
renderAll();
// 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');
$<HTMLInputElement>('project-name').value = '운곡교 P1 샘플';
$<HTMLInputElement>('bridge-name').value = '운곡교';
applyImport();
})
.catch((err: Error) => setStatus(`샘플을 불러오지 못했습니다: ${err.message}`));
}