Files
Pier_v1/src/view/abut-scene.ts
T
2026-11-03 17:22:23 +09:00

347 lines
18 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.
/**
* 교대 3D 장면 (2026-11-03): 벽체, 흉벽, 저판, 말뚝, 날개벽, 헌치를 Three.js 그룹으로 만든다. 순수 함수 (렌더러 없음).
*
* 좌표 (장면 기준): x = 교대축 방향 s (IP1 → IP3), y = EL (위 +), z = 교대축 직각 t (전면 +).
* 날개벽 평면(E, N)은 축 기준 (s, t)로 바꿔 쓴다: s = (P − IP1)·u, t = (P − IP1)·(전면 법선).
* 흉벽·벽체 단면은 모식도다. 흉벽은 저판 상면에서 상단까지 그린다(교좌면 아래 세부는 배근 단계에서).
*/
import * as THREE from 'three';
import type { AbutModel, AbutPoint } from '../core/abut/geometry';
import { buildMembers, type AbutMembers } from '../core/abut/members';
import { buildWings, HAUNCH_LEG_M, WING_THICKNESS_M, type WingSolid } from '../core/abut/wings';
import type { TerrainGrid } from '../core/ground-model';
import { terrainGeometry } from './terrain';
export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'slab'] as const;
/** 접속슬래브 두께 (m), spec_abut §4.8. */
const SLAB_THICKNESS_M = 0.45;
/** 접속슬래브 길이 (배면 쪽, m), spec_abut §4.8 "7000 (날개벽 축 방향)". */
const SLAB_LENGTH_M = 7;
/** 접속슬래브 하향 경사 (배면 쪽으로), spec_abut §4.8 "하향 4 %". */
const SLAB_FALL = 0.04;
/** 접속슬래브와 날개벽 안쪽면 사이 간격 (m), spec_abut §4.8 "날개벽 20". */
const SLAB_CLEARANCE_M = 0.02;
/** Eight-corner slab with its top sloping away from the wall. Indexed by (s end, t end, bottom/top). Double-sided: the winding is not critical. */
function slabGeometry(plan: [number, number][], bottomEl: (s: number, t: number) => number): THREE.BufferGeometry {
// plan: four (s, t) corners in order; vertices 0–3 are the bottom, 4–7 the top.
const corners = [...plan.map(([s, t]) => [s, bottomEl(s, t), t]), ...plan.map(([s, t]) => [s, bottomEl(s, t) + SLAB_THICKNESS_M, t])];
const quads = [
[0, 1, 2, 3], [4, 5, 6, 7], // bottom and top
[0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7], // sides
];
const positions: number[] = [];
const indices: number[] = [];
for (const q of quads) {
const base = positions.length / 3;
for (const i of q) positions.push(...corners[i]);
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geom.setIndex(indices);
geom.computeVertexNormals();
return geom;
}
/** 지반 pad margin (m) around the abutment plan, like the pier's GROUND_PAD. */
export const ABUT_GROUND_MARGIN_M = 2;
/** The third axis point is IP3; the model keeps IP1 only, so it is taken from the axis length and direction. */
function input3(model: AbutModel): AbutPoint {
return { e: model.ip1.e + model.u.e * model.length, n: model.ip1.n + model.u.n * model.length };
}
/** True when every point (with margin m) lies inside the terrain grid's E–N extent. */
export function covers(grid: TerrainGrid, points: AbutPoint[], margin: number): boolean {
const eMin = grid.origin[1];
const eMax = grid.origin[1] + (grid.ny - 1) * grid.step;
const nMin = grid.origin[0];
const nMax = grid.origin[0] + (grid.nx - 1) * grid.step;
return points.every((p) => p.e - margin >= eMin && p.e + margin <= eMax && p.n - margin >= nMin && p.n + margin <= nMax);
}
/** Extrudes a closed profile given in its own (x, y) plane, placed by the basis vectors ax, ay and the extrusion az. */
function prism(profile: [number, number][], depth: number, origin: THREE.Vector3, ax: THREE.Vector3, ay: THREE.Vector3, az: THREE.Vector3): THREE.BufferGeometry {
const shape = new THREE.Shape(profile.map(([x, y]) => new THREE.Vector2(x, y)));
const geom = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false, steps: 1 });
geom.applyMatrix4(new THREE.Matrix4().makeBasis(ax, ay, az).setPosition(origin));
return geom;
}
function material(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({ color, side: THREE.DoubleSide, roughness: 0.85 });
}
/** Local (s, t) of a plan point, relative to the IP1 end of the axis. */
function toAxis(model: AbutModel, p: AbutPoint): { s: number; t: number } {
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * front.e + d.n * front.n };
}
/** Keeps the part of a 2-D profile where g(x, y) ≥ 0 (Sutherland–Hodgman; g is linear, so the cut is a straight line). */
function clipByLine(poly: [number, number][], g: (x: number, y: number) => number): [number, number][] {
const out: [number, number][] = [];
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
const gp = g(p[0], p[1]);
const gq = g(q[0], q[1]);
if (gp >= 0) out.push(p);
if (gp * gq < 0) {
const f = gp / (gp - gq);
out.push([p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f]);
}
}
return out;
}
/** Keeps the part of a 2-D profile with x ≥ x0 (Sutherland–Hodgman against one line). */
function clipBelowX(poly: [number, number][], x0: number): [number, number][] {
const out: [number, number][] = [];
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
if (p[0] >= x0) out.push(p);
if ((p[0] - x0) * (q[0] - x0) < 0) {
const y = p[1] + ((q[1] - p[1]) * (x0 - p[0])) / (q[0] - p[0]);
out.push([x0, y]);
}
}
return out;
}
function wingDirectionAxis(model: AbutModel): { ws: number; wt: number } {
const rad = ((model.wing?.lineDeg ?? 0) * Math.PI) / 180;
const w = { e: Math.cos(rad), n: Math.sin(rad) };
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
return { ws: w.e * model.u.e + w.n * model.u.n, wt: w.e * front.e + w.n * front.n };
}
/**
* Placement of the abutment in the pier scene (2026-11-03). The pier scene maps (E, N, EL) to
* (E − pivot.E, EL, −(N − pivot.N)); the abutment's local (s, t, EL) → (E, N) with s along u and t along the front
* normal, then the same mapping. So the abutment and any pier in the same workbook share one frame.
*/
export function abutPlacement(model: AbutModel, pivot: { e: number; n: number }): THREE.Matrix4 {
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
const xs = new THREE.Vector3(model.u.e, 0, -model.u.n);
const y = new THREE.Vector3(0, 1, 0);
const zt = new THREE.Vector3(f.e, 0, -f.n);
const origin = new THREE.Vector3(model.ip1.e - pivot.e, 0, -(model.ip1.n - pivot.n));
return new THREE.Matrix4().makeBasis(xs, y, zt).setPosition(origin);
}
export function buildAbutGroup(
model: AbutModel,
members: AbutMembers = buildMembers(model),
wings: WingSolid[] = buildWings(model, members),
pivot?: { e: number; n: number },
groundEl?: number,
terrain?: TerrainGrid,
): THREE.Group {
const root = new THREE.Group();
root.name = model.name;
if (pivot) {
// applyMatrix4 keeps auto-update on, so the world matrix is refreshed by the scene and by bounding-box calls.
root.applyMatrix4(abutPlacement(model, pivot));
}
const L = model.length;
const ex = new THREE.Vector3(1, 0, 0);
const ey = new THREE.Vector3(0, 1, 0);
const ez = new THREE.Vector3(0, 0, 1);
const add = (name: (typeof ABUT_GROUP_NAMES)[number], geom: THREE.BufferGeometry, color: number) => {
const mesh = new THREE.Mesh(geom, material(color));
mesh.name = name;
let group = root.getObjectByName(name) as THREE.Group | undefined;
if (!group) {
group = new THREE.Group();
group.name = name;
root.add(group);
}
group.add(mesh);
};
const footingTop = members.footingTopEl;
const footingBottom = members.footingBottomEl;
// 벽체 (spec §4.3, 삽도): the full 2400 block from the footing top up to the 교좌면 step (t −0.8 … 1.6).
const parapetTopAt = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / L;
const bracketAt = (s: number) => parapetTopAt(s) - members.shape.bracketHeightM;
for (const seg of members.wallSegments) {
const block: [number, number][] = [
[seg.sFrom, footingTop],
[seg.sTo, footingTop],
[seg.sTo, seg.seatEl],
[seg.sFrom, seg.seatEl],
];
add('wall', prism(block, members.shape.wallFrontM - members.shape.wallBackM, new THREE.Vector3(0, 0, members.shape.wallBackM), ex, ey, ez), 0xb7c7d3);
}
// 흉벽 (삽도): sits on the 벽체 top, above the 교좌면 step. The back strip (t −0.8 … −0.5) rises to the 브라켓 상면 (흉벽 상단 − 0.85),
// where the 접속슬래브 sits; the 흉벽 (t −0.5 … 0) rises to the parapet top line. Both start at the 교좌면 step, so nothing overlaps the 벽체.
for (const seg of members.wallSegments) {
const backStrip: [number, number][] = [
[seg.sFrom, seg.seatEl],
[seg.sTo, seg.seatEl],
[seg.sTo, bracketAt(seg.sTo)],
[seg.sFrom, bracketAt(seg.sFrom)],
];
add('parapet', prism(backStrip, members.shape.parapetBackM - members.shape.wallBackM, new THREE.Vector3(0, 0, members.shape.wallBackM), ex, ey, ez), 0x8898aa);
const parapet: [number, number][] = [
[seg.sFrom, seg.seatEl],
[seg.sTo, seg.seatEl],
[seg.sTo, parapetTopAt(seg.sTo)],
[seg.sFrom, parapetTopAt(seg.sFrom)],
];
add('parapet', prism(parapet, -members.shape.parapetBackM, new THREE.Vector3(0, 0, members.shape.parapetBackM), ex, ey, ez), 0x8898aa);
}
if (footingBottom !== undefined) {
// 저판: s 0 … L, t −1.8 … +2.8, from the 기초 하단 to the 저판 상면.
const footProfile: [number, number][] = [
[0, members.shape.footingBackM],
[L, members.shape.footingBackM],
[L, members.shape.footingFrontM],
[0, members.shape.footingFrontM],
];
add('footing', prism(footProfile, members.shape.footingThicknessM, new THREE.Vector3(0, footingBottom, 0), ex, ez, ey), 0x5a6a78);
}
// 받침블럭 (spec §4.2, §6.6): concrete 1200 × 1200 from the 교좌면 to 50 below z, and the 몰탈패드 1000 × 1000 × 50 up to z.
// The plan follows the 교대축 (받침 방위각 = 축 방향). t is the 전면 +, so the bearing's lateral offset is taken with the front side.
for (const b of model.bearings) {
const t = model.frontSide * b.lateral;
const blockTop = b.z - 0.05;
add('blocks', prism([[b.s - 0.6, b.seatEl], [b.s + 0.6, b.seatEl], [b.s + 0.6, blockTop], [b.s - 0.6, blockTop]], 1.2, new THREE.Vector3(0, 0, t - 0.6), ex, ey, ez), 0xa9b4bf);
add('blocks', prism([[b.s - 0.5, blockTop], [b.s + 0.5, blockTop], [b.s + 0.5, b.z], [b.s - 0.5, b.z]], 1.0, new THREE.Vector3(0, 0, t - 0.5), ex, ey, ez), 0x6e7a86);
}
// 접속슬래브 (spec §4.8): bottom on the 브라켓 top (흉벽 상단 − 0.85), 5 mm behind the 흉벽 back face, 7 m back, falling 4 % away from the wall.
// Plan: between the wing inner faces, each 20 mm inside (spec §4.8 "측면 = 날개벽 안쪽면 + 20"), so it never meets a wing.
// The wing is drawn as a prism over the wing direction with its 0.45 m thickness along s, so its inner face at t is s = sEnd + a·0.45 + (ws/wt)·t.
const slabNear = members.shape.parapetBackM - 0.005;
const slabFar = slabNear - SLAB_LENGTH_M;
const innerS = (sEnd: number, t: number): number => {
const w = wings.find((x) => x.sEnd === sEnd);
if (!w) return sEnd; // no wing on this side: the slab ends at the 교좌면 끝선
const a = w.sEnd === 0 ? 1 : -1;
const p0 = toAxis(model, w.corners[0]);
const p1 = toAxis(model, w.corners[1]);
return sEnd + a * (WING_THICKNESS_M + SLAB_CLEARANCE_M) + ((p1.s - p0.s) / (p1.t - p0.t)) * t;
};
const bracketTopEl = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / L - members.shape.bracketHeightM;
const slabMesh = new THREE.Mesh(
slabGeometry(
[
[innerS(0, slabNear), slabNear],
[innerS(L, slabNear), slabNear],
[innerS(L, slabFar), slabFar],
[innerS(0, slabFar), slabFar],
],
// Flat on the bracket where it lies over the back strip (t ≥ 벽체 배면), falling 4 % only behind the wall.
(s, t) => bracketTopEl(s) - SLAB_FALL * Math.max(0, members.shape.wallBackM - t),
),
new THREE.MeshStandardMaterial({ color: 0xd8c8a0, roughness: 0.9, side: THREE.DoubleSide }),
);
slabMesh.name = 'slab';
const slabGroup = new THREE.Group();
slabGroup.name = 'slab';
slabGroup.add(slabMesh);
root.add(slabGroup);
// 말뚝: D 600 (r 0.3) at each row and bar, from the 기초 하단 down to the 선단.
if (members.pileBottomEl !== undefined && footingBottom !== undefined) {
const height = footingBottom - members.pileBottomEl;
for (const row of members.piles) {
for (const s of row.s) {
const geom = new THREE.CylinderGeometry(0.3, 0.3, height, 16);
geom.translate(s, members.pileBottomEl + height / 2, row.t);
add('piles', geom, 0x7d8b99);
}
}
}
// 날개벽: the side profile (d along the wing, EL) extruded across the 0.45 m thickness toward the inside of the axis.
const dir = wingDirectionAxis(model);
for (const wing of wings) {
const sEnd = wing.sEnd;
const a = sEnd === 0 ? 1 : -1;
const profile: [number, number][] = [
...wing.topLine.map((p) => [p.dM, p.el] as [number, number]),
...[...wing.bottomLine].reverse().map((p) => [p.dM, p.el] as [number, number]),
];
const ax = new THREE.Vector3(dir.ws, 0, dir.wt);
// 삽도 (A-A, 평면): above the 브라켓 상면 the wing meets the 흉벽 back face (t = −0.5); below it, the wing meets the 벽체 back face (t = −0.8).
// The part in front of those faces lies inside the 벽체 or 흉벽, so it is cut off. The bracket falls along s, so the cut follows it.
// The wing is 0.45 thick along s, so its footprint spans two s values at each dM; the cut uses the end where the 브라켓 is highest.
const slopeS = (members.parapetTopEndEl - members.parapetTopStartEl) / L;
const highEnd = slopeS < 0 ? Math.min(0, a * WING_THICKNESS_M) : Math.max(0, a * WING_THICKNESS_M);
const bracketAtDm = (dM: number) => members.parapetTopStartEl + slopeS * (sEnd + dir.ws * dM + highEnd) - members.shape.bracketHeightM;
const above = clipByLine(profile, (x, y) => y - bracketAtDm(x));
const below = clipByLine(profile, (x, y) => bracketAtDm(x) - y);
const upper = prism(clipBelowX(above, members.shape.parapetBackM / dir.wt), WING_THICKNESS_M, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
const lower = prism(clipBelowX(below, members.shape.wallBackM / dir.wt), WING_THICKNESS_M, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
add('wings', upper, 0xc9a77c);
add('wings', lower, 0xc9a77c);
// 저판 상면 헌치 (수평, 2026-11-03): a right triangle in the plane EL–s on the 저판 상면, next to the 날개벽 내측면,
// extruded along the wing from the 벽체 배면 to the 뒷굽.
if (wing.footingHaunch) {
const f = wing.footingHaunch;
const leg = HAUNCH_LEG_M;
const triangle: [number, number][] = [[0, 0], [leg, 0], [0, leg]];
const origin = new THREE.Vector3(sEnd + a * WING_THICKNESS_M + dir.ws * f.dMFrom, f.el, dir.wt * f.dMFrom);
add('haunches', prism(triangle, f.dMTo - f.dMFrom, origin, new THREE.Vector3(a, 0, 0), ey, new THREE.Vector3(dir.ws, 0, dir.wt)), 0xc9a77c);
}
for (const h of wing.haunches) {
const shape: [number, number][] = h.corners.map((c) => {
const p = toAxis(model, c);
return [p.s, p.t];
});
const zBottom = h.zBottomEl;
const height = h.zTopEl - h.zBottomEl;
if (height > 0) add('haunches', prism(shape, height, new THREE.Vector3(0, zBottom, 0), ex, ez, ey), 0xc9a77c);
}
}
// 지형: when the ground model's terrain grid covers the abutment footprint, the terrain is drawn (the same frame as the
// piers); otherwise a flat pad at 지반고 covers the footing and the wings.
const footprint = [model.ip1, input3(model), ...wings.flatMap((w) => w.corners)];
const grid = terrain && pivot && covers(terrain, footprint, ABUT_GROUND_MARGIN_M) ? terrain : undefined;
if (grid && pivot) {
const mesh = new THREE.Mesh(terrainGeometry(grid, pivot), new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
const group = new THREE.Group();
group.name = 'ground';
group.add(mesh);
root.add(group);
} else if (groundEl !== undefined) {
const ts = [members.shape.footingBackM, members.shape.footingFrontM, ...wings.flatMap((w) => w.corners.map((c) => toAxis(model, c).t))];
const tMin = Math.min(...ts) - ABUT_GROUND_MARGIN_M;
const tMax = Math.max(...ts) + ABUT_GROUND_MARGIN_M;
const sMin = -ABUT_GROUND_MARGIN_M;
const sMax = L + ABUT_GROUND_MARGIN_M;
const pad = new THREE.BoxGeometry(sMax - sMin, 0.05, tMax - tMin);
pad.translate((sMin + sMax) / 2, groundEl, (tMin + tMax) / 2);
const mesh = new THREE.Mesh(pad, new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
mesh.name = 'ground';
const group = new THREE.Group();
group.name = 'ground';
group.add(mesh);
root.add(group);
}
return root;
}
/**
* Camera for looking at one abutment (2026-11-03): the target is the centre of its bounding box, and the camera stands
* back along a diagonal from the bridge side so both the wall and the piles are in view.
*/
export function cameraForBox(box: THREE.Box3): { position: THREE.Vector3; target: THREE.Vector3 } {
const target = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const span = Math.max(size.x, size.y, size.z, 1);
const dist = span * 1.6;
const position = new THREE.Vector3(target.x + dist * 0.6, target.y + dist * 0.5, target.z + dist * 0.8);
return { position, target };
}