fix(abut): 날개벽을 삽도대로 흉벽·벽체 배면에 맞닿게 나눔

- 날개벽을 상·하 두 부분으로 나눔. 브라켓 상면 위는 흉벽 배면(−0.5)에, 아래는 벽체 배면(−0.8)에 맞닿음 (삽도 A-A와 평면)
- 브라켓 상면이 s 방향으로 기울어 있으므로, 두께 0.45 m 안에서 가장 높은 브라켓 상면을 기준으로 자름
- 헌치는 기존 위치 유지: 위 헌치는 흉벽 배면 모서리, 아래 헌치는 벽체 배면 모서리
- 테스트 갱신: 날개벽 상부·하부 경계, 날개벽 개수 4. 전체 638건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-03 16:42:10 +09:00
1 parent 7fab9b6685
commit 3aa7cc06b6
3 files changed
+40 -6

No files matched your search

+29 -3
View File
@@ -79,6 +79,23 @@ function toAxis(model: AbutModel, p: AbutPoint): { s: number; t: number } {
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * front.e + d.n * front.n }; 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). */ /** 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][] { function clipBelowX(poly: [number, number][], x0: number): [number, number][] {
const out: [number, number][] = []; const out: [number, number][] = [];
@@ -255,9 +272,18 @@ export function buildAbutGroup(
...[...wing.bottomLine].reverse().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); const ax = new THREE.Vector3(dir.ws, 0, dir.wt);
// The wing starts behind the 벽체 back face (t = 벽체 배면 −0.8): the part in front of it lies inside the 벽체 and 흉벽, so it is cut off. // 삽도 (A-A, 평면): above the 브라켓 상면 the wing meets the 흉벽 back face (t = −0.5); below it, the wing meets the 벽체 back face (t = −0.8).
const geom = prism(clipBelowX(profile, members.shape.wallBackM / dir.wt), 0.45, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0)); // 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.
add('wings', geom, 0xc9a77c); // 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_THICKNESS_M) : Math.max(0, a * WING_THICKNESS_M);
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_THICKNESS_M, 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_THICKNESS_M, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
add('wings', upper, 0xc9a77c);
add('wings', lower, 0xc9a77c);
for (const h of wing.haunches) { for (const h of wing.haunches) {
const shape: [number, number][] = h.corners.map((c) => { const shape: [number, number][] = h.corners.map((c) => {
const p = toAxis(model, c); const p = toAxis(model, c);
+10 -2
View File
@@ -69,7 +69,7 @@ describe('교대 3D: 받침블럭과 접속슬래브 (spec §4.2, §4.8)', () =>
expect(Math.min(...backs)).toBeCloseTo(members.shape.wallBackM, 6); expect(Math.min(...backs)).toBeCloseTo(members.shape.wallBackM, 6);
}); });
it('the 날개벽 stays behind the 벽체 back face, so it does not enter the 벽체 or the 흉벽', () => { it('the 날개벽 meets the 흉벽 back face above the 브라켓 and the 벽체 back face below it, and never enters them', () => {
// All 벽체 and 흉벽 parts lie at t ≥ 벽체 배면 (−0.8). A wing vertex with t ≤ −0.8 therefore has no volume in common with them. // All 벽체 and 흉벽 parts lie at t ≥ 벽체 배면 (−0.8). A wing vertex with t ≤ −0.8 therefore has no volume in common with them.
const z = (g: THREE.Object3D) => { const z = (g: THREE.Object3D) => {
const pos = (g as THREE.Mesh).geometry.getAttribute('position'); const pos = (g as THREE.Mesh).geometry.getAttribute('position');
@@ -77,7 +77,15 @@ describe('교대 3D: 받침블럭과 접속슬래브 (spec §4.2, §4.8)', () =>
for (let i = 0; i < pos.count; i++) max = Math.max(max, pos.getZ(i)); for (let i = 0; i < pos.count; i++) max = Math.max(max, pos.getZ(i));
return max; return max;
}; };
for (const w of (named('wings') as THREE.Group).children) expect(z(w)).toBeLessThanOrEqual(members.shape.wallBackM + 1e-6); // Upper part (above the 브라켓 상면) meets the 흉벽 back face (−0.5); the lower part meets the 벽체 back face (−0.8).
const backTop = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM;
for (const w of (named('wings') as THREE.Group).children) {
expect(z(w)).toBeLessThanOrEqual(members.shape.parapetBackM + 1e-6);
const pos = (w as THREE.Mesh).geometry.getAttribute('position');
for (let i = 0; i < pos.count; i++) {
if (pos.getZ(i) > members.shape.wallBackM + 1e-6) expect(pos.getY(i)).toBeGreaterThanOrEqual(backTop(pos.getX(i)) - 1e-6);
}
}
for (const w of (named('wall') as THREE.Group).children) expect(new THREE.Box3().setFromObject(w).min.z).toBeGreaterThanOrEqual(members.shape.wallBackM - 1e-6); for (const w of (named('wall') as THREE.Group).children) expect(new THREE.Box3().setFromObject(w).min.z).toBeGreaterThanOrEqual(members.shape.wallBackM - 1e-6);
}); });
+1 -1
View File
@@ -26,7 +26,7 @@ describe('교대 A1 3D 장면 (AA3·AA3b·AA3c)', () => {
expect(count('parapet')).toBe(10); // per step: back strip (흉벽 배면 단차) and the 흉벽 (삽도) expect(count('parapet')).toBe(10); // per step: back strip (흉벽 배면 단차) and the 흉벽 (삽도)
expect(count('footing')).toBe(1); expect(count('footing')).toBe(1);
expect(count('piles')).toBe(18); expect(count('piles')).toBe(18);
expect(count('wings')).toBe(2); expect(count('wings')).toBe(4); // upper and lower part per wing
expect(count('haunches')).toBe(4); expect(count('haunches')).toBe(4);
}); });