From 7fab9b6685c4c31e490216658a485794523d6335 Mon Sep 17 00:00:00 2001 From: ypshim Date: Tue, 3 Nov 2026 16:35:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(abut):=20=EB=82=A0=EA=B0=9C=EB=B2=BD=C2=B7?= =?UTF-8?q?=ED=97=8C=EC=B9=98=C2=B7=EC=A0=91=EC=86=8D=EC=8A=AC=EB=9E=98?= =?UTF-8?q?=EB=B8=8C=EA=B0=80=20=EB=B2=BD=EC=B2=B4=EC=99=80=20=ED=9D=89?= =?UTF-8?q?=EB=B2=BD=20=EC=95=88=EC=9C=BC=EB=A1=9C=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EA=B0=80=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 날개벽: 벽체 배면(t −0.8) 뒤로만 세움. 벽체·흉벽 앞쪽 부분은 잘라냄 (프로파일을 dM ≥ 벽체 배면 위치로 자름) - 위 헌치: 브라켓 상면이 s 방향으로 기울므로, 헌치 면적 안에서 가장 높은 브라켓 상면 위에 놓음 - 접속슬래브: 흉벽 배면 단차 위(t ≥ 벽체 배면)는 브라켓 상면에 평평하게 얹고, 벽체 뒤쪽에서만 4 % 하향 - 테스트: 날개벽 t ≤ −0.8, 벽체 배면 위치, 위 헌치와 슬래브가 배면 단차 아래로 내려가지 않음. 전체 638건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시 Co-Authored-By: Claude Haiku 5.5 --- src/core/abut/wings.ts | 8 ++++++-- src/view/abut-scene.ts | 21 +++++++++++++++++++-- tests/abut-blocks-slab.test.ts | 25 +++++++++++++++++++++++++ tests/abut-wings.test.ts | 6 ++++-- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/core/abut/wings.ts b/src/core/abut/wings.ts index 1801ef9..37ce574 100644 --- a/src/core/abut/wings.ts +++ b/src/core/abut/wings.ts @@ -110,14 +110,18 @@ export function buildWings(model: AbutModel, members: AbutMembers, forms?: { lef const pointAt = (d: number) => ({ e: p0.e + w.e * d, n: p0.n + w.n * d }); const shift = (q: AbutPoint, v: AbutPoint, f: number) => ({ e: q.e + v.e * f, n: q.n + v.n * f }); const bracketTop = topStart - members.shape.bracketHeightM; + // The 브라켓 상면 falls along s (the 흉벽 top slopes), so the upper haunch rests on the highest point of its own footprint. + const sAt = (q: AbutPoint) => (q.e - model.ip1.e) * model.u.e + (q.n - model.ip1.n) * model.u.n; + const backTopAt = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM; const haunchAt = (kind: 'upper' | 'lower', t: number): Haunch => { const d = Math.abs(t) / k; const c = pointAt(d); const inwardUnit = { e: model.u.e * a, n: model.u.n * a }; + const corners: [AbutPoint, AbutPoint, AbutPoint] = [c, shift(c, w, HAUNCH_LEG_M), shift(c, inwardUnit, HAUNCH_LEG_M)]; return { kind, - corners: [c, shift(c, w, HAUNCH_LEG_M), shift(c, inwardUnit, HAUNCH_LEG_M)], - zBottomEl: kind === 'upper' ? bracketTop : footingTop, + corners, + zBottomEl: kind === 'upper' ? Math.max(...corners.map((q) => backTopAt(sAt(q)))) : footingTop, zTopEl: kind === 'upper' ? topAt(d) : bracketTop, }; }; diff --git a/src/view/abut-scene.ts b/src/view/abut-scene.ts index 7d92a84..91047f5 100644 --- a/src/view/abut-scene.ts +++ b/src/view/abut-scene.ts @@ -79,6 +79,21 @@ 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 }; } +/** 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) }; @@ -207,7 +222,8 @@ export function buildAbutGroup( [innerS(L, slabFar), slabFar], [innerS(0, slabFar), slabFar], ], - (s, t) => bracketTopEl(s) + SLAB_FALL * (t - slabNear), + // 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), ), new THREE.MeshStandardMaterial({ color: 0xd8c8a0, roughness: 0.9, side: THREE.DoubleSide }), ); @@ -239,7 +255,8 @@ export function buildAbutGroup( ...[...wing.bottomLine].reverse().map((p) => [p.dM, p.el] as [number, number]), ]; const ax = new THREE.Vector3(dir.ws, 0, dir.wt); - const geom = prism(profile, 0.45, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0)); + // 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. + 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)); add('wings', geom, 0xc9a77c); for (const h of wing.haunches) { const shape: [number, number][] = h.corners.map((c) => { diff --git a/tests/abut-blocks-slab.test.ts b/tests/abut-blocks-slab.test.ts index b67e8b7..06d26db 100644 --- a/tests/abut-blocks-slab.test.ts +++ b/tests/abut-blocks-slab.test.ts @@ -68,4 +68,29 @@ describe('교대 3D: 받침블럭과 접속슬래브 (spec §4.2, §4.8)', () => const backs = (named('wall') as THREE.Group).children.map((w) => new THREE.Box3().setFromObject(w).min.z); expect(Math.min(...backs)).toBeCloseTo(members.shape.wallBackM, 6); }); + + it('the 날개벽 stays behind the 벽체 back face, so it does not enter the 벽체 or the 흉벽', () => { + // 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 pos = (g as THREE.Mesh).geometry.getAttribute('position'); + let max = -Infinity; + for (let i = 0; i < pos.count; i++) max = Math.max(max, pos.getZ(i)); + return max; + }; + for (const w of (named('wings') as THREE.Group).children) expect(z(w)).toBeLessThanOrEqual(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); + }); + + it('the upper 헌치 and the 접속슬래브 stay on the 브라켓 상면 over the back strip, not in it', () => { + const backTop = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM; + for (const h of (named('haunches') as THREE.Group).children) { + const b = new THREE.Box3().setFromObject(h); + if (b.max.z < members.shape.parapetBackM + 1e-6) continue; // lower 헌치, behind the 벽체 + expect(b.min.y).toBeGreaterThanOrEqual(Math.max(backTop(b.min.x), backTop(b.max.x)) - 1e-6); + } + const pos = ((named('slab') as THREE.Group).children[0] as THREE.Mesh).geometry.getAttribute('position'); + for (let i = 0; i < pos.count; i++) { + if (pos.getZ(i) >= members.shape.wallBackM) expect(pos.getY(i)).toBeGreaterThanOrEqual(backTop(pos.getX(i)) - 1e-6); + } + }); }); diff --git a/tests/abut-wings.test.ts b/tests/abut-wings.test.ts index ad4f864..cabd45c 100644 --- a/tests/abut-wings.test.ts +++ b/tests/abut-wings.test.ts @@ -126,9 +126,11 @@ describe('교대 날개벽 하단 선 · 헌치 (AA3c, spec_abut §4.7)', () => it('upper haunch runs from the bracket top (흉벽 상단 − 0.85) to the wing top; lower from the footing top to the bracket top', () => { const [up, low] = a2Left.haunches; - expect(up.zBottomEl).toBeCloseTo(46.838 - 0.85, 2); + // The upper haunch rests on the highest point of the 브라켓 top over its footprint (the top falls along s), never below it. + expect(up.zBottomEl).toBeGreaterThanOrEqual(46.838 - 0.85 - 1e-6); + expect(up.zBottomEl).toBeLessThan(46.838 - 0.85 + 0.05); expect(low.zBottomEl).toBeCloseTo(41.995, 3); - expect(low.zTopEl).toBeCloseTo(up.zBottomEl, 6); + expect(low.zTopEl).toBeCloseTo(46.838 - 0.85, 2); expect(up.zTopEl).toBeGreaterThan(up.zBottomEl); }); });