Files
Pier_v1/src/view/scene.ts
T
ypshimandClaude Haiku 5.5 43a18182f3 feat(bearing): 일체 block as one stepped block per pair, with its rebar (승인 2026-10-26)
- integrated-block.ts: per pair (전열 k · 후열 k) one block centred on the
  pair midpoint, 2500 across the 교축 and 1200 along the 교각축, with a
  stepped top (each half to its own bearing row top). Only when 일체 is chosen
  and every pair is within 100 mm; otherwise 분리 is kept (B02).
- Rebar: S2 19 bars at 125 across the 2500 (10 front half, 9 back half), each
  leg from its half's top; S1 8 bars across the 1200, 3800 long, depth from
  the lower top. Totals per pier: S1 40, S2 95 (분리: 80 / 80).
- Scene and 3D rebar draw the stepped blocks instead of the per-bearing blocks.
- The per-bearing blocks stay for the solplate, the pads, and the body.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 12:02:33 +09:00

258 lines
11 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';
import type { TerrainGrid } from '../core/ground-model';
import { terrainGeometry } from './terrain';
import { INTEGRATED_BLOCK } from '../core/integrated-block';
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, terrain?: TerrainGrid): 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));
}
// 일체 (승인 2026-10-26): the per-bearing concrete blocks give way to one stepped block per pair (below).
const integrated = m.integratedBlocks.length > 0;
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;
if (!integrated) {
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);
}
}
for (const ib of m.integratedBlocks) {
const k = m.blocks.find((b) => b.no === ib.frontNo && b.row === '전열')!.k;
const turn = (-ib.rowSkewDeg * Math.PI) / 180;
// Two halves of 1250 across the 교축, each to its own top: the front half (lower n) and the back half.
for (const [top, zc] of [[ib.topFront, ib.n - INTEGRATED_BLOCK.length / 4000], [ib.topBack, ib.n + INTEGRATED_BLOCK.length / 4000]] as const) {
const h = top - ib.bottom;
const g = new THREE.BoxGeometry(INTEGRATED_BLOCK.depth / 1000, h, INTEGRATED_BLOCK.length / 2000);
g.rotateY(turn);
g.translate(ib.s, ib.bottom + h / 2, zc);
add('blocks', g, 1, k);
}
}
// Ground: the terrain grid around the piers when the ground model has one (수 m 떨어진 지형까지 보임),
// otherwise a translucent plane at the ground EL, used only to judge soil cover (not a computed input).
if (terrain) {
const material = new THREE.MeshStandardMaterial({ color: COLORS.ground, roughness: 0.9, metalness: 0, transparent: true, opacity: 0.35, side: THREE.DoubleSide });
material.userData.baseOpacity = 0.35;
parts.ground.add(new THREE.Mesh(terrainGeometry(terrain, m.pivot), material));
} else {
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]);
// The camera fits the pier, not the terrain: with a terrain grid the size comes from the members only.
const box = new THREE.Box3();
for (const key of Object.keys(parts) as PartName[]) {
if (key !== 'ground' || !terrain) box.expandByObject(parts[key]);
}
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;
}