From d0fa9b94ebe2cf404bcbb6f05f4255d0b7a192af Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 22 Oct 2026 09:04:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20lateral=20coping=20step=20(Q1?= =?UTF-8?q?=C2=B7Q2)=20and=20IP4=20bend-point=20solve=20(spec=20=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Front and back coping tops that differ by 5 mm or more in any segment are drawn as two coping parts split at the midline between the bearing rows. Tolerance 5 mm and midline position are the draft assumptions (Q1·Q2). - Scene extrudes each coping part across its own width. - solveIp4 computes IP4 as the intersection of the IP1→IP2 line with the line through IP3 turned by the bend angle (positive = counter-clockwise, sign convention to be confirmed). Bent coping geometry remains 미실시. Co-Authored-By: Claude Haiku 5.5 --- src/core/geometry.ts | 63 ++++++++++++++++++------- src/core/validate.ts | 1 + src/view/scene.ts | 12 +++-- tests/validate-and-foundation.test.ts | 68 +++++++++++++++++++++++++-- 4 files changed, 118 insertions(+), 26 deletions(-) diff --git a/src/core/geometry.ts b/src/core/geometry.ts index a3c6a81..bcc22e7 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -84,6 +84,13 @@ export interface PileModel { diameter: number; } +/** One coping slab across [nFrom, nTo]; profile is its (s, EL) outline, closed. */ +export interface CopingPart { + nFrom: number; + nTo: number; + profile: [number, number][]; +} + export interface PierModel { name: string; lengthIp: number; @@ -106,8 +113,8 @@ export interface PierModel { h1: number; h2: number; segments: Segment[]; - /** Coping elevation outline as (s, EL) points, closed. */ - profile: [number, number][]; + /** Coping split across the width (n range) by the lateral step line; one part when there is no step. */ + copingParts: CopingPart[]; levelBottom: number; endBottom: number; bearings: BearingLocal[]; @@ -129,6 +136,22 @@ export interface PierModel { }; } +/** + * Bend point IP4 (spec §4.1-2): the point where the IP1→IP2 line meets the line through IP3 whose + * direction is IP1→IP2 turned by the bend angle. Positive angle = counter-clockwise [확인 필요: 부호 규약]. + * Returns null for a zero angle, where IP4 is not defined. + */ +export function solveIp4(ip1: Vec2, ip2: Vec2, ip3: Vec2, angleDeg: number): Vec2 | null { + if (angleDeg === 0) return null; + const cross = (a: Vec2, b: Vec2) => a.e * b.n - a.n * b.e; + const len = Math.hypot(ip2.e - ip1.e, ip2.n - ip1.n); + const d1: Vec2 = { e: (ip2.e - ip1.e) / len, n: (ip2.n - ip1.n) / len }; + const rad = (angleDeg * Math.PI) / 180; + const d2: Vec2 = { e: d1.e * Math.cos(rad) - d1.n * Math.sin(rad), n: d1.e * Math.sin(rad) + d1.n * Math.cos(rad) }; + const t = cross({ e: ip3.e - ip1.e, n: ip3.n - ip1.n }, d2) / cross(d1, d2); + return { e: ip1.e + t * d1.e, n: ip1.n + t * d1.n }; +} + function toLocal(p: Vec2, origin: Vec2, u: Vec2, nrm: Vec2): { s: number; n: number } { const dx = p.e - origin.e; const dy = p.n - origin.n; @@ -150,7 +173,7 @@ export function buildPier(input: PierInput): PierModel { const ip3 = ip('IP3'); const ip4 = ip('IP4'); if (ip4.angleDeg !== 0) { - throw new Error('미실시: 꺾인 코핑(IP4 꺾임각 ≠ 0)은 아직 지원하지 않습니다.'); + throw new Error('미실시: 꺾인 코핑(IP4 꺾임각 ≠ 0)의 형상은 아직 지원하지 않습니다. IP4 계산식(solveIp4)은 구현되어 있습니다.'); } const dx = ip3.x - ip1.x; @@ -166,6 +189,7 @@ export function buildPier(input: PierInput): PierModel { const L = input.copingLengthMm / 1000; const B = input.copingWidthMm / 1000; + const planHalfB = B / 2; const ip1v: Vec2 = { e: ip1.x, n: ip1.y }; const bp1: Vec2 = { e: ip1v.e + u.e * (lengthIp / 2 - L / 2), n: ip1v.n + u.n * (lengthIp / 2 - L / 2) }; const bp2: Vec2 = { e: bp1.e + u.e * L, n: bp1.n + u.n * L }; @@ -190,13 +214,9 @@ export function buildPier(input: PierInput): PierModel { bounds.push(L); const segments: Segment[] = frontLocal.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: b.z - h1 })); - // Lateral step: front and back coping tops must match segment by segment (not supported yet). - frontLocal.forEach((f, k) => { - const backTop = backLocal[k].z - h2; - if (Math.abs(segments[k].top - backTop) > DIM.lateralTolerance) { - throw new Error('미실시: 횡방향 단차(전열·후열 상면 차 ≥ 5 mm)는 아직 지원하지 않습니다.'); - } - }); + // Lateral step (spec §4.3, Q1·Q2 추천안): front and back tops differ by 5 mm or more in some segment. + const backTops = backLocal.map((b) => b.z - h2); + const lateralStep = backTops.some((top, k) => Math.abs(segments[k].top - top) >= DIM.lateralTolerance); // Columns (spec §4.5). Multi-column positions are placed; haunch and separate-footing checks are not. const colS = columnPositions(input.type, L); @@ -220,11 +240,22 @@ export function buildPier(input: PierInput): PierModel { underside.push([zoneStart - DIM.haunch, lowerEl], [next[1] + DIM.haunch, lowerEl], [next[1], levelBottom]); }); - const profile: [number, number][] = [[0, endBottom]]; - for (const seg of segments) { - profile.push([seg.s0, seg.top], [seg.s1, seg.top]); - } - profile.push(...underside); + // Elevation outline for one row of tops, closed by the shared underside. + const outline = (tops: number[]): [number, number][] => { + const pts: [number, number][] = [[0, endBottom]]; + segments.forEach((seg, k) => pts.push([seg.s0, tops[k]], [seg.s1, tops[k]])); + pts.push(...underside); + return pts; + }; + // The lateral step line lies midway between the front and back bearing rows. + const meanN = (rows: BearingLocal[]) => rows.reduce((sum, b) => sum + b.n, 0) / rows.length; + const frontTops = segments.map((seg) => seg.top); + const copingParts: CopingPart[] = lateralStep + ? [ + { nFrom: -planHalfB, nTo: (meanN(frontLocal) + meanN(backLocal)) / 2, profile: outline(frontTops) }, + { nFrom: (meanN(frontLocal) + meanN(backLocal)) / 2, nTo: planHalfB, profile: outline(backTops) }, + ] + : [{ nFrom: -planHalfB, nTo: planHalfB, profile: outline(frontTops) }]; const footingTop = input.foundationType === '현장타설말뚝' ? input.footingBottomEl : input.footingBottomEl + DIM.footingH; const columns: ColumnModel[] = colS.map((s) => ({ @@ -294,7 +325,7 @@ export function buildPier(input: PierInput): PierModel { h1, h2, segments, - profile, + copingParts, levelBottom, endBottom, bearings: [...frontLocal, ...backLocal].map((b) => ({ no: b.no, row: b.row, s: b.s, n: b.n, z: b.z })), diff --git a/src/core/validate.ts b/src/core/validate.ts index a4d92c9..2eb8d0e 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -6,6 +6,7 @@ const EXPECTED_COLUMNS: Record = { 'T형': 1, '2주형': 2, '3 /** Rules from spec §10 that are not implemented in this stage. Shown to the user, never silently skipped. */ export const NOT_IMPLEMENTED_RULES = [ 'E04 fck 미입력 (배근 단계, 3단계)', + '꺾인 코핑 형상 (IP4 꺾임각 ≠ 0; IP4 계산식만 구현)', 'W06 속성 복사 건너뜀 (2단계)', 'W08 솔플레이트 T1−T2 검사 (2단계)', ] as const; diff --git a/src/view/scene.ts b/src/view/scene.ts index 9b041f9..c434652 100644 --- a/src/view/scene.ts +++ b/src/view/scene.ts @@ -65,11 +65,13 @@ export function buildPierScene(m: PierModel): PierScene { parts[part].add(new THREE.Mesh(geometry, material)); }; - // Coping: the elevation outline extruded across the width (no boolean operations). - const shape = new THREE.Shape(m.profile.map(([s, z]) => new THREE.Vector2(s, z))); - const coping = new THREE.ExtrudeGeometry(shape, { depth: m.copingB, bevelEnabled: false }); - coping.translate(0, 0, -m.copingB / 2); - add('coping', coping); + // Coping: each part's elevation outline extruded across its width (no boolean operations). + for (const part of m.copingParts) { + const shape = new THREE.Shape(part.profile.map(([s, z]) => new THREE.Vector2(s, z))); + const coping = new THREE.ExtrudeGeometry(shape, { depth: part.nTo - part.nFrom, bevelEnabled: false }); + coping.translate(0, 0, part.nFrom); + add('coping', coping); + } for (const c of m.columns) { const g = new THREE.CylinderGeometry(c.diameter / 2, c.diameter / 2, c.top - c.bottom, 48); diff --git a/tests/validate-and-foundation.test.ts b/tests/validate-and-foundation.test.ts index 871b9f4..17d4057 100644 --- a/tests/validate-and-foundation.test.ts +++ b/tests/validate-and-foundation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildPier } from '../src/core/geometry'; +import { buildPier, solveIp4 } from '../src/core/geometry'; import { validateInput, validatePier } from '../src/core/validate'; import type { Issue, PierInput } from '../src/core/types'; import { loadP1 } from './helpers'; @@ -59,17 +59,17 @@ describe('foundation types', () => { it('2주형: underside between columns drops 300 below Level, T형 has no such point (Q3 추천안)', () => { const t = buildPier(loadP1()); const dip = t.levelBottom - 0.3; - expect(t.profile.some(([, z]) => Math.abs(z - dip) < 1e-9)).toBe(false); + expect(t.copingParts[0].profile.some(([, z]) => Math.abs(z - dip) < 1e-9)).toBe(false); const two = buildPier({ ...loadP1(), type: '2주형', columnCount: 2 }); - expect(two.profile.some(([, z]) => Math.abs(z - dip) < 1e-9)).toBe(true); - const outlineLow = Math.min(...two.profile.map(([, z]) => z)); + expect(two.copingParts[0].profile.some(([, z]) => Math.abs(z - dip) < 1e-9)).toBe(true); + const outlineLow = Math.min(...two.copingParts[0].profile.map(([, z]) => z)); expect(outlineLow).toBeCloseTo(dip, 9); }); it('3주형 builds with two gaps of haunches', () => { const m = buildPier({ ...loadP1(), type: '3주형', columnCount: 3 }); - const dips = m.profile.filter(([, z]) => Math.abs(z - (m.levelBottom - 0.3)) < 1e-9); + const dips = m.copingParts[0].profile.filter(([, z]) => Math.abs(z - (m.levelBottom - 0.3)) < 1e-9); expect(dips).toHaveLength(4); }); @@ -130,3 +130,61 @@ describe('일체기초 (spec §4.6)', () => { expect(m.footings.every((f) => Math.abs(f.length - 10.4) < 1e-9)).toBe(true); }); }); + +describe('횡방향 단차 (spec §4.3, Q1·Q2 추천안)', () => { + it('P1 has one coping part (no step)', () => { + expect(buildPier(loadP1()).copingParts).toHaveLength(1); + }); + + it('a 20 mm step in one segment splits the coping into two parts at the bearing-row midline', () => { + const p1 = loadP1(); + const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.02 } : b)); + const m = buildPier({ ...p1, bearings }); + expect(m.copingParts).toHaveLength(2); + const [front, back] = m.copingParts; + expect(front.nTo).toBeCloseTo(back.nFrom, 9); + expect(front.nFrom).toBeCloseTo(-m.copingB / 2, 9); + expect(back.nTo).toBeCloseTo(m.copingB / 2, 9); + expect(front.nTo).toBeCloseTo((meanN(m, '전열') + meanN(m, '후열')) / 2, 9); + // Segment 2 (bearing no. 7) is the one raised by 20 mm on the back row only. + const topAt = (part: typeof front, s: number) => part.profile.find(([ps, pz]) => Math.abs(ps - s) < 1e-9 && pz > m.levelBottom)![1]; + const s2 = m.segments[2].s0; + expect(topAt(back, s2) - topAt(front, s2)).toBeCloseTo(0.02, 6); + }); + + it('a 3 mm difference is below the 5 mm tolerance: no step', () => { + const p1 = loadP1(); + const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.003 } : b)); + expect(buildPier({ ...p1, bearings }).copingParts).toHaveLength(1); + }); +}); + +function meanN(m: ReturnType, row: '전열' | '후열'): number { + const rows = m.bearings.filter((b) => b.row === row); + return rows.reduce((sum, b) => sum + b.n, 0) / rows.length; +} + +describe('꺾인 코핑 IP4 (spec §4.1)', () => { + const cross = (a: { e: number; n: number }, b: { e: number; n: number }) => a.e * b.n - a.n * b.e; + + it('IP4 lies on the IP1→IP2 line and on the line through IP3 turned by the bend angle', () => { + const ip1 = { e: 0, n: 0 }; + const ip2 = { e: 1, n: 0 }; + const ip3 = { e: 10, n: 2 }; + const ip4 = solveIp4(ip1, ip2, ip3, 10)!; + expect(ip4.n).toBeCloseTo(0, 9); + const rad = (10 * Math.PI) / 180; + const d2 = { e: Math.cos(rad), n: Math.sin(rad) }; + expect(cross({ e: ip4.e - ip3.e, n: ip4.n - ip3.n }, d2)).toBeCloseTo(0, 9); + }); + + it('zero angle has no IP4', () => { + expect(solveIp4({ e: 0, n: 0 }, { e: 1, n: 0 }, { e: 10, n: 2 }, 0)).toBeNull(); + }); + + it('bent coping is still reported as 미실시 by buildPier', () => { + const p1 = loadP1(); + const ips = p1.ips.map((p) => (p.name === 'IP4' ? { ...p, angleDeg: 5 } : p)); + expect(() => buildPier({ ...p1, ips })).toThrow('미실시'); + }); +});