/** * 교대 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, 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 }; } 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; // 벽체: one segment per 교좌면 step, from the footing top to the 교좌면, t −0.8 … +1.6. for (const seg of members.wallSegments) { const profile: [number, number][] = [ [seg.sFrom, footingTop], [seg.sTo, footingTop], [seg.sTo, seg.seatEl], [seg.sFrom, seg.seatEl], ]; add('wall', prism(profile, members.shape.wallFrontM - members.shape.wallBackM, new THREE.Vector3(0, 0, members.shape.wallBackM), ex, ey, ez), 0xb7c7d3); } // 흉벽: t −0.5 … 0, from the footing top up to the parapet top line. const parapetProfile: [number, number][] = [ [0, footingTop], [L, footingTop], [L, members.parapetTopEndEl], [0, members.parapetTopStartEl], ]; add('parapet', prism(parapetProfile, -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], ], (s, t) => bracketTopEl(s) + SLAB_FALL * (t - slabNear), ), 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); const geom = prism(profile, 0.45, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0)); add('wings', geom, 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 }; }