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>
525 lines
27 KiB
TypeScript
525 lines
27 KiB
TypeScript
/**
|
||
* 교대 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 };
|
||
}
|