Files
Pier_v1/src/view/scene.ts
T
ypshimandClaude Haiku 5.5 36b26209df feat(bearing): 솔플레이트 규칙을 교각별 입력으로 바꾸고 도면 불러오기 제거 (C안)
- 받침 기준 카드, 도면 불러오기(xlsx·csv·dxf), 도면 참조 표·오버레이 제거
- 교각마다 솔플레이트 규칙 입력: 받침높이 H(솔플레이트 포함) 211, 교직 L 1000,
  교축 W 1100, 종단 기울기 S −0.5 % (기본값, 가정 표시)
- 중심 두께 T = ⌈22 + L·|S|/200⌉ 로 계산해 얇은 쪽 T2가 22 mm 이상이 되게 함
  (기본값 T 25, T2 22.5, h3 = H − T = 186)
- geometry·scene·SVG는 교각 규칙에서 만든 같은 솔플레이트 계획을 모든 받침에 사용

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 11:07:51 +09:00

264 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: '지반',
};
/** Ground pad drawn without a terrain grid (m): a square of side = coping length + margin, thin. */
const GROUND_PAD = { margin: 2, thickness: 0.05 } as const;
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, groundEl: number | null = m.groundEl): 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,
depthWrite: opacity >= 1,
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);
{
// 교량받침 본체: the solplate plan less 100 mm on each side, from the mortar top up to the solplate.
const body = new THREE.BoxGeometry(b.plan.W / 1000 - 0.2, b.plan.h3 / 1000, b.plan.L / 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.
const sp = solplateGeometry(b, b.plan);
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 떨어진 지형까지 보임).
// Without a terrain, a thin square pad at this pier's 지반고, side = coping length + 2 m (승인 2026-10-27).
// It is a local estimate, not a terrain surface: the pads are not joined to each other.
const terrainSurface = terrain ? terrainGeometry(terrain, m.pivot) : undefined;
if (terrainSurface && (terrainSurface.getIndex()?.count ?? 0) > 0) {
const material = new THREE.MeshStandardMaterial({ color: COLORS.ground, roughness: 0.9, metalness: 0, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide });
material.userData.baseOpacity = 0.35;
parts.ground.add(new THREE.Mesh(terrainSurface, material));
} else if (groundEl !== null) {
const side = m.copingL + GROUND_PAD.margin;
const pad = new THREE.BoxGeometry(side, GROUND_PAD.thickness, side);
pad.translate(m.copingL / 2, groundEl, 0);
add('ground', pad, 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;
}