feat(abut): 접속슬래브 카드 — 매립형 / 노출형 형식, 경사, 치수 입력, 삽도 3개, 모따기 3D 반영

- 노출형 (삽도 기준, QA-8 가정): 상면 = 흉벽 상단, 끝은 브라켓 위(끝 두께 = 브라켓 높이), 헌치 길이 기본 2000
- 형식을 고르면 경사가 기본값으로 바뀜 (매립형 하향 4 %, 노출형 종단경사), 경사 타일로 변경 가능
- 길이 · 두께 · 흉벽과 간격 · 날개벽과 간격 입력이 3D, 폭, 날개벽 평면에 반영
- 슬래브 모따기 = 날개벽 헌치 (3D와 평면), 날개벽 없는 측은 skew 교좌면 끝선
- 3D 슬래브를 단면 기준 입체로 다시 만듦, 프로젝트 파일 저장 · 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 10:11:39 +09:00
1 parent fc0370883f
commit 4b8b80ae97
14 files changed
+854 -60

No files matched your search

+88 -37
View File
@@ -9,35 +9,65 @@ 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', '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;
/** 말뚝 두부 매입 (m): the drawn pile head goes this far into the 저판 (drawing only). */
const PILE_HEAD_EMBED_M = 0.1;
/** 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
];
/** 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 (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);
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));
@@ -120,27 +150,44 @@ function wingDirectionAxis(model: AbutModel): { ws: number; wt: number } {
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). */
/**
* 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[]): AbutPlanOutline {
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 - 0.005;
const far = near - 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 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 sEnd + a * (w.thicknessM + SLAB_CLEARANCE_M) + ((p1.s - p0.s) / (p1.t - p0.t)) * t;
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);
@@ -149,7 +196,7 @@ export function abutPlanOutline(model: AbutModel, members: AbutMembers, wings: W
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: [[innerS(0, near), near], [innerS(L, near), near], [innerS(L, far), far], [innerS(0, far), far]],
slab: [...nearCorner(0), ...nearCorner(L).reverse(), [innerS(L, far), far], [innerS(0, far), far]],
};
}
@@ -180,6 +227,7 @@ export function buildAbutGroup(
pivot?: { e: number; n: number },
groundEl?: number,
terrain?: TerrainGrid,
slabInput?: Partial<AbutSlab>,
): THREE.Group {
const root = new THREE.Group();
root.name = model.name;
@@ -289,16 +337,19 @@ export function buildAbutGroup(
add('blocks', placed(prism([[-0.5, blockTop], [0.5, blockTop], [0.5, b.z], [-0.5, b.z]], 1.0, new THREE.Vector3(0, 0, -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), from abutPlanOutline (shared with the ② 날개벽 plan).
const slabNear = members.shape.parapetBackM - 0.005;
const slabPlan = abutPlanOutline(model, members, wings).slab;
const bracketTopEl = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / L - members.shape.bracketHeightM;
// 접속슬래브 (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,
// Flat on the bracket where it lies over the back strip (t ≥ 벽체 배면), falling 4 % only behind the wall.
(s, t) => bracketTopEl(s) - SLAB_FALL * Math.max(0, members.shape.wallBackM - t),
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 }),
);