feat(core): lateral coping step (Q1·Q2) and IP4 bend-point solve (spec §4.1)

- 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 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-22 09:04:12 +09:00
1 parent ad59c0c74d
commit d0fa9b94eb
4 files changed
+118 -26

No files matched your search

+47 -16
View File
@@ -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 })),
+1
View File
@@ -6,6 +6,7 @@ const EXPECTED_COLUMNS: Record<PierType, number> = { '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;
+7 -5
View File
@@ -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);
+63 -5
View File
@@ -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<typeof buildPier>, 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('미실시');
});
});