Files
Pier_v1/tests/kink-steps.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

266 lines
14 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 { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { parseWorkbook } from '../src/core/parseWorkbook';
import { buildPier, DIM, defaultLevelWidthMm } from '../src/core/geometry';
import type { PierInput } from '../src/core/types';
import { rebarSummary } from '../src/core/rebar/summary';
import { multicolCopingInfo } from '../src/core/rebar/multicol-coping';
import { copingFold, inWedge, isTransverse } from '../src/core/rebar/coping-fold';
import { buildRebar3D } from '../src/view/rebar3d';
import { shearB9 } from '../src/core/rebar/footing';
import { footingUnits } from '../src/core/rebar/footing-units';
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.
const SAMPLE = fileURLToPath(new URL('../project_data/01_엑셀/샘플01_산외교_교각입력데이터.xlsx', import.meta.url));
function p11(): PierInput {
const wb = XLSX.read(readFileSync(SAMPLE), { type: 'buffer' });
wb.Sheets['IP']['F46'] = { t: 'n', v: 8.767 };
const input = parseWorkbook(wb).piers.find((p) => p.name === 'P11')!;
return { ...input, levelWidthMm: input.levelWidthMm ?? defaultLevelWidthMm(input.type, 2000) };
}
const kinkOf = (m: ReturnType<typeof buildPier>) => m.stretches[1].base;
const gOf = (m: ReturnType<typeof buildPier>, no: number) => m.bearings.find((b) => b.no === no)!.g;
describe('꺾임점 근처 계단 경계 (A, 2026-11-10)', () => {
it('a back boundary 0.07 m from the kink moves onto the kink (⑥|⑦)', () => {
const m = buildPier(p11());
const kink = kinkOf(m);
const own = (gOf(m, 6) + gOf(m, 7)) / 2;
expect(Math.abs(own - kink)).toBeLessThan(0.1);
expect(m.backSegments![2].s0).toBeCloseTo(kink, 9);
expect(m.backSegments![1].s1).toBeCloseTo(kink, 9);
});
it('boundaries far from the kink stay midway between their bearings', () => {
const m = buildPier(p11());
expect(m.backSegments![1].s0).toBeCloseTo((gOf(m, 5) + gOf(m, 6)) / 2, 9);
expect(m.backSegments![3].s0).toBeCloseTo((gOf(m, 7) + gOf(m, 8)) / 2, 9);
m.segments.slice(1).forEach((seg) => expect(Math.abs(seg.s0 - kinkOf(m))).toBeGreaterThan(DIM.blockSize / 2));
});
it('no snap when a bearing would end up closer than half a block to the kink', () => {
const input = p11();
const base = buildPier(input);
// ⑦ 0.4 m past the kink, ⑥ 0.6 m before it: midpoint 0.1 m before the kink, but ⑦ is within half a block.
const u1 = base.stretches[1].u;
const u0 = base.stretches[0].u;
const kink = kinkOf(base);
const d7 = kink + 0.4 - gOf(base, 7);
const d6 = kink - 0.6 - gOf(base, 6);
const bearings = input.bearings.map((b) =>
b.no === 7 ? { ...b, x: b.x + d7 * u1.e, y: b.y + d7 * u1.n } : b.no === 6 ? { ...b, x: b.x + d6 * u0.e, y: b.y + d6 * u0.n } : b);
const m = buildPier({ ...input, bearings });
const own = (gOf(m, 6) + gOf(m, 7)) / 2;
expect(kinkOf(m) - own).toBeCloseTo(0.1, 3);
expect(m.backSegments![2].s0).toBeCloseTo(own, 9);
});
});
describe('전·후열 나눔선 하나 (B, 2026-11-10)', () => {
const split = (m: ReturnType<typeof buildPier>, k: number) => {
const [front] = m.copingParts.filter((p) => p.k === k);
return Math.abs(front.nFrom) < Math.abs(front.nTo) ? front.nFrom : front.nTo;
};
const meanN = (m: ReturnType<typeof buildPier>, row: string, k?: number) => {
const rows = m.bearings.filter((b) => b.row === row && (k === undefined || b.k === k));
return rows.reduce((s, b) => s + b.n, 0) / rows.length;
};
it('both stretches use the same line, the mean of the two rows over the whole pier (no jog at the kink)', () => {
const m = buildPier(p11());
expect(m.copingParts).toHaveLength(4);
const nAll = (meanN(m, '전열') + meanN(m, '후열')) / 2;
expect(split(m, 0)).toBeCloseTo(nAll, 9);
expect(split(m, 1)).toBeCloseTo(nAll, 9);
// Per stretch the lines would differ by about 0.16 m.
const per = (k: number) => (meanN(m, '전열', k) + meanN(m, '후열', k)) / 2;
expect(Math.abs(per(0) - per(1))).toBeGreaterThan(0.1);
});
it('falls back to a line per stretch when a bearing would be closer than half a block to the common line', () => {
const input = p11();
const base = buildPier(input);
const nrm = base.stretches[1].nrm;
// Move back ⑦ towards the axis (+n side here is the front), to n ≈ +0.1.
const b7 = base.bearings.find((b) => b.no === 7)!;
const d = 0.1 - b7.n;
const bearings = input.bearings.map((b) => (b.no === 7 ? { ...b, x: b.x + d * nrm.e, y: b.y + d * nrm.n } : b));
const m = buildPier({ ...input, bearings });
const nAll = (meanN(m, '전열') + meanN(m, '후열')) / 2;
expect(m.bearings.some((b) => Math.abs(b.n - nAll) < DIM.blockSize / 2)).toBe(true);
for (const k of [0, 1]) expect(split(m, k)).toBeCloseTo((meanN(m, '전열', k) + meanN(m, '후열', k)) / 2, 9);
});
});
describe('꺾임점 계단면 = 미터면 (2026-11-10)', () => {
// Top of a stepped profile at s: the last top point at or before s.
const topAt = (m: ReturnType<typeof buildPier>, profile: [number, number][], s: number) =>
profile.slice(0, profile.findIndex(([ps]) => ps > s + 1e-9)).filter(([, z]) => z > m.endBottom + 1e-9).pop()![1];
it('over the whole mitre spread, stretch 0 keeps the top before the kink and stretch 1 the top after it', () => {
const m = buildPier(p11());
const kink = kinkOf(m);
const spread = (m.copingB / 2) * Math.tan((8.767 * Math.PI) / 360);
// The back part carries the ⑥|⑦ step at the kink: ⑥ top (back segment 1) before, ⑦ top (segment 2) after.
expect(m.backSegments![2].s0).toBeCloseTo(kink, 9);
const before = m.backSegments![1].top, after = m.backSegments![2].top;
expect(after - before).toBeGreaterThan(0.5);
const backPart = (k: number) => {
const parts = m.copingParts.filter((p) => p.k === k);
return parts.find((p) => p.profile.some(([, z]) => Math.abs(z - after) < 1e-9))!;
};
// Stretch 0 (local s = g): just short of the far edge of the mitre spread, still the ⑥ top.
expect(topAt(m, backPart(0).profile, kink + spread - 1e-6)).toBeCloseTo(before, 9);
// Stretch 1 (local s = g − kink): just past the near edge of the spread, already the ⑦ top.
expect(topAt(m, backPart(1).profile, -spread + 1e-6)).toBeCloseTo(after, 9);
// Away from the kink the profiles are unchanged: the ⑦ top in stretch 0 a block past the kink.
expect(topAt(m, backPart(0).profile, kink + DIM.blockSize / 2 + 0.1)).toBeCloseTo(after, 9);
});
});
describe('받침 번호가 축 반대 방향인 교각 (2026-11-10)', () => {
it('steps are built in g order: bounds rise and each step top is its own bearing seat', () => {
// P1 with the bearing numbers reversed in each row (⑤ … ① along IP1 → IP3).
const p1 = (() => {
const wb = XLSX.read(readFileSync(fileURLToPath(new URL('../도면추출/samples/운곡교P1입력.xlsx', import.meta.url))), { type: 'buffer' });
return parseWorkbook(wb).piers.find((p) => p.name === 'P1')!;
})();
const renumber = (no: number) => (no <= 5 ? 6 - no : 16 - no);
const reversed = { ...p1, bearings: p1.bearings.map((b) => ({ ...b, no: renumber(b.no) })) };
const a = buildPier(p1), b = buildPier(reversed);
for (const m of [a, b]) m.segments.forEach((s) => expect(s.s1).toBeGreaterThan(s.s0));
expect(b.segments.map((s) => s.top)).toEqual(a.segments.map((s) => s.top));
expect(b.segments.map((s) => s.s0)).toEqual(a.segments.map((s) => s.s0));
});
});
describe('꺾인 다주형 코핑의 철근표 (A, 2026-11-10)', () => {
it('uses the multi-column rows, not the one-column T형 rows', () => {
const m = buildPier(p11());
const rows = rebarSummary(m).rows.filter((r) => r.member === 'coping');
const info = multicolCopingInfo(m);
for (const name of ['C9', 'C10', 'C11', 'C1', 'C7']) {
expect(rows.find((r) => r.name === name)!.count, name).toBe(info.bars.filter((b) => b.name === name).length);
}
expect(rows.every((r) => r.note.includes('다주형'))).toBe(true);
});
});
describe('꺾인 코핑 철근 접기 (B·C, 2026-11-10)', () => {
const m = buildPier(p11());
const f = copingFold(m)!;
const bars = multicolCopingInfo(m).bars;
const unfolded = (pts: [number, number, number][]) => pts.slice(1).reduce((s, p, i) => s + Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1], p[2] - pts[i][2]), 0) * 1000;
it('a run across the kink turns at its mitre point: length = unfolded + a0(n) − a1(n), outer longer, inner shorter', () => {
const crossing = bars.filter((b) => b.name === 'C7' && b.pieces!.length === 2);
expect(crossing.length).toBeGreaterThan(0);
for (const b of crossing) {
const n = b.pts[1][2];
expect(b.length).toBeCloseTo(unfolded(b.pts) + (f.a0(n) - f.a1(n)) * 1000, 6);
}
const byN = [...crossing].sort((a, b) => a.pts[1][2] - b.pts[1][2]);
const d = (b: (typeof crossing)[number]) => b.length - unfolded(b.pts);
expect(Math.sign(d(byN[0]))).toBe(-Math.sign(d(byN.at(-1)!)));
expect(Math.abs(d(byN[0]))).toBeGreaterThan(100);
});
it('no bar across the width cuts the mitre, except the one shear set laid on it', () => {
const transverse = bars.filter((b) => isTransverse(b.pts));
const onIt = transverse.filter((b) => b.pieces!.length === 1 && b.pieces![0].pts.every((p) => Math.abs(p[0] - (f.kinkG + f.a0(p[2]))) < 0.2));
expect(new Set(onIt.map((b) => b.name)).size).toBeGreaterThan(0);
for (const b of transverse.filter((x) => !onIt.includes(x))) expect(inWedge(f, b.pts)).toBe(false);
});
it('the top-bar laps keep l_s/2 + the wedge away from the kink', () => {
const c1 = bars.filter((b) => b.name === 'C1');
const ends = c1.flatMap((b) => [Math.min(...b.pts.map((p) => p[0])), Math.max(...b.pts.map((p) => p[0]))]);
const inner = ends.filter((x) => x > 0.2 && x < m.copingL - 0.2);
expect(inner.length).toBeGreaterThan(0);
for (const x of inner) expect(Math.abs(x - f.kinkG)).toBeGreaterThan(f.wedge - 1e-9);
});
it('a straight coping is unchanged (no pieces)', () => {
const straight = buildPier({ ...p11(), ips: p11().ips.map((q) => (q.name === 'IP4' ? { ...q, angleDeg: 0 } : q)) });
expect(multicolCopingInfo(straight).bars.every((b) => b.pieces === undefined)).toBe(true);
});
});
describe('꺾인 코핑 3D 철근 (E, 2026-11-10)', () => {
const straightOf = (input: PierInput) => ({ ...input, ips: input.ips.map((q) => (q.name === 'IP4' ? { ...q, angleDeg: 0 } : q)) });
const count = (rb: ReturnType<typeof buildRebar3D>, name: string) => rb.byName.get(name)?.length ?? 0;
it('a bent multi-column coping now draws its coping bars, runs across the kink in two pieces', () => {
const bent = buildRebar3D(buildPier(p11()));
for (const name of ['C1', 'C7', 'C9', 'C10', 'C12', 'C13', 'C15', 'C16']) expect(count(bent, name), name).toBeGreaterThan(0);
const straight = buildRebar3D(buildPier(straightOf(p11())));
expect(count(bent, 'C7')).toBeGreaterThan(count(straight, 'C7'));
}, 60000);
it('a bent T형 coping draws its coping bars through the same fold', () => {
const t = { ...p11(), type: 'T형' as const, columnCount: 1 };
const bent = buildRebar3D(buildPier(t));
const straight = buildRebar3D(buildPier(straightOf(t)));
expect(count(bent, 'C1')).toBeGreaterThan(0);
expect(count(bent, 'C1')).toBeGreaterThan(count(straight, 'C1'));
}, 60000);
});
describe('꺾인 기초 (2026-11-10)', () => {
// 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 kink = m.stretches[1].base;
const spread = (m.footings[0].width / 2) * Math.tan((8.767 * Math.PI) / 360);
it('the two footings become one bent footing: each part reaches past the mitre spread, one unfolded extent', () => {
expect(m.footings.map((f) => [f.k, f.bent?.part])).toEqual([[0, 0], [1, 1]]);
const [p0, p1] = m.footings;
expect(p0.bent!.gFrom).toBeCloseTo(-0.1, 9);
expect(p0.bent!.gTo).toBeCloseTo(kink + m.columns[2].s + 5.2, 9);
expect(p0.s + p0.length / 2).toBeGreaterThanOrEqual(kink + spread);
expect(p1.s - p1.length / 2).toBeLessThanOrEqual(-spread);
});
it('rebar is one footing of the unfolded length with all three columns (no double count of the overlap)', () => {
const units = footingUnits(m);
expect(units).toHaveLength(1);
expect(units[0].length).toBeCloseTo(m.footings[0].bent!.gTo - m.footings[0].bent!.gFrom, 9);
expect(units[0].columnX).toHaveLength(3);
const rows = rebarSummary(m).rows;
const u = units[0];
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);
});
});
describe('정척 초과 확대기초 철근의 2조각 (2026-11-10)', () => {
it('P1 B6 (13646 > 12000): 31 bars of 2 pieces 7776 each, so the weight counts 62 pieces', () => {
const b6 = rebarSummary(buildPier(loadP1())).rows.find((r) => r.name === 'B6')!;
expect(b6.count).toBe(31);
expect(b6.length!).toBeCloseTo(7776, 0);
expect(b6.pieces).toEqual([{ length: b6.length, count: 62 }]);
});
});