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 { 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) => m.stretches[1].base; const gOf = (m: ReturnType, 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, 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, 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, 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, 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): columns ① ② share one 18.05 m footing in stretch 0, ③ has a 10.4 m footing in stretch 1. const m = buildPier({ ...p11(), type: '3주형', columnCount: 3 }); it('each footing draws its rebar in its own stretch', () => { const rb = buildRebar3D(m); const footingNames = /^B\d/; const ks = new Set(rb.records.filter((r) => footingNames.test(r.name)).map((r) => r.k)); expect(m.footings.map((f) => f.k)).toEqual([0, 1]); expect(ks).toEqual(new Set([0, 1])); }, 60000); 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', () => { const rows = rebarSummary(m).rows.filter((r) => r.member === 'footing'); expect(m.footings.map((f) => f.length)).toEqual([18.05, 10.4]); const b5 = rows.find((r) => r.name === 'B5')!; expect(b5.pieces!.length).toBe(2); const total = b5.pieces!.reduce((s, p) => s + p.count, 0); // The 18.05 m footing's bars are two pieces each; the 10.4 m footing's one piece. expect(total).toBeGreaterThan(b5.count); }); }); 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 }]); }); });