fix(abut): 접속슬래브를 날개벽 안쪽면 사이에 두어 간섭 제거

- 슬래브 평면을 날개벽 안쪽면에서 20 mm 안쪽으로 잡음 (spec §4.8 "측면 = 날개벽 안쪽면 + 20"). 날개벽은 축 방향 프리즘(두께 0.45 m, s 방향)으로 그리므로 안쪽면 s = sEnd + a·0.45 + (ws/wt)·t
- 배면 끝은 흉벽 배면 −0.505에서 7 m (t 기준)
- 간섭 검사 테스트: 슬래브 꼭짓점이 어느 날개벽 평면 영역에도 들어가지 않음
- 테스트 635건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-03 16:11:53 +09:00
1 parent 8f75c6dc9f
commit fbc315fdba
2 files changed
+54 -17

No files matched your search

+28 -10
View File
@@ -8,7 +8,7 @@
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 { buildWings, WING_THICKNESS_M, type WingSolid } from '../core/abut/wings';
import type { TerrainGrid } from '../core/ground-model';
import { terrainGeometry } from './terrain';
@@ -19,15 +19,16 @@ const SLAB_THICKNESS_M = 0.45;
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(s0: number, s1: number, tNear: number, tFar: number, bottomEl: (s: number, t: number) => number): THREE.BufferGeometry {
const corners = [s0, s1].flatMap((s) => [tNear, tFar].flatMap((t) => [bottomEl(s, t), bottomEl(s, t) + SLAB_THICKNESS_M].map((y) => [s, y, t])));
// index = is * 4 + it * 2 + ih (ih 0 = bottom, 1 = top)
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, 2, 3, 1], [4, 5, 7, 6], // s0 and s1 ends
[0, 4, 6, 2], [1, 3, 7, 5], // bottom and top
[0, 1, 5, 4], [2, 6, 7, 3], // near and far faces (t ends)
[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[] = [];
@@ -173,12 +174,29 @@ export function buildAbutGroup(
}
// 접속슬래브 (spec §4.8): bottom on the 브라켓 top (흉벽 상단 − 0.85), 5 mm behind the 흉벽 back face, 7 m back, falling 4 % away from the wall.
// Centred on the axis over the wing-to-wing width (slabWidthMm); the 흉벽 slope along s is followed.
const slabW = (model.wing?.slabWidthMm ?? L * 1000) / 1000;
// 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((L - slabW) / 2, (L + slabW) / 2, slabNear, slabNear - SLAB_LENGTH_M, (s, t) => bracketTopEl(s) + SLAB_FALL * (t - slabNear)),
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';
+26 -7
View File
@@ -14,7 +14,8 @@ const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abu
const input = toAbutInput(rec);
const model = buildAbut(input);
const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
const group = buildAbutGroup(model, members, buildWings(model, members));
const wings = buildWings(model, members);
const group = buildAbutGroup(model, members, wings);
const named = (name: string) => group.getObjectByName(name) as THREE.Group | undefined;
@@ -23,12 +24,30 @@ describe('교대 3D: 받침블럭과 접속슬래브 (spec §4.2, §4.8)', () =>
expect(named('blocks')?.children).toHaveLength(2 * model.bearings.length);
});
it('draws the 접속슬래브 as one slab over the wing-to-wing width', () => {
const slab = named('slab');
expect(slab?.children).toHaveLength(1);
const box = new THREE.Box3().setFromObject(slab as THREE.Group);
const width = box.max.x - box.min.x;
expect(width).toBeCloseTo((model.wing?.slabWidthMm ?? 0) / 1000, 2);
it('draws the 접속슬래브 as one slab', () => {
expect(named('slab')?.children).toHaveLength(1);
});
it('the slab does not overlap either wing in plan (spec §4.8: 날개벽 안쪽면 + 20)', () => {
// Axis frame: s along IP1 → IP3, t positive to the 전면, as toAxis() in abut-scene.ts.
const axis = (p: { e: number; n: number }) => {
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
const f = { 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 * f.e + d.n * f.n };
};
const inside = (x: number, y: number, poly: { s: number; t: number }[]) => {
let c = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
if (poly[i].t > y !== poly[j].t > y && x < ((poly[j].s - poly[i].s) * (y - poly[i].t)) / (poly[j].t - poly[i].t) + poly[i].s) c = !c;
}
return c;
};
const pos = (named('slab') as THREE.Group).children[0] as THREE.Mesh;
const attr = pos.geometry.getAttribute('position');
const polys = wings.map((w) => w.corners.map(axis));
for (let i = 0; i < attr.count; i++) {
for (const poly of polys) expect(inside(attr.getX(i), attr.getZ(i), poly)).toBe(false);
}
});
it('the slab reaches 7 m behind the 흉벽 back face and is 450 thick', () => {