Files
Pier_v1/tests/bent-coping.test.ts
T
ypshimandClaude Haiku 5.5 ac646d554c feat(shape): 꺾임점에서 겹치는 기초를 하나의 꺾인 기초로 합친다
꺾임을 넘는 기초를 합치지 않아, 기둥 간격이 기초 길이보다 짧은 꺾인 교각(산외교 P11: 2.75 m)에서 두 기초가
겹치고(콘크리트 쐐기 틈, 철근 겹침, 수량 이중 계산) 말뚝도 겹쳤다. 꺾임 전후 기초가 겹치거나 미터 퍼짐 안에서
맞닿으면 하나의 꺾인 기초로 본다:
- 형상: 구간별 조각 둘(bent), 각 조각을 미터 퍼짐 + 10 mm 너머로 늘려 미터면에서 틈 없이 잇는다
- 말뚝: 편 길이에 한 번 배치하고 꺾임점 기준으로 구간을 나눈다
- 철근: 편 좌표의 한 기초(footing-units.ts)로 철근표와 3D가 같이 계산, 3D는 미터점에서 접는다

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 17:46:47 +09:00

169 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import * as THREE from 'three';
import { buildPier, solveIp4, type PierModel } from '../src/core/geometry';
import { buildAxis, copingSpan, fromAxis, kinkArc } from '../src/core/axis';
import { buildPierScene, mitrePlane } from '../src/view/scene';
import type { PierInput } from '../src/core/types';
import { loadP1 } from './helpers';
// Constructed bend: IP1 (0, 0), IP2 (1, 0), 60° right turn, kink (7, 0), IP3 (9, −3.464) four metres ahead on the turned line.
// Axis length 11 m; a 6 m coping centred on it has its kink at g = 4.5, and the T형 column (g = 3) stays 1.5 m clear of it.
const IP1 = { e: 0, n: 0 };
const IP3 = { e: 9, n: -3.4641016 };
const ANGLE = 60;
const L = 6;
const B = 3.1;
const FRONT_N = -0.7;
const BACK_N = 0.6;
const GS = [0.6, 1.9, 3.2];
function bentInput(foundationType: PierInput['foundationType'] = '현장타설말뚝', type: PierInput['type'] = 'T형', coping = L): PierInput {
const base = loadP1();
const axis = buildAxis(IP1, IP3, solveIp4(IP1, { e: 1, n: 0 }, IP3, ANGLE));
const sStart = copingSpan(axis, coping).sStart;
const z = base.copingTopEl + 0.15;
const bearingsAt = (row: '전열' | '후열', n: number) =>
GS.map((g, i) => {
const w = world(axis, sStart + g, n);
return { no: i + 1, row, x: w.e, y: w.n, z, azimuthDeg: 0 };
});
return {
...base,
type,
columnCount: type === '2주형' ? 2 : 1,
foundationType,
copingLengthMm: coping * 1000,
copingWidthMm: B * 1000,
ips: [
{ name: 'IP1', x: IP1.e, y: IP1.n, angleDeg: 0 },
{ name: 'IP2', x: 1, y: 0, angleDeg: 0 },
{ name: 'IP3', x: IP3.e, y: IP3.n, angleDeg: 0 },
{ name: 'IP4', x: 0, y: 0, angleDeg: ANGLE },
],
bearings: [...bearingsAt('전열', FRONT_N), ...bearingsAt('후열', BACK_N)],
};
}
function world(axis: ReturnType<typeof buildAxis>, arc: number, n: number) {
const k = kinkArc(axis) !== null && arc > kinkArc(axis)! ? 1 : 0;
return fromAxis(axis, { k, s: arc, n });
}
/** World point of a model object: stretch origin + s·u + n·nrm (inverse of the scene placement). */
function modelWorld(m: PierModel, k: number, s: number, n: number) {
const st = m.stretches[k];
return { e: st.origin.e + s * st.u.e + n * st.nrm.e, n: st.origin.n + s * st.u.n + n * st.nrm.n };
}
const toScene = (m: PierModel, p: { e: number; n: number }) => new THREE.Vector3(p.e - m.pivot.e, 0, -(p.n - m.pivot.n));
describe('bent coping model (stage 2)', () => {
const input = bentInput();
const m = buildPier(input);
const axis = buildAxis(IP1, IP3, solveIp4(IP1, { e: 1, n: 0 }, IP3, ANGLE));
it('two stretches split at the kink, inside the coping', () => {
expect(m.stretches).toHaveLength(2);
expect(m.kink).not.toBeNull();
const a = kinkArc(axis)!;
const kinkG = a - copingSpan(axis, L).sStart;
expect(m.stretches[1].base).toBeCloseTo(kinkG, 9);
expect(kinkG).toBeGreaterThan(0);
expect(kinkG).toBeLessThan(L);
});
it('coping has one part per stretch (no lateral step in this input)', () => {
expect(m.copingParts.map((p) => p.k)).toEqual([0, 1]);
});
it('every bearing and block keeps its input world coordinates through its stretch frame', () => {
for (const b of m.bearings) {
const want = input.bearings.find((x) => x.row === b.row && x.no === b.no)!;
const got = modelWorld(m, b.k, b.s, b.n);
expect(got.e).toBeCloseTo(want.x, 6);
expect(got.n).toBeCloseTo(want.y, 6);
}
for (const blk of m.blocks) {
const want = input.bearings.find((x) => x.row === blk.row && x.no === blk.no)!;
const got = modelWorld(m, blk.k, blk.s, blk.n);
expect(got.e).toBeCloseTo(want.x, 6);
expect(got.n).toBeCloseTo(want.y, 6);
}
});
it('the column sits on the centre line in stretch 0 and keeps clear of the kink', () => {
const c = m.columns[0];
expect(c.k).toBe(0);
const world = modelWorld(m, c.k, c.s, 0);
const arc = copingSpan(axis, L).sStart + L / 2;
const expected = fromAxis(axis, { k: 0, s: arc, n: 0 });
expect(world.e).toBeCloseTo(expected.e, 6);
expect(world.n).toBeCloseTo(expected.n, 6);
});
it('mitre plane trims stretch 0 before the kink and keeps stretch 1 after it', () => {
const a = kinkArc(axis)!;
const plane0 = mitrePlane(m, 0)!;
const plane1 = mitrePlane(m, 1)!;
const before = toScene(m, fromAxis(axis, { k: 0, s: a - 0.5, n: 0 }));
const after = toScene(m, fromAxis(axis, { k: 0, s: a + 0.5, n: 0 }));
expect(plane0.distanceToPoint(before)).toBeGreaterThan(0);
expect(plane0.distanceToPoint(after)).toBeLessThan(0);
expect(plane1.distanceToPoint(after)).toBeGreaterThan(0);
expect(plane1.distanceToPoint(before)).toBeLessThan(0);
});
it('scene builds one coping mesh per part, with mitre clipping on each', () => {
const scene = buildPierScene(m);
const coping = scene.parts.coping.children as THREE.Mesh[];
expect(coping).toHaveLength(2);
for (const mesh of coping) {
const mat = mesh.material as THREE.MeshStandardMaterial;
expect(mat.clippingPlanes?.length ?? 0).toBe(1);
}
});
it('footings are aligned with their stretch: a T형 footing crossing the kink stays in stretch 0 and is trimmed there', () => {
const piled = buildPier(bentInput('말뚝기초'));
expect(piled.footings).toHaveLength(1);
expect(piled.footings[0].k).toBe(0);
expect(piled.footings[0].length).toBeCloseTo(10.4, 6);
expect(piled.blindings[0].k).toBe(0);
expect(piled.piles).toHaveLength(20);
expect(piled.piles.every((p) => p.k === 0)).toBe(true);
expect(buildPier(bentInput('직접기초')).piles).toHaveLength(0);
});
it('2주형 bent with columns on both sides of the kink: the overlapping footings become one bent footing (2026-11-10)', () => {
const m = buildPier(bentInput('말뚝기초', '2주형', 10));
// The two 10.4 m footings overlap across the kink, so they are one bent footing, a part per stretch.
expect(m.footings.map((f) => [f.k, f.bent?.part])).toEqual([[0, 0], [1, 1]]);
// Piles are laid once over the unfolded footing: both stretches get some, and none overlap (they used to: 2 × 20).
expect(m.piles.some((p) => p.k === 0) && m.piles.some((p) => p.k === 1)).toBe(true);
const kink = m.stretches[1].base;
const g = m.piles.map((p) => ({ g: p.k === 0 ? p.s : p.s + kink, n: p.n }));
for (let i = 0; i < g.length; i++) for (let j = i + 1; j < g.length; j++) {
expect(Math.hypot(g[i].g - g[j].g, g[i].n - g[j].n)).toBeGreaterThan(1.0);
}
const scene = buildPierScene(m);
const footings = scene.parts.footings.children as THREE.Mesh[];
expect(footings).toHaveLength(2);
for (const mesh of footings) {
expect(((mesh.material as THREE.MeshStandardMaterial).clippingPlanes ?? []).length).toBe(1);
}
});
it('a kink outside the coping is refused', () => {
const bad = { ...input, copingLengthMm: 2000 };
expect(() => buildPier(bad)).toThrow('꺾임점이 코핑 범위 밖');
});
});
describe('bend geometry must move forward', () => {
it('IP3 behind the kink on the turned line is refused', () => {
const base = bentInput();
const ips = base.ips.map((p) => (p.name === 'IP3' ? { ...p, x: 6, y: 2 } : p));
expect(() => buildPier({ ...base, ips })).toThrow('꺾임각과 IP 배치');
});
});