Files
Pier_v1/src/view/scene.ts
T
ypshimandClaude Haiku 5.5 22fd1dc5f4 feat(view): solplate taper across the 교각축 직각 (thick T1 at the (−) side); 교량받침 본체 as a box
The solplate's L (A + margin) runs across the 교각축 직각 and its W (B + margin) along the 교축; the top tapers across L with
T1 at the (−) side (T1 = T + L·|S|/200). The 교량받침 본체 is one box per bearing: the solplate plan less 100 mm on each
side, from the mortar top (EL.X) up h3 = 181 mm, turned with the bearing row. It is shown with the bearing group.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-23 14:56:26 +09:00

227 lines
9.3 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 * as THREE from 'three';
import type { PierModel } from '../core/geometry';
import { solplateGeometry } from '../core/solplate';
export type PartName = 'coping' | 'columns' | 'footings' | 'piles' | 'blinding' | 'blocks' | 'pads' | 'bearings' | 'solplates' | 'ground';
export const PART_LABELS: Record<PartName, string> = {
coping: '코핑',
columns: '기둥',
footings: '확대기초',
piles: '말뚝',
blinding: '버림콘크리트',
blocks: '받침블럭',
pads: '모르타르 패드',
bearings: '교량받침 본체',
solplates: '솔플레이트',
ground: '지반',
};
const COLORS: Record<PartName, number> = {
coping: 0xb8c2cc,
columns: 0x9aa7b4,
footings: 0x7d8a99,
piles: 0x5b6878,
blinding: 0xe6e2d8,
blocks: 0xd6dde5,
pads: 0x8898aa,
bearings: 0x5b6676,
solplates: 0x6f7c8a,
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 stretchMatrix(m: PierModel, k: number): THREE.Matrix4 {
const st = m.stretches[k];
const X = new THREE.Vector3(st.u.e, 0, -st.u.n);
const Y = new THREE.Vector3(0, 1, 0);
const Z = new THREE.Vector3(st.nrm.e, 0, -st.nrm.n);
const origin = new THREE.Vector3(st.origin.e - m.pivot.e, 0, -(st.origin.n - m.pivot.n));
return new THREE.Matrix4().makeBasis(X, Y, Z).setPosition(origin);
}
/**
* Mitre plane at the bend point. Stretch 0 keeps the side (p − kink)·mitre ≤ 0 and stretch 1 the other side,
* so each stretch's full-length slab is trimmed to its own half and the two meet at the mitred joint.
* Three.js keeps fragments with a non-negative distance from the plane, hence the sign flip for stretch 0.
*/
/** Mitre plane for one stretch as a list, or undefined on a straight axis. */
export function planesOf(m: PierModel, k: number): THREE.Plane[] | undefined {
const plane = mitrePlane(m, k);
return plane ? [plane] : undefined;
}
/** Straight-frame matrix (stretch 0). */
export function frameMatrix(m: PierModel): THREE.Matrix4 {
return stretchMatrix(m, 0);
}
export function mitrePlane(m: PierModel, k: number): THREE.Plane | undefined {
if (!m.kink || !m.mitre) return undefined;
const normal = new THREE.Vector3(m.mitre.e, 0, -m.mitre.n).multiplyScalar(k === 0 ? -1 : 1);
const point = new THREE.Vector3(m.kink.e - m.pivot.e, 0, -(m.kink.n - m.pivot.n));
return new THREE.Plane().setFromNormalAndCoplanarPoint(normal, point);
}
export interface PierScene {
root: THREE.Group;
parts: Record<PartName, THREE.Group>;
size: number;
}
export function buildPierScene(m: PierModel): PierScene {
const Ms = m.stretches.map((_, k) => stretchMatrix(m, k));
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, k = 0, clippingPlanes?: THREE.Plane[]) => {
geometry.applyMatrix4(Ms[k]);
const material = new THREE.MeshStandardMaterial({
color: COLORS[part],
roughness: 0.75,
metalness: 0.05,
transparent: opacity < 1,
opacity,
side: THREE.DoubleSide,
clippingPlanes,
});
material.userData.baseOpacity = opacity;
parts[part].add(new THREE.Mesh(geometry, material));
};
// Coping: each part's elevation outline extruded across its width (no boolean operations), trimmed by the mitre plane.
for (const part of m.copingParts) {
const shape = new THREE.Shape(part.profile.map(([s, z]) => new THREE.Vector2(s, z)));
const coping = new THREE.ExtrudeGeometry(shape, { depth: part.nTo - part.nFrom, bevelEnabled: false });
coping.translate(0, 0, part.nFrom);
const plane = mitrePlane(m, part.k);
add('coping', coping, 1, part.k, plane ? [plane] : undefined);
}
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, 1, c.k);
}
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, 1, f.k, planesOf(m, f.k));
}
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, 1, b.k, planesOf(m, b.k));
}
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, 1, p.k, planesOf(m, p.k));
}
for (const b of m.blocks) {
// Turn the block so its sides follow the bearing row (rowSkewDeg); the 3D turn is rotateY(−skew).
const turn = (-b.rowSkewDeg * Math.PI) / 180;
const h = b.top - b.bottom;
const g = new THREE.BoxGeometry(1.2, h, 1.2);
g.rotateY(turn);
g.translate(b.s, b.bottom + h / 2, b.n);
add('blocks', g, 1, b.k);
const pad = new THREE.BoxGeometry(1.0, 0.05, 1.0);
pad.rotateY(turn);
pad.translate(b.s, b.padTop - 0.025, b.n);
add('pads', pad, 1, b.k);
if (b.plan) {
// 교량받침 본체 (승인 2026-10-23): the solplate plan less 100 mm on each side, from the mortar top up to the solplate.
const body = new THREE.BoxGeometry((b.plan.B + b.plan.margin) / 1000 - 0.2, b.plan.h3 / 1000, (b.plan.A + b.plan.margin) / 1000 - 0.2);
body.rotateY(turn);
body.translate(b.s, b.padTop + b.plan.h3 / 2000, b.n);
add('bearings', body, 1, b.k);
// Solplate: a wedge that tapers across the 교각축 직각 (T1 at the (−) side), on the bearing (승인 2026-10-23).
const sp = solplateGeometry(b, b.plan.h3, { A: b.plan.A, B: b.plan.B, margin: b.plan.margin, T: b.plan.T, slopePct: b.plan.S });
add('solplates', wedgeGeometry(sp.corners), 1, b.k);
}
}
// 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 };
}
/** ③ rebar view: members and the ground at half opacity so the bars show through (승인 2026-10-23). */
const REBAR_VIEW_ALPHA = 0.5;
/** Turns the ③ rebar view on or off. Off restores each part's own opacity. Depth writes stop while on, so bars behind a member stay visible. */
export function setRebarView(scene: PierScene, on: boolean): void {
for (const group of Object.values(scene.parts)) {
group.traverse((o) => {
if (!(o instanceof THREE.Mesh)) return;
const mat = o.material as THREE.MeshStandardMaterial;
const base = mat.userData.baseOpacity as number;
mat.opacity = on ? REBAR_VIEW_ALPHA : base;
mat.transparent = on || base < 1;
mat.depthWrite = !on;
mat.needsUpdate = true;
});
}
}
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 };
}
/**
* A wedge from the eight corners of solplateGeometry: corner index (ai, ci, t) = (ai·2 + ci)·2 + t, with ai the 교축 side
* (0 = 시점), ci the 교각축 직각 side and t 0 = underside, 1 = top. Each face is a quad split into two triangles.
*/
function wedgeGeometry(corners: [number, number, number][]): THREE.BufferGeometry {
const idx = (ai: number, ci: number, t: number) => (ai * 2 + ci) * 2 + t;
const quads = [
[idx(0, 0, 0), idx(0, 1, 0), idx(1, 1, 0), idx(1, 0, 0)],
[idx(0, 0, 1), idx(1, 0, 1), idx(1, 1, 1), idx(0, 1, 1)],
[idx(0, 0, 0), idx(0, 1, 0), idx(0, 1, 1), idx(0, 0, 1)],
[idx(1, 0, 0), idx(1, 0, 1), idx(1, 1, 1), idx(1, 1, 0)],
[idx(0, 0, 0), idx(0, 0, 1), idx(1, 0, 1), idx(1, 0, 0)],
[idx(0, 1, 0), idx(1, 1, 0), idx(1, 1, 1), idx(0, 1, 1)],
];
const index: number[] = [];
for (const [q0, q1, q2, q3] of quads) index.push(q0, q1, q2, q0, q2, q3);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(corners.flat(), 3));
geo.setIndex(index);
geo.computeVertexNormals();
return geo;
}