/** * 교대 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 type { BearingSole } from '../core/sole-plate'; import { jointPositions } from '../core/abut/joints'; import * as THREE from 'three'; import type { AbutModel, AbutPoint } from '../core/abut/geometry'; import { buildMembers, type AbutMembers } from '../core/abut/members'; import { buildWings, type WingSolid } from '../core/abut/wings'; import { resolveSlab, slabGradePct, slabLevelAt, slabSection, type AbutSlab } from '../core/abut/slab'; import type { TerrainGrid } from '../core/ground-model'; import { terrainGeometry } from './terrain'; export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'blinding', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'bearings', 'solplates', 'slab', 'joints'] as const; /** 말뚝 두부 매입 (m): the drawn pile head goes this far into the 저판 (drawing only). */ const PILE_HEAD_EMBED_M = 0.1; /** Keeps the part of a convex plan polygon with tLo ≤ t ≤ tHi (two half-plane cuts). */ function clipT(poly: [number, number][], tHi: number, tLo: number): [number, number][] { const cut = (pts: [number, number][], keep: (t: number) => boolean, edge: number) => { const out: [number, number][] = []; for (let i = 0; i < pts.length; i++) { const a = pts[i]; const b = pts[(i + 1) % pts.length]; if (keep(a[1])) out.push(a); if (keep(a[1]) !== keep(b[1])) { const k = (edge - a[1]) / (b[1] - a[1]); out.push([a[0] + k * (b[0] - a[0]), edge]); } } return out; }; return cut(cut(poly, (t) => t <= tHi + 1e-9, tHi), (t) => t >= tLo - 1e-9, tLo); } /** * 접속슬래브 solid (2026-11-04): the plan polygon (s, t), cut into strips at the section stations (t, descending). Inside a * strip the top and the bottom are flat, so each strip is fanned; the sides follow the plan edges, split at the stations. * Double-sided material: the winding is not critical. */ function slabGeometry(plan: [number, number][], stationsT: number[], level: (s: number, t: number) => { top: number; bottom: number }): THREE.BufferGeometry { const positions: number[] = []; const push = (s: number, y: number, t: number) => { positions.push(s, y, t); return positions.length / 3 - 1; }; const indices: number[] = []; for (let i = 0; i + 1 < stationsT.length; i++) { const strip = clipT(plan, stationsT[i], stationsT[i + 1]); if (strip.length < 3) continue; for (const face of ['top', 'bottom'] as const) { const ids = strip.map(([s, t]) => push(s, level(s, t)[face], t)); for (let j = 1; j + 1 < ids.length; j++) indices.push(ids[0], ids[j], ids[j + 1]); } } for (let i = 0; i < plan.length; i++) { const a = plan[i]; const b = plan[(i + 1) % plan.length]; const cuts = stationsT.filter((t) => (t - a[1]) * (t - b[1]) < 0).map((t) => (t - a[1]) / (b[1] - a[1])); const ks = [0, ...cuts.sort((x, y) => x - y), 1]; for (let j = 0; j + 1 < ks.length; j++) { const p = [a[0] + ks[j] * (b[0] - a[0]), a[1] + ks[j] * (b[1] - a[1])]; const q = [a[0] + ks[j + 1] * (b[0] - a[0]), a[1] + ks[j + 1] * (b[1] - a[1])]; const lp = level(p[0], p[1]); const lq = level(q[0], q[1]); const v = [push(p[0], lp.bottom, p[1]), push(q[0], lq.bottom, q[1]), push(q[0], lq.top, q[1]), push(p[0], lp.top, p[1])]; indices.push(v[0], v[1], v[2], v[0], v[2], v[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; } /** Solplate wedge in the block frame: W along x, L along z, bottom at y0; thickness T1 at z = side·L/2, T2 at the other end. */ function solplateWedge(L: number, W: number, T1: number, T2: number, side: 1 | -1, y0: number): THREE.BufferGeometry { const g = new THREE.BoxGeometry(W, 1, L); const pos = g.getAttribute('position') as THREE.BufferAttribute; for (let i = 0; i < pos.count; i++) { const z = pos.getZ(i), top = pos.getY(i) > 0; const thick = T1 + ((T2 - T1) * (L / 2 - side * z)) / L; pos.setY(i, y0 + (top ? thick : 0)); } pos.needsUpdate = true; g.computeVertexNormals(); return g; } 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 }; } /** * Plan (s, t) outlines of the abutment: the 벽체 (skew), each wing, and the 접속슬래브 between the wings (spec §4.8). * The slab starts 흉벽과 간격 behind the 흉벽 back face, runs 길이 back, keeps 날개벽과 간격 from each wing's inner face, and * is chamfered at the 흉벽 corners by that wing's 헌치 (2026-11-04: 모따기 = 날개벽 헌치). No wing: the 교좌면 끝선, no chamfer. */ export interface AbutPlanOutline { wall: [number, number][]; wings: { side: 'left' | 'right'; sEnd: number; poly: [number, number][] }[]; slab: [number, number][]; } export function abutPlanOutline(model: AbutModel, members: AbutMembers, wings: WingSolid[], slab: AbutSlab = resolveSlab()): AbutPlanOutline { const L = model.length; const k = abutSkewK(model); const tb = members.shape.wallBackM; const tf = members.shape.wallFrontM; const near = members.shape.parapetBackM - slab.parapetGapMm / 1000; const far = near - slab.lengthMm / 1000; const sideK = (w: WingSolid) => { const p0 = toAxis(model, w.corners[0]); const p1 = toAxis(model, w.corners[1]); return (p1.s - p0.s) / (p1.t - p0.t); }; const innerS = (sEnd: number, t: number): number => { const w = wings.find((x) => x.sEnd === sEnd); if (!w) return sEnd + k * t; // no wing on this side: the slab ends at the 교좌면 끝선 (skew) const a = w.sEnd === 0 ? 1 : -1; return sEnd + a * (w.thicknessM + slab.wingGapMm / 1000) + sideK(w) * t; }; // Near corner of one side: [point on the side line, point on the near edge] (chamfer), or the corner alone. const nearCorner = (sEnd: number): [number, number][] => { const w = wings.find((x) => x.sEnd === sEnd); const corner: [number, number] = [innerS(sEnd, near), near]; if (!w || !(w.haunchM > 0)) return [corner]; const c = w.haunchM; const a = sEnd === 0 ? 1 : -1; const tSide = near - c / Math.hypot(1, sideK(w)); return [[innerS(sEnd, tSide), tSide], [corner[0] + a * c, near]]; }; const axis = (c: AbutPoint): [number, number] => { const p = toAxis(model, c); return [p.s, p.t]; }; return { wall: [[k * tb, tb], [L + k * tb, tb], [L + k * tf, tf], [k * tf, tf]], wings: wings.map((w) => ({ side: w.side, sEnd: w.sEnd, poly: w.corners.map(axis) })), slab: [...nearCorner(0), ...nearCorner(L).reverse(), [innerS(L, far), far], [innerS(0, far), far]], }; } /** Skew of the plan (s per m of t along the wing), the same one the 저판 · 벽체 · 말뚝 열 use. */ export function abutSkewK(model: AbutModel): number { const d = wingDirectionAxis(model); return d.ws / d.wt; } /** * 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, slabInput?: Partial, /** Solplate of each bearing (abutSoles, 2026-11-06): the bearing body and the solplate wedge, and the block-top drop. */ soles?: BearingSole[], /** 수축줄눈 위치 s (m, core/abut/joints.ts). Absent = the default count between the bearings. */ jointS?: number[], ): 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; // Skew (2026-11-03): the 벽체·흉벽 plan follows the wing lines, s = s_axis + k·t, with k = ws/wt of the wing direction. const skewAxis = wingDirectionAxis(model); const skewK = skewAxis.ws / skewAxis.wt; // A prism whose profile (s, EL) is given at the face t0 and which is extruded along t with the skew (λ·(k, 0, 1)). const skewPrism = (profile: [number, number][], depth: number, t0: number) => prism( profile.map(([s, y]) => [s + skewK * t0, y] as [number, number]), depth, new THREE.Vector3(0, 0, t0), ex, ey, new THREE.Vector3(skewK, 0, 1), ); 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', skewPrism(block, members.shape.wallFrontM - members.shape.wallBackM, members.shape.wallBackM), 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', skewPrism(backStrip, members.shape.parapetBackM - members.shape.wallBackM, members.shape.wallBackM), 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', skewPrism(parapet, -members.shape.parapetBackM, members.shape.parapetBackM), 0x8898aa); } // 수축줄눈 (spec_abut §4.9, 2026-11-06): lines only, on the 벽체 · 흉벽 faces (the 저판 is continuous). At each joint s: // 벽체 전면 (t = 교좌면 끝) 저판 상면 → 교좌면, 흉벽 전면 (t = 0) 교좌면 → 흉벽 상단, 배면 (t = 벽체 배면) 저판 상면 → 브라켓 상면, // 흉벽 배면 브라켓 상면 → 흉벽 상단. A joint between two steps takes the lower 교좌면. Lifted 3 mm off the faces. { const joints = jointS ?? jointPositions(L, model.bearings.map((b) => b.s)); const seatAt = (s: number) => Math.min(...members.wallSegments.filter((g) => s >= g.sFrom - 1e-6 && s <= g.sTo + 1e-6).map((g) => g.seatEl)); const pts: number[] = []; const line = (s: number, t: number, y0: number, y1: number) => { if (y1 > y0) pts.push(s + skewK * t, y0, t, s + skewK * t, y1, t); }; const lift = 0.003, sh = members.shape; for (const s of joints) { const seat = seatAt(s); if (!Number.isFinite(seat)) continue; line(s, sh.wallFrontM + lift, footingTop, seat); line(s, 0 + lift, seat, parapetTopAt(s)); line(s, sh.wallBackM - lift, footingTop, bracketAt(s)); line(s, sh.parapetBackM - lift, bracketAt(s), parapetTopAt(s)); } if (pts.length) { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3)); const lines = new THREE.LineSegments(g, new THREE.LineBasicMaterial({ color: 0x1b2733 })); lines.name = 'joints'; const group = new THREE.Group(); group.name = 'joints'; group.add(lines); root.add(group); } } if (footingBottom !== undefined) { // 저판: s 0 … L, t −1.8 … +2.8, from the 기초 하단 to the 저판 상면. // Skew (2026-11-03): the 저판 ends follow the wing outer lines (s = sEnd + (ws/wt)·t), so the plan is a parallelogram. const footProfile: [number, number][] = [ [skewK * members.shape.footingBackM, members.shape.footingBackM], [L + skewK * members.shape.footingBackM, members.shape.footingBackM], [L + skewK * members.shape.footingFrontM, members.shape.footingFrontM], [skewK * members.shape.footingFrontM, members.shape.footingFrontM], ]; add('footing', prism(footProfile, members.shape.footingThicknessM, new THREE.Vector3(0, footingBottom, 0), ex, ez, ey), 0x5a6a78); // 버림 (2026-11-04): under the 저판, wider by the 여유 on every side, the same skew. const m = members.shape.blindingMarginM; const th = members.shape.blindingThicknessM; if (th > 0) { const tb = members.shape.footingBackM - m; const tf = members.shape.footingFrontM + m; const blindProfile: [number, number][] = [ [skewK * tb - m, tb], [L + skewK * tb + m, tb], [L + skewK * tf + m, tf], [skewK * tf - m, tf], ]; add('blinding', prism(blindProfile, th, new THREE.Vector3(0, footingBottom - th, 0), ex, ez, ey), 0x9aa29a); } } // 받침블럭 (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. // The block turns by the bearing azimuth (rotDeg, from the 방위각 input) about its own centre. // 교량받침 본체 · 솔플레이트 (2026-11-06, 교각과 같은 개념): the body is the solplate plan less 100 mm each side, height h3, // on the pad; the solplate tapers along the girder (L) with T1 on the 오르막 쪽. 거더 하면 고정: the block top and the pad // drop by T − T_ref (as the 받침 ELEVATION TABLE). const soleOf = new Map((soles ?? []).map((s) => [s.no, s])); for (const b of model.bearings) { const t = model.frontSide * b.lateral; const sole = soleOf.get(b.no); const padTop = b.z - (sole?.dropMm ?? 0) / 1000; const blockTop = padTop - 0.05; const turn = (b.rotDeg * Math.PI) / 180; const placed = (g: THREE.BufferGeometry) => g.rotateY(turn).translate(b.s, 0, t); add('blocks', placed(prism([[-0.6, b.seatEl], [0.6, b.seatEl], [0.6, blockTop], [-0.6, blockTop]], 1.2, new THREE.Vector3(0, 0, -0.6), ex, ey, ez)), 0xa9b4bf); add('blocks', placed(prism([[-0.5, blockTop], [0.5, blockTop], [0.5, padTop], [-0.5, padTop]], 1.0, new THREE.Vector3(0, 0, -0.5), ex, ey, ez)), 0x6e7a86); if (!sole) continue; const L = sole.L / 1000, W = sole.W / 1000, h3 = sole.h3 / 1000; // Local block frame after rotateY(turn): +z (the girder, L) is (sin turn)·u + (cos turn)·front in (E, N). const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide }; const zDir = { e: Math.sin(turn) * model.u.e + Math.cos(turn) * front.e, n: Math.sin(turn) * model.u.n + Math.cos(turn) * front.n }; const t1Side = sole.uphill && zDir.e * sole.uphill.e + zDir.n * sole.uphill.n > 0 ? 1 : -1; const body = new THREE.BoxGeometry(W - 0.2, h3, L - 0.2); body.translate(0, padTop + h3 / 2, 0); add('bearings', placed(body), 0x3d4a57); add('solplates', placed(solplateWedge(L, W, sole.T1 / 1000, sole.T2 / 1000, t1Side, padTop + h3)), 0xc08a3e); } // 접속슬래브 (spec §4.8, 2026-11-04 매립형 / 노출형): the section (core/abut/slab.ts) is measured from the 흉벽 상단 at each s. // Plan: between the wing inner faces with the gaps and chamfers, from abutPlanOutline (shared with the ② 날개벽 plan). const slab = resolveSlab(slabInput); const slabPlan = abutPlanOutline(model, members, wings, slab).slab; const section = slabSection(slab, slabGradePct(slab, model.wing?.slopePct ?? 0), members.shape); const slabMesh = new THREE.Mesh( slabGeometry( slabPlan, section.map((p) => p.t), (s, t) => { const lv = slabLevelAt(section, t); return { top: parapetTopAt(s) + lv.top, bottom: parapetTopAt(s) + lv.bottom }; }, ), 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); // 말뚝: 원기둥 (지름 입력, 기본 D600) at each row, from the 기초 하단 down to the 선단. if (members.pileBottomEl !== undefined && footingBottom !== undefined) { // The pile head runs PILE_HEAD_EMBED_M into the 저판, so its top cap is hidden inside the slab and does not z-fight // with the 저판 bottom (the striped discs seen from below, 2026-11-04). The 선단 EL is unchanged. const height = footingBottom + PILE_HEAD_EMBED_M - members.pileBottomEl; for (const row of members.piles) { for (const s of row.s) { // 말뚝 지름은 입력값 (QA-5). Each row moves along s with the skew, like the 저판 (시안: 열마다 skew만큼 밀림). const r = members.shape.pileDiameterM / 2; const geom = new THREE.CylinderGeometry(r, r, height, 16); geom.translate(s + skewK * row.t, 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.thicknessM) : Math.max(0, a * wing.thicknessM); 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.thicknessM, 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.thicknessM, 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 = wing.haunchM; const triangle: [number, number][] = [[0, 0], [leg, 0], [0, leg]]; const origin = new THREE.Vector3(sEnd + a * wing.thicknessM + 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 }; }