Files
Pier_v1/src/view/abut-scene.ts
T
ypshimandClaude Opus 5.5 bff1502ecf fix(abut): 수축줄눈을 받침 사이 중간에 · 기본 개수 ceil(L/10) − 1 · 3D 선 표시
spec_abut §3 · §4.9 (QA-7 가정)와 맞지 않던 세 가지를 고친다.
- 위치: 등분점(1개면 L/2)이 아니라 가장 가까운 '받침 사이 중간'(= 교좌면 계단 경계). 이전에는 운곡교 교대
  5기 모두 줄눈이 가운데 받침 ③ 블럭을 지났다 (A1: 줄눈 6.099 · 받침 6.037). 이제 A1은 IP3에서 4.905 m
- 개수: 입력이 없으면 ceil(L/10) − 1 (길이에 따라 자동, 이전에는 1 고정). 입력 칸은 비우면 자동
- 3D: 벽체 전면 · 흉벽 전면 · 벽체 배면 · 흉벽 배면에 선으로 표시(저판은 연속이라 없음), '벽체·흉벽' 토글을 따름
- 공통 계산 core/abut/joints.ts를 ② 정면·배면 삽도, 교대 상세 요약, 입력 칸 안내, 3D가 함께 씀
- 테스트: 개수 규칙, A1 위치 = ②·③ 중간 = 계단 경계, 여러 개 · 받침 부족 시 등분, 3D 선 4면

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:23:04 +09:00

525 lines
27 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 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<AbutSlab>,
/** 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 };
}