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:
ypshimandClaude Haiku 5.5 committed 2026-10-21 17:28:02 +09:00
1 parent d963d8ed3c
commit ad8e437c99
6 files changed
+727

No files matched your search

+137
View File
@@ -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<PartName, string> = {
coping: '코핑',
columns: '기둥',
footings: '확대기초',
piles: '말뚝',
blinding: '버림콘크리트',
blocks: '받침블럭',
pads: '모르타르 패드',
ground: '지반',
};
const COLORS: Record<PartName, number> = {
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<PartName, THREE.Group>;
size: number;
}
export function buildPierScene(m: PierModel): PierScene {
const M = frameMatrix(m);
const parts = {} as Record<PartName, THREE.Group>;
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<ViewKind, THREE.Vector3> = {
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 };
}