feat(shape): 꺾임점에서 겹치는 기초를 하나의 꺾인 기초로 합친다

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-10 17:46:47 +09:00
1 parent ed84761449
commit ac646d554c
6 files changed
+142 -46

No files matched your search

+32 -5
View File
@@ -128,6 +128,11 @@ export interface FootingModel {
bottom: number; bottom: number;
length: number; length: number;
width: number; width: number;
/**
* One footing across the kink (2026-11-10): this record is one stretch's part, reaching past the mitre (the mitre plane
* trims it), and gFrom … gTo is the whole footing in unfolded g. Rebar and quantities take the pair as one footing.
*/
bent?: { gFrom: number; gTo: number; part: 0 | 1 };
} }
export interface BlindingModel { export interface BlindingModel {
@@ -443,12 +448,30 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
const fd = footingDims(input); const fd = footingDims(input);
// Footings are grouped per stretch so that a 일체기초 never crosses the kink; each group is aligned with its stretch. // Footings are grouped per stretch so that a 일체기초 never crosses the kink; each group is aligned with its stretch.
const footingSets: { s: number; k: number; length: number }[] = []; const footingSets: { s: number; k: number; length: number; bent?: FootingModel['bent'] }[] = [];
for (let k = 0; k < stretchCount; k++) { for (let k = 0; k < stretchCount; k++) {
for (const g of footingGroups(colG.filter((_, i) => colK[i] === k), fd.L)) { for (const g of footingGroups(colG.filter((_, i) => colK[i] === k), fd.L)) {
footingSets.push({ s: g.s - base[k], k, length: g.length }); footingSets.push({ s: g.s - base[k], k, length: g.length });
} }
} }
// Footings that meet or overlap across the kink become one bent footing (2026-11-10, 산외교 P11: 2.75 m overlap).
// Each part reaches past the mitre spread (W/2)·tan(θ/2) so the trimmed concrete joins without a wedge gap.
if (kinkG !== null) {
const last0 = footingSets.filter((f) => f.k === 0).sort((a, b) => b.s - a.s)[0];
const first1 = footingSets.filter((f) => f.k === 1).sort((a, b) => a.s - b.s)[0];
if (last0 && first1) {
const spreadF = (fd.W / 2) * Math.tan((Math.abs(ip4.angleDeg) * Math.PI) / 360);
const g0s = last0.s - last0.length / 2, g0e = last0.s + last0.length / 2;
const g1s = kinkG + first1.s - first1.length / 2, g1e = kinkG + first1.s + first1.length / 2;
if (g0e >= g1s - 2 * spreadF) {
const ext = spreadF + 0.01;
const p0e = Math.max(g0e, kinkG + ext), p1s = Math.min(g1s, kinkG - ext) - kinkG;
const bent = { gFrom: g0s, gTo: g1e };
Object.assign(last0, { s: (g0s + p0e) / 2, length: p0e - g0s, bent: { ...bent, part: 0 as const } });
Object.assign(first1, { s: (p1s + g1e - kinkG) / 2, length: g1e - kinkG - p1s, bent: { ...bent, part: 1 as const } });
}
}
}
const cast = castShape(input); const cast = castShape(input);
if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.'); if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.');
@@ -473,7 +496,7 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
}); });
} else { } else {
for (const f of footingSets) { for (const f of footingSets) {
footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W }); footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W, ...(f.bent ? { bent: f.bent } : {}) });
blindings.push({ blindings.push({
s: f.s, s: f.s,
k: f.k, k: f.k,
@@ -482,10 +505,14 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
width: fd.W + fd.blM, width: fd.W + fd.blM,
thickness: fd.blT, thickness: fd.blT,
}); });
if (input.foundationType === '말뚝기초') { if (input.foundationType === '말뚝기초' && f.bent?.part !== 1) {
const g = pileGeometry(f.length, fd.W); // A bent footing lays its piles once over the unfolded length; each pile goes to the stretch of its side of the kink.
const len = f.bent ? f.bent.gTo - f.bent.gFrom : f.length;
const g = pileGeometry(len, fd.W);
const counts = resolveRowCounts(input.pileRows, g); const counts = resolveRowCounts(input.pileRows, g);
piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) => ({ ...p, k: f.k }))); const centre = f.bent ? (f.bent.gFrom + f.bent.gTo) / 2 : f.s;
piles.push(...pilesFor(centre, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) =>
f.bent && kinkG !== null && p.s > kinkG ? { ...p, s: p.s - kinkG, k: 1 } : { ...p, k: f.k }));
} }
} }
} }
+40
View File
@@ -0,0 +1,40 @@
/**
* 확대기초 철근의 계산 단위 (2026-11-10). Pure.
*
* model.footings는 구간마다 한 기록이다. 꺾임점을 지나는 꺾인 기초(bent)는 두 기록(구간 0 · 1 조각)이지만 철근과 수량은
* 하나의 기초로 계산한다: 편 좌표(g)의 길이 gTo − gFrom, 가운데 g, 두 구간의 기둥 모두. 철근표와 3D가 같은 단위를 쓴다.
* 3D는 편 좌표 철근을 coping-fold.ts로 꺾임점에서 접는다 (교축 철근은 미터점에서 꺾음).
*/
import type { PierModel } from '../geometry';
export interface FootingUnit {
/** Centre along the axis (m): the stretch's local s, or the unfolded g of a bent footing. */
s: number;
k: number;
length: number;
width: number;
top: number;
bottom: number;
/** Columns on this footing, as x mm from its centre (for the B9 column-zone rule). */
columnX: number[];
/** True for one footing across the kink, in unfolded g (stretch 0 coordinates before folding). */
bent: boolean;
}
export function footingUnits(model: PierModel): FootingUnit[] {
const base1 = model.stretches[1]?.base ?? 0;
const gOf = (c: { s: number; k: number }) => (c.k === 0 ? c.s : c.s + base1);
const out: FootingUnit[] = [];
for (const f of model.footings) {
if (f.bent) {
if (f.bent.part === 1) continue;
const s = (f.bent.gFrom + f.bent.gTo) / 2, length = f.bent.gTo - f.bent.gFrom;
const columnX = model.columns.filter((c) => Math.abs(gOf(c) - s) <= length / 2).map((c) => (gOf(c) - s) * 1000);
out.push({ s, k: 0, length, width: f.width, top: f.top, bottom: f.bottom, columnX, bent: true });
} else {
const columnX = model.columns.filter((c) => c.k === f.k && Math.abs(c.s - f.s) <= f.length / 2).map((c) => (c.s - f.s) * 1000);
out.push({ s: f.s, k: f.k, length: f.length, width: f.width, top: f.top, bottom: f.bottom, columnX, bent: false });
}
}
return out;
}
+4 -4
View File
@@ -10,6 +10,7 @@ import { CAST_PENDING } from './cast-in-place';
import { castRebarLayout } from './cast-layout'; import { castRebarLayout } from './cast-layout';
import { multicolCopingInfo } from './multicol-coping'; import { multicolCopingInfo } from './multicol-coping';
import { stepBlockBars } from './step-bars'; import { stepBlockBars } from './step-bars';
import { footingUnits } from './footing-units';
import { columnRebar, columnInputFor } from './column'; import { columnRebar, columnInputFor } from './column';
import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing'; import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing';
import { DEFAULT_MATERIAL, developmentLength, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, NOMINAL_DIAMETER, splitPieces, STANDARD_BAR } from './common'; import { DEFAULT_MATERIAL, developmentLength, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, NOMINAL_DIAMETER, splitPieces, STANDARD_BAR } from './common';
@@ -394,10 +395,9 @@ export function rebarSummary(
// Per footing (2026-11-10): the columns of its own stretch only, and every bar length kept (a bent pier has footings // Per footing (2026-11-10): the columns of its own stretch only, and every bar length kept (a bent pier has footings
// of different lengths; the longest no longer stands for all). // of different lengths; the longest no longer stands for all).
const byName = new Map<string, { first: FootingBar; lengths: Map<number, number> }>(); const byName = new Map<string, { first: FootingBar; lengths: Map<number, number> }>();
for (const f of model.footings) { // One unit per real footing: a bent footing across the kink is one footing in unfolded g (footing-units.ts).
// Columns on this footing, as x mm from the footing centre (for the B9 column-zone rule). for (const f of footingUnits(model)) {
const columnX = model.columns.filter((c) => c.k === f.k && Math.abs(c.s - f.s) <= f.length / 2).map((c) => (c.s - f.s) * 1000); for (const b of footingBarList(f.length * 1000, f.width * 1000, (f.top - f.bottom) * 1000, f.columnX, model.footingRebar)) {
for (const b of footingBarList(f.length * 1000, f.width * 1000, (f.top - f.bottom) * 1000, columnX, model.footingRebar)) {
const cur = byName.get(b.name) ?? { first: b, lengths: new Map<number, number>() }; const cur = byName.get(b.name) ?? { first: b, lengths: new Map<number, number>() };
cur.lengths.set(b.length, (cur.lengths.get(b.length) ?? 0) + b.count); cur.lengths.set(b.length, (cur.lengths.get(b.length) ?? 0) + b.count);
byName.set(b.name, cur); byName.set(b.name, cur);
+18 -7
View File
@@ -9,6 +9,7 @@ import { castRebarLayout } from '../core/rebar/cast-layout';
import { multicolCopingBars } from '../core/rebar/multicol-coping'; import { multicolCopingBars } from '../core/rebar/multicol-coping';
import { copingFold, foldBar } from '../core/rebar/coping-fold'; import { copingFold, foldBar } from '../core/rebar/coping-fold';
import { stepBlockBars } from '../core/rebar/step-bars'; import { stepBlockBars } from '../core/rebar/step-bars';
import { footingUnits } from '../core/rebar/footing-units';
import { barDepths, barsAcross } from '../core/rebar/bearing-block'; import { barDepths, barsAcross } from '../core/rebar/bearing-block';
import { blockFramePoint } from '../core/geometry'; import { blockFramePoint } from '../core/geometry';
import { INTEGRATED_BLOCK } from '../core/integrated-block'; import { INTEGRATED_BLOCK } from '../core/integrated-block';
@@ -432,7 +433,17 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
} }
} }
// Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md. // Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md.
for (const f of m.footings) { // One unit per real footing (footing-units.ts); a bent footing is drawn in unfolded g and folded at the mitre.
for (const f of footingUnits(m)) {
const fold = f.bent ? copingFold(m) : null;
const fBar = (name: string, a: [number, number, number], b: [number, number, number], dMm: number, color: number) => {
if (!fold) return addBar(name, f.k, a, b, dMm, color);
for (const p of foldBar(fold, [a, b]).pieces) for (let i = 1; i < p.pts.length; i++) addBar(name, p.k, p.pts[i - 1], p.pts[i], dMm, color);
};
const fPath = (name: string, pts: [number, number, number][], dMm: number, color: number) => {
if (!fold) return addPath(name, f.k, pts, dMm, color);
for (const p of foldBar(fold, pts).pieces) addPath(name, p.k, p.pts, dMm, color);
};
const Lf = f.length; const Lf = f.length;
const Wf = f.width; const Wf = f.width;
const Hf = (f.top - f.bottom) * 1000; const Hf = (f.top - f.bottom) * 1000;
@@ -452,7 +463,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
const off = legOffsets(); const off = legOffsets();
// One name, several cylinders: each segment of the path is one bar piece. // One name, several cylinders: each segment of the path is one bar piece.
const path = (name: string, pts: [number, number, number][], dMm: number) => { const path = (name: string, pts: [number, number, number][], dMm: number) => {
addPath(name, f.k, pts, dMm, COLOR.footing); fPath(name, pts, dMm, COLOR.footing);
}; };
// B1–B4: along s, at W position n. legTo (absolute EL, m) is where both ends turn up or down (§5.1); undefined = straight. // B1–B4: along s, at W position n. legTo (absolute EL, m) is where both ends turn up or down (§5.1); undefined = straight.
const along = (name: string, n: number, y: number, legTo: number | undefined, dMm: number) => { const along = (name: string, n: number, y: number, legTo: number | undefined, dMm: number) => {
@@ -492,21 +503,21 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
// Each hook tail (6·d) turns toward the footing centre along the pier axis (Q-B19). // Each hook tail (6·d) turns toward the footing centre along the pier axis (Q-B19).
const dB9 = NOMINAL_DIAMETER.H16; const dB9 = NOMINAL_DIAMETER.H16;
const tail9 = (6 * dB9) / 1000; const tail9 = (6 * dB9) / 1000;
const colX = m.columns.filter((c) => c.k === f.k && Math.abs(c.s - f.s) <= Lf / 2).map((c) => (c.s - f.s) * 1000); const colX = f.columnX;
const yB9Bot = bottom + mm(z.B24); const yB9Bot = bottom + mm(z.B24);
const yB9Top = bottom + mm(z.B5); const yB9Top = bottom + mm(z.B5);
for (const b of b9Bars(Lf * 1000, Wf * 1000, colX, 2000, fs).bars) { for (const b of b9Bars(Lf * 1000, Wf * 1000, colX, 2000, fs).bars) {
const x = mm(b.x); const x = mm(b.x);
const n = mm(b.n); const n = mm(b.n);
const tailDir = x >= 0 ? -1 : 1; const tailDir = x >= 0 ? -1 : 1;
addBar('B9', f.k, [f.s + x, yB9Bot, n], [f.s + x, yB9Top, n], dB9, COLOR.footing); fBar('B9', [f.s + x, yB9Bot, n], [f.s + x, yB9Top, n], dB9, COLOR.footing);
addBar('B9', f.k, [f.s + x, yB9Top, n], [f.s + x + tailDir * tail9, yB9Top, n], dB9, COLOR.footing); fBar('B9', [f.s + x, yB9Top, n], [f.s + x + tailDir * tail9, yB9Top, n], dB9, COLOR.footing);
addBar('B9', f.k, [f.s + x, yB9Bot, n], [f.s + x + tailDir * tail9, yB9Bot, n], dB9, COLOR.footing); fBar('B9', [f.s + x, yB9Bot, n], [f.s + x + tailDir * tail9, yB9Bot, n], dB9, COLOR.footing);
} }
// B10-1 / B10-2 (§7) at each B10 level, B11 / B12 (§8) between them. Ends turn 90° inward (B10) or down (B11, B12). // B10-1 / B10-2 (§7) at each B10 level, B11 / B12 (§8) between them. Ends turn 90° inward (B10) or down (B11, B12).
const polyline = (name: string, pts: [number, number, number][], dMm: number) => { const polyline = (name: string, pts: [number, number, number][], dMm: number) => {
addPath(name, f.k, pts, dMm, COLOR.footing); fPath(name, pts, dMm, COLOR.footing);
}; };
const hookB10 = mm(hookLength('main90', NOMINAL_DIAMETER.S19)); // 296 const hookB10 = mm(hookLength('main90', NOMINAL_DIAMETER.S19)); // 296
const hookAsm = mm(Math.round(hookLength('main90', NOMINAL_DIAMETER.S16))); // 246 const hookAsm = mm(Math.round(hookLength('main90', NOMINAL_DIAMETER.S16))); // 246
+10 -5
View File
@@ -134,12 +134,17 @@ describe('bent coping model (stage 2)', () => {
expect(buildPier(bentInput('직접기초')).piles).toHaveLength(0); expect(buildPier(bentInput('직접기초')).piles).toHaveLength(0);
}); });
it('2주형 bent with columns on both sides of the kink: separate footings, one per stretch', () => { 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)); const m = buildPier(bentInput('말뚝기초', '2주형', 10));
expect(m.footings.map((f) => f.k)).toEqual([0, 1]); // The two 10.4 m footings overlap across the kink, so they are one bent footing, a part per stretch.
expect(m.footings.every((f) => Math.abs(f.length - 10.4) < 1e-9)).toBe(true); expect(m.footings.map((f) => [f.k, f.bent?.part])).toEqual([[0, 0], [1, 1]]);
expect(m.piles).toHaveLength(40); // Piles are laid once over the unfolded footing: both stretches get some, and none overlap (they used to: 2 × 20).
expect(m.piles.filter((p) => p.k === 1)).toHaveLength(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 scene = buildPierScene(m);
const footings = scene.parts.footings.children as THREE.Mesh[]; const footings = scene.parts.footings.children as THREE.Mesh[];
expect(footings).toHaveLength(2); expect(footings).toHaveLength(2);
+38 -25
View File
@@ -10,6 +10,7 @@ import { multicolCopingInfo } from '../src/core/rebar/multicol-coping';
import { copingFold, inWedge, isTransverse } from '../src/core/rebar/coping-fold'; import { copingFold, inWedge, isTransverse } from '../src/core/rebar/coping-fold';
import { buildRebar3D } from '../src/view/rebar3d'; import { buildRebar3D } from '../src/view/rebar3d';
import { shearB9 } from '../src/core/rebar/footing'; import { shearB9 } from '../src/core/rebar/footing';
import { footingUnits } from '../src/core/rebar/footing-units';
import { loadP1 } from './helpers'; import { loadP1 } from './helpers';
// 산외교 P11 (샘플01) with the coping bent 8.767° at IP4 (2026-11-10 검토): back ⑥ g 11.64, ⑦ g 18.49, kink g 15.14. // 산외교 P11 (샘플01) with the coping bent 8.767° at IP4 (2026-11-10 검토): back ⑥ g 11.64, ⑦ g 18.49, kink g 15.14.
@@ -210,35 +211,47 @@ describe('꺾인 코핑 3D 철근 (E, 2026-11-10)', () => {
}, 60000); }, 60000);
}); });
describe('꺾인 교각의 확대기초 철근 (2026-11-10)', () => { describe('꺾인 기초 (2026-11-10)', () => {
// 3주형 as in the project (산외교 P11): columns ① ② share one 18.05 m footing in stretch 0, ③ has a 10.4 m footing in stretch 1. // 3주형 as in the project (산외교 P11): footings ① ② (−0.10 … 17.95) and ③ (15.20 … 25.60) overlap 2.75 m across the kink.
const m = buildPier({ ...p11(), type: '3주형', columnCount: 3 }); const m = buildPier({ ...p11(), type: '3주형', columnCount: 3 });
const kink = m.stretches[1].base;
const spread = (m.footings[0].width / 2) * Math.tan((8.767 * Math.PI) / 360);
it('each footing draws its rebar in its own stretch', () => { it('the two footings become one bent footing: each part reaches past the mitre spread, one unfolded extent', () => {
const rb = buildRebar3D(m); expect(m.footings.map((f) => [f.k, f.bent?.part])).toEqual([[0, 0], [1, 1]]);
const footingNames = /^B\d/; const [p0, p1] = m.footings;
const ks = new Set(rb.records.filter((r) => footingNames.test(r.name)).map((r) => r.k)); expect(p0.bent!.gFrom).toBeCloseTo(-0.1, 9);
expect(m.footings.map((f) => f.k)).toEqual([0, 1]); expect(p0.bent!.gTo).toBeCloseTo(kink + m.columns[2].s + 5.2, 9);
expect(ks).toEqual(new Set([0, 1])); expect(p0.s + p0.length / 2).toBeGreaterThanOrEqual(kink + spread);
}, 60000); expect(p1.s - p1.length / 2).toBeLessThanOrEqual(-spread);
it('B9 leaves out only the column zones of the same stretch', () => {
const rows = rebarSummary(m).rows;
const expected = m.footings.reduce((sum, f) => {
const cols = m.columns.filter((c) => c.k === f.k && Math.abs(c.s - f.s) <= f.length / 2).map((c) => (c.s - f.s) * 1000);
return sum + shearB9(f.length * 1000, f.width * 1000, (f.top - f.bottom) * 1000, cols, 2000, m.footingRebar).count;
}, 0);
expect(rows.find((r) => r.name === 'B9')!.count).toBe(expected);
}); });
it('footings of different lengths keep each length (no longest-for-all), spliced bars count both pieces', () => { it('rebar is one footing of the unfolded length with all three columns (no double count of the overlap)', () => {
const rows = rebarSummary(m).rows.filter((r) => r.member === 'footing'); const units = footingUnits(m);
expect(m.footings.map((f) => f.length)).toEqual([18.05, 10.4]); expect(units).toHaveLength(1);
const b5 = rows.find((r) => r.name === 'B5')!; expect(units[0].length).toBeCloseTo(m.footings[0].bent!.gTo - m.footings[0].bent!.gFrom, 9);
expect(b5.pieces!.length).toBe(2); expect(units[0].columnX).toHaveLength(3);
const total = b5.pieces!.reduce((s, p) => s + p.count, 0); const rows = rebarSummary(m).rows;
// The 18.05 m footing's bars are two pieces each; the 10.4 m footing's one piece. const u = units[0];
expect(total).toBeGreaterThan(b5.count); expect(rows.find((r) => r.name === 'B9')!.count).toBe(shearB9(u.length * 1000, u.width * 1000, (u.top - u.bottom) * 1000, u.columnX, 2000, m.footingRebar).count);
});
it('3D draws the bent footing in both stretches, bars along the footing turning at the mitre', () => {
const rb = buildRebar3D(m);
const footing = rb.records.filter((r) => /^B\d/.test(r.name));
expect(new Set(footing.map((r) => r.k))).toEqual(new Set([0, 1]));
// Every footing bar point stays within the unfolded footing (no part drawn past its own stretch end).
for (const r of footing) for (const p of r.pts) {
const g = r.k === 0 ? p[0] : p[0] + kink;
expect(g).toBeGreaterThan(-0.1 - spread - 1e-6);
expect(g).toBeLessThan(m.footings[0].bent!.gTo + spread + 1e-6);
}
}, 60000);
it('footings apart across the kink stay separate (2주형 sample: 10.4 m footings 4.9 m apart)', () => {
const two = buildPier(p11());
expect(two.footings.every((f) => f.bent === undefined)).toBe(true);
expect(footingUnits(two)).toHaveLength(2);
}); });
}); });