From ad8e437c9911cd81ee85874ccc2892667c7b1c50 Mon Sep 17 00:00:00 2001 From: ypshim Date: Wed, 21 Oct 2026 17:28:02 +0900 Subject: [PATCH] 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 --- index.html | 56 ++++++++++ src/main.ts | 255 ++++++++++++++++++++++++++++++++++++++++++++ src/style.css | 227 +++++++++++++++++++++++++++++++++++++++ src/view/scene.ts | 137 ++++++++++++++++++++++++ src/vite-env.d.ts | 1 + tests/scene.test.ts | 51 +++++++++ 6 files changed, 727 insertions(+) create mode 100644 index.html create mode 100644 src/main.ts create mode 100644 src/style.css create mode 100644 src/view/scene.ts create mode 100644 src/vite-env.d.ts create mode 100644 tests/scene.test.ts diff --git a/index.html b/index.html new file mode 100644 index 0000000..5f52b57 --- /dev/null +++ b/index.html @@ -0,0 +1,56 @@ + + + + + + 교각 3D 형상 생성 (1단계) + + +
+

교각 3D 형상 생성 ① 형상 생성 (1단계)

+
+ + +
+
+ +
+ + +
+
+
+ +
+

계산 결과

+
+

알림

+
    +

    기본값 적용 · 미구현

    +
      +
      +
      + + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..d343aa4 --- /dev/null +++ b/src/main.ts @@ -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 $ = (id: string): T => document.getElementById(id) as T; + +const VIEW_LABELS: Record = { iso: '등각', front: '정면', side: '측면', top: '평면' }; + +const state: { + piers: Loaded[]; + parseIssues: Issue[]; + current: { loaded: Loaded; scene: PierScene } | null; + view: ViewKind; + visible: Record; +} = { + piers: [], + parseIssues: [], + current: null, + view: 'iso', + visible: Object.fromEntries(Object.keys(PART_LABELS).map((k) => [k, true])) as Record, +}; + +// --- 3D renderer ----------------------------------------------------------- +const container = $('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(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 = $('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 = $('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('#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 = $('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 = $('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 = $('summary').tBodies[0] ?? $('summary').createTBody(); + body.replaceChildren(); + const issuesBox = $('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 = $('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 ----------------------------------------------------------------------- +$('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}`; + } +}); + +$('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}`; + } +}); + +$('pier').addEventListener('change', (ev) => showPier((ev.target as HTMLSelectElement).value)); + +buildPartToggles(); +buildViewButtons(); +renderResults(null); diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..bb20c0f --- /dev/null +++ b/src/style.css @@ -0,0 +1,227 @@ +:root { + --bg: #f5f7fa; + --panel: #ffffff; + --text: #1d2733; + --muted: #5b6878; + --line: #d5dce4; + --primary: #1f5fa8; + --warn: #9a5b00; + --focus: 2px solid #1f5fa8; + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --bg: #111820; + --panel: #1a232d; + --text: #e6ebf0; + --muted: #9aa7b4; + --line: #2d3a47; + --primary: #5b93d6; + --warn: #e0a24a; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.5 system-ui, 'Noto Sans KR', sans-serif; +} + +button, +select, +input, +label { + font: inherit; +} + +button, +select, +.file { + min-height: 44px; +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: var(--focus); + outline-offset: 2px; +} + +.top { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.top h1 { + font-size: 18px; + margin: 0; +} + +.stage { + font-size: 13px; + color: var(--muted); + font-weight: 400; + margin-left: 8px; +} + +.actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.primary { + background: var(--primary); + color: #fff; + border: 0; + border-radius: 6px; + padding: 0 14px; + cursor: pointer; +} + +.file { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 6px; + cursor: pointer; +} + +.file input { + font-size: 12px; + max-width: 220px; +} + +.layout { + display: grid; + grid-template-columns: 240px 1fr 320px; + gap: 12px; + padding: 12px 16px; + min-height: calc(100vh - 80px); +} + +.side, +.results, +.viewer { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; +} + +.side section, +.results section { + margin-bottom: 16px; +} + +h2 { + font-size: 15px; + margin: 0 0 8px; +} + +.muted { + color: var(--muted); +} + +select { + width: 100%; + padding: 6px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--text); +} + +.parts label, +.views button { + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; +} + +.views { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.views button { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--text); + justify-content: center; + cursor: pointer; +} + +.views button[aria-pressed='true'] { + border-color: var(--primary); + color: var(--primary); + font-weight: 600; +} + +.canvas { + width: 100%; + height: calc(100vh - 140px); + min-height: 420px; + border-radius: 6px; + overflow: hidden; + background: #e9eef4; +} + +table { + width: 100%; + border-collapse: collapse; +} + +td { + padding: 4px 6px; + border-bottom: 1px solid var(--line); + vertical-align: top; +} + +td:first-child { + color: var(--muted); + width: 48%; +} + +.issues { + padding-left: 18px; + margin: 0; +} + +.issues li { + margin-bottom: 6px; + color: var(--text); +} + +.issues .code { + font-weight: 600; + color: var(--warn); +} + +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + } + + .canvas { + height: 60vh; + } +} diff --git a/src/view/scene.ts b/src/view/scene.ts new file mode 100644 index 0000000..9b041f9 --- /dev/null +++ b/src/view/scene.ts @@ -0,0 +1,137 @@ +import * as THREE from 'three'; +import type { PierModel } from '../core/geometry'; + +export type PartName = 'coping' | 'columns' | 'footings' | 'piles' | 'blinding' | 'blocks' | 'pads' | 'ground'; + +export const PART_LABELS: Record = { + coping: '코핑', + columns: '기둥', + footings: '확대기초', + piles: '말뚝', + blinding: '버림콘크리트', + blocks: '받침블럭', + pads: '모르타르 패드', + ground: '지반', +}; + +const COLORS: Record = { + coping: 0xb8c2cc, + columns: 0x9aa7b4, + footings: 0x7d8a99, + piles: 0x5b6878, + blinding: 0xe6e2d8, + blocks: 0xd6dde5, + pads: 0x8898aa, + ground: 0x6b8f4e, +}; + +/** + * Maps the model's local frame (x = along axis s, y = absolute EL, z = across axis n) to the scene. + * The scene uses y as up and places the first column centre at the origin (spec §1). + * Basis vectors: X = (u.e, 0, -u.n), Z = (nrm.e, 0, -nrm.n). The determinant is +1, so faces keep their winding. + */ +export function frameMatrix(m: PierModel): THREE.Matrix4 { + const X = new THREE.Vector3(m.u.e, 0, -m.u.n); + const Y = new THREE.Vector3(0, 1, 0); + const Z = new THREE.Vector3(m.nrm.e, 0, -m.nrm.n); + const origin = new THREE.Vector3(m.bp1.e - m.pivot.e, 0, -(m.bp1.n - m.pivot.n)); + return new THREE.Matrix4().makeBasis(X, Y, Z).setPosition(origin); +} + +export interface PierScene { + root: THREE.Group; + parts: Record; + size: number; +} + +export function buildPierScene(m: PierModel): PierScene { + const M = frameMatrix(m); + const parts = {} as Record; + for (const key of Object.keys(PART_LABELS) as PartName[]) { + parts[key] = new THREE.Group(); + parts[key].name = key; + } + + const add = (part: PartName, geometry: THREE.BufferGeometry, opacity = 1) => { + geometry.applyMatrix4(M); + const material = new THREE.MeshStandardMaterial({ + color: COLORS[part], + roughness: 0.75, + metalness: 0.05, + transparent: opacity < 1, + opacity, + side: THREE.DoubleSide, + }); + parts[part].add(new THREE.Mesh(geometry, material)); + }; + + // Coping: the elevation outline extruded across the width (no boolean operations). + const shape = new THREE.Shape(m.profile.map(([s, z]) => new THREE.Vector2(s, z))); + const coping = new THREE.ExtrudeGeometry(shape, { depth: m.copingB, bevelEnabled: false }); + coping.translate(0, 0, -m.copingB / 2); + add('coping', coping); + + for (const c of m.columns) { + const g = new THREE.CylinderGeometry(c.diameter / 2, c.diameter / 2, c.top - c.bottom, 48); + g.translate(c.s, (c.top + c.bottom) / 2, 0); + add('columns', g); + } + + for (const f of m.footings) { + const g = new THREE.BoxGeometry(f.length, f.top - f.bottom, f.width); + g.translate(f.s, (f.top + f.bottom) / 2, 0); + add('footings', g); + } + + for (const b of m.blindings) { + const g = new THREE.BoxGeometry(b.length, b.thickness, b.width); + g.translate(b.s, b.top - b.thickness / 2, 0); + add('blinding', g); + } + + for (const p of m.piles) { + const g = new THREE.CylinderGeometry(p.diameter / 2, p.diameter / 2, p.top - p.bottom, 20); + g.translate(p.s, (p.top + p.bottom) / 2, p.n); + add('piles', g); + } + + for (const b of m.blocks) { + const h = b.top - b.bottom; + const g = new THREE.BoxGeometry(1.2, h, 1.2); + g.translate(b.s, b.bottom + h / 2, b.n); + add('blocks', g); + const pad = new THREE.BoxGeometry(1.0, 0.05, 1.0); + pad.translate(b.s, b.padTop - 0.025, b.n); + add('pads', pad); + } + + // Ground: a translucent plane at the ground EL, used only to judge soil cover (not a computed input). + const ground = new THREE.PlaneGeometry(60, 60); + ground.rotateX(-Math.PI / 2); + ground.translate(m.copingL / 2, m.groundEl, 0); + add('ground', ground, 0.35); + + const root = new THREE.Group(); + for (const key of Object.keys(parts) as PartName[]) root.add(parts[key]); + const box = new THREE.Box3().setFromObject(root); + const size = Math.max(...box.getSize(new THREE.Vector3()).toArray()); + return { root, parts, size }; +} + +export type ViewKind = 'iso' | 'front' | 'side' | 'top'; + +/** Camera position for one of the four view presets, looking at the centre of the model. */ +export function viewPosition(scene: PierScene, kind: ViewKind, m: PierModel): { position: THREE.Vector3; target: THREE.Vector3 } { + const box = new THREE.Box3().setFromObject(scene.root); + const target = box.getCenter(new THREE.Vector3()); + const dist = scene.size * 1.6; + const X = new THREE.Vector3(m.u.e, 0, -m.u.n); + const Z = new THREE.Vector3(m.nrm.e, 0, -m.nrm.n); + const dirs: Record = { + iso: new THREE.Vector3(1, 0.7, 1).normalize(), + front: Z.clone().add(new THREE.Vector3(0, 0.15, 0)).normalize(), + side: X.clone().add(new THREE.Vector3(0, 0.15, 0)).normalize(), + top: new THREE.Vector3(0, 1, 0.001).normalize(), + }; + return { position: target.clone().add(dirs[kind].multiplyScalar(dist)), target }; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/scene.test.ts b/tests/scene.test.ts new file mode 100644 index 0000000..b6821f0 --- /dev/null +++ b/tests/scene.test.ts @@ -0,0 +1,51 @@ +import * as THREE from 'three'; +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { buildPierScene, frameMatrix } from '../src/view/scene'; +import { loadP1 } from './helpers'; + +const model = buildPier(loadP1()); +const M = frameMatrix(model); + +/** Scene coordinates of a national-grid point at EL 0 (x = E − pivot E, z = −(N − pivot N)). */ +function sceneOf(e: number, n: number, el: number): THREE.Vector3 { + return new THREE.Vector3(e - model.pivot.e, el, -(n - model.pivot.n)); +} + +describe('3D frame (view/scene.ts)', () => { + it('is a proper rotation (determinant +1) so faces keep their winding', () => { + expect(M.determinant()).toBeCloseTo(1, 9); + }); + + it('maps local (along-axis s, across n, EL) to the national-grid position', () => { + // Block 1 on the front row: its centre in grid coordinates must match the local-to-grid formula. + const b = model.blocks[0]; + const grid = { + e: model.bp1.e + model.u.e * b.s + model.nrm.e * b.n, + n: model.bp1.n + model.u.n * b.s + model.nrm.n * b.n, + }; + const mapped = new THREE.Vector3(b.s, b.bottom, b.n).applyMatrix4(M); + const expected = sceneOf(grid.e, grid.n, b.bottom); + expect(mapped.distanceTo(expected)).toBeLessThan(1e-6); + }); + + it('bounding box spans pile tip (2.208 m) to the highest pad top (back bearing 5, 43.898 m = sheet E41)', () => { + const scene = buildPierScene(model); + const box = new THREE.Box3().setFromObject(scene.root); + expect(box.min.y).toBeCloseTo(2.208, 2); + expect(box.max.y).toBeCloseTo(43.898, 2); + }); + + it('builds the expected part counts for P1', () => { + const scene = buildPierScene(model); + const count = (key: keyof typeof scene.parts) => scene.parts[key].children.length; + expect(count('coping')).toBe(1); + expect(count('columns')).toBe(1); + expect(count('footings')).toBe(1); + expect(count('piles')).toBe(20); + expect(count('blocks')).toBe(10); + expect(count('pads')).toBe(10); + expect(count('blinding')).toBe(1); + expect(count('ground')).toBe(1); + }); +});