feat(view): add 3D pier viewer and P1 sample page
- three.js scene in the pier frame (pivot at first column centre) - coping as extruded elevation outline; columns, footings, piles, blinding, bearing blocks and pads - four view presets, per-part visibility toggles - workbook load (sample or file), result table, issue list and undetermined defaults - frame orientation test and bounding-box test Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d963d8ed3c
commit
ad8e437c99
6 files changed
+727
No files matched your search
+255
@@ -0,0 +1,255 @@
|
||||
import './style.css';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { parseWorkbook } from './core/parseWorkbook';
|
||||
import { buildPier, type PierModel } from './core/geometry';
|
||||
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
|
||||
import type { Issue, PierInput } from './core/types';
|
||||
import { buildPierScene, PART_LABELS, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
|
||||
|
||||
interface Loaded {
|
||||
input: PierInput;
|
||||
model: PierModel | null;
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||
|
||||
const VIEW_LABELS: Record<ViewKind, string> = { iso: '등각', front: '정면', side: '측면', top: '평면' };
|
||||
|
||||
const state: {
|
||||
piers: Loaded[];
|
||||
parseIssues: Issue[];
|
||||
current: { loaded: Loaded; scene: PierScene } | null;
|
||||
view: ViewKind;
|
||||
visible: Record<PartName, boolean>;
|
||||
} = {
|
||||
piers: [],
|
||||
parseIssues: [],
|
||||
current: null,
|
||||
view: 'iso',
|
||||
visible: Object.fromEntries(Object.keys(PART_LABELS).map((k) => [k, true])) as Record<PartName, boolean>,
|
||||
};
|
||||
|
||||
// --- 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;
|
||||
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();
|
||||
|
||||
// --- UI builders (textContent only: workbook text is never parsed as HTML) -------
|
||||
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 buildPartToggles(): void {
|
||||
const box = $<HTMLDivElement>('parts');
|
||||
for (const key of Object.keys(PART_LABELS) as PartName[]) {
|
||||
const label = el('label');
|
||||
const input = el('input');
|
||||
input.type = 'checkbox';
|
||||
input.checked = state.visible[key];
|
||||
input.addEventListener('change', () => {
|
||||
state.visible[key] = input.checked;
|
||||
if (state.current) state.current.scene.parts[key].visible = input.checked;
|
||||
});
|
||||
label.append(input, document.createTextNode(PART_LABELS[key]));
|
||||
box.appendChild(label);
|
||||
}
|
||||
}
|
||||
|
||||
function buildViewButtons(): void {
|
||||
const box = $<HTMLDivElement>('views');
|
||||
for (const kind of Object.keys(VIEW_LABELS) as ViewKind[]) {
|
||||
const btn = el('button', VIEW_LABELS[kind]);
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('aria-pressed', String(kind === state.view));
|
||||
btn.addEventListener('click', () => setView(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(Object.keys(VIEW_LABELS)[i] === kind));
|
||||
});
|
||||
if (!state.current) return;
|
||||
const { position, target } = viewPosition(state.current.scene, kind, state.current.loaded.model!);
|
||||
camera.position.copy(position);
|
||||
controls.target.copy(target);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
// --- Loading ----------------------------------------------------------------------
|
||||
function loadWorkbookBytes(bytes: Uint8Array): void {
|
||||
const { piers, issues: parseIssues } = parseWorkbook(XLSX.read(bytes, { type: 'array' }));
|
||||
const loaded: Loaded[] = piers.map((input) => {
|
||||
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 };
|
||||
});
|
||||
state.piers = loaded;
|
||||
state.parseIssues = parseIssues;
|
||||
|
||||
const select = $<HTMLSelectElement>('pier');
|
||||
select.replaceChildren();
|
||||
for (const item of loaded) {
|
||||
if (!item.model) continue;
|
||||
select.appendChild(new Option(item.input.name, item.input.name));
|
||||
}
|
||||
select.disabled = select.options.length === 0;
|
||||
const built = loaded.filter((l) => l.model).length;
|
||||
$('status').textContent = `교각 ${piers.length}기 인식, 형상 생성 ${built}기`;
|
||||
const first = loaded.find((l) => l.model);
|
||||
if (first) showPier(first.input.name);
|
||||
else showPier(null);
|
||||
}
|
||||
|
||||
function showPier(name: string | null): void {
|
||||
const select = $<HTMLSelectElement>('pier');
|
||||
if (state.current) world.remove(state.current.scene.root);
|
||||
state.current = null;
|
||||
if (!name) {
|
||||
renderResults(null);
|
||||
return;
|
||||
}
|
||||
const loaded = state.piers.find((l) => l.input.name === name && l.model);
|
||||
if (!loaded || !loaded.model) return;
|
||||
const scene = buildPierScene(loaded.model);
|
||||
for (const key of Object.keys(PART_LABELS) as PartName[]) {
|
||||
scene.parts[key].visible = state.visible[key];
|
||||
}
|
||||
world.add(scene.root);
|
||||
state.current = { loaded, scene };
|
||||
select.value = name;
|
||||
setView(state.view);
|
||||
renderResults(loaded);
|
||||
}
|
||||
|
||||
// --- Results ------------------------------------------------------------------------
|
||||
function row(table: HTMLTableSectionElement, label: string, value: string): void {
|
||||
const tr = el('tr');
|
||||
tr.append(el('td', label), el('td', value));
|
||||
table.appendChild(tr);
|
||||
}
|
||||
|
||||
const fmt = (v: number, d = 3) => v.toFixed(d);
|
||||
const pt = (p: { e: number; n: number }) => `E ${p.e.toFixed(3)}, N ${p.n.toFixed(3)}`;
|
||||
|
||||
function renderResults(loaded: Loaded | null): void {
|
||||
const body = $<HTMLTableElement>('summary').tBodies[0] ?? $<HTMLTableElement>('summary').createTBody();
|
||||
body.replaceChildren();
|
||||
const issuesBox = $<HTMLUListElement>('issues');
|
||||
issuesBox.replaceChildren();
|
||||
|
||||
const m = loaded?.model;
|
||||
if (m && loaded) {
|
||||
row(body, '교각', loaded.input.name);
|
||||
row(body, '교각축 길이 L_IP (m)', fmt(m.lengthIp));
|
||||
row(body, '교각축 방위각 (°)', fmt(m.azimuthDeg, 2));
|
||||
row(body, 'BP1 (국토좌표 m)', pt(m.bp1));
|
||||
row(body, 'BP2 (국토좌표 m)', pt(m.bp2));
|
||||
row(body, 'd_IP2 (m)', fmt(m.dIp2));
|
||||
row(body, '기둥 중심 (국토좌표 m)', pt(m.pivot));
|
||||
row(body, '코핑 상면 EL 기준 (m)', fmt(loaded.input.copingTopEl));
|
||||
row(body, '전열 구간 상면 EL (m)', m.segments.map((s) => fmt(s.top)).join(' / '));
|
||||
row(body, '코핑 끝단 하면 EL (m)', fmt(m.endBottom));
|
||||
row(body, '코핑 Level 하면 EL (m)', fmt(m.levelBottom));
|
||||
row(body, '기둥 높이 (m)', fmt(m.derived.columnHeight));
|
||||
row(body, '확대기초 상면 EL (m)', m.footings.length ? fmt(m.footings[0].top) : '없음');
|
||||
row(body, '흙덮개 (m)', fmt(m.derived.soilCover));
|
||||
row(body, '말뚝 수 (본)', String(m.derived.pileCount));
|
||||
row(body, '말뚝 선단 EL (m)', m.derived.pileTipEl === null ? '없음' : fmt(m.derived.pileTipEl));
|
||||
row(body, '받침블럭 콘크리트 전열 / 후열 (mm)', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)}`);
|
||||
} else {
|
||||
row(body, '상태', '교각을 불러오면 결과가 표시됩니다.');
|
||||
}
|
||||
|
||||
const allIssues = [...state.parseIssues, ...state.piers.flatMap((l) => l.issues)];
|
||||
if (allIssues.length === 0) {
|
||||
issuesBox.appendChild(el('li', '알림 없음'));
|
||||
}
|
||||
for (const issue of allIssues) {
|
||||
const li = el('li');
|
||||
li.append(el('span', issue.code, 'code'), document.createTextNode(` ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`));
|
||||
issuesBox.appendChild(li);
|
||||
}
|
||||
|
||||
const defaults = $<HTMLUListElement>('defaults');
|
||||
defaults.replaceChildren();
|
||||
const items = [
|
||||
'[미확정] Q16 받침 EL·E 식: 현재 모델(블럭 높이 = EL 차) 적용',
|
||||
'[미확정] Q5 말뚝 지름 600 mm (강관) 기본값',
|
||||
'[미확정] Q4 직접기초 버림콘크리트 적용',
|
||||
'횡방향 단차: 없을 때만 생성 (있으면 미실시 알림)',
|
||||
...NOT_IMPLEMENTED_RULES.map((r) => `미구현 규칙: ${r}`),
|
||||
];
|
||||
for (const text of items) defaults.appendChild(el('li', text));
|
||||
}
|
||||
|
||||
// --- Events -----------------------------------------------------------------------
|
||||
$<HTMLButtonElement>('sample').addEventListener('click', async () => {
|
||||
$('status').textContent = '샘플 불러오는 중…';
|
||||
try {
|
||||
const res = await fetch(new URL('samples/P1.xlsx', document.baseURI));
|
||||
loadWorkbookBytes(new Uint8Array(await res.arrayBuffer()));
|
||||
} catch (err) {
|
||||
$('status').textContent = `샘플을 불러오지 못했습니다: ${(err as Error).message}`;
|
||||
}
|
||||
});
|
||||
|
||||
$<HTMLInputElement>('file').addEventListener('change', async (ev) => {
|
||||
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
$('status').textContent = `${file.name} 읽는 중…`;
|
||||
try {
|
||||
loadWorkbookBytes(new Uint8Array(await file.arrayBuffer()));
|
||||
} catch (err) {
|
||||
$('status').textContent = `엑셀을 읽지 못했습니다: ${(err as Error).message}`;
|
||||
}
|
||||
});
|
||||
|
||||
$<HTMLSelectElement>('pier').addEventListener('change', (ev) => showPier((ev.target as HTMLSelectElement).value));
|
||||
|
||||
buildPartToggles();
|
||||
buildViewButtons();
|
||||
renderResults(null);
|
||||
Reference in new issue
Block a user