import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import * as XLSX from 'xlsx'; import { describe, expect, it } from 'vitest'; import { buildAbut } from '../src/core/abut/geometry'; import { parseAbutments, toAbutInput } from '../src/core/abut/parse'; import { buildMembers } from '../src/core/abut/members'; import { abutFootingGeom, abutFootingRebar, type AbutFootingGeom } from '../src/core/abut/rebar/footing'; import { gridLayout, skewAngle, SQUARE } from '../src/core/abut/rebar/skew-layout'; import { lapLength, splicePlan } from '../src/core/abut/rebar/splice'; // 예제 A1 (교대확대기초철근.md §13): L 12197 · W 4600 · H 1100, 벽체 1000 ~ 3400, θ 95°02′52″. const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]; const model = buildAbut(toAbutInput(rec)); const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }); const geom = abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg)); const A1 = abutFootingRebar(geom, undefined, undefined, 'A1'); const bar = (r: typeof A1, mark: string) => r.bars.find((b) => b.mark === mark)!; const codes = (r: typeof A1) => r.issues.map((i) => i.code); describe('교대 저판 배근 공통 (AD1 skew · AD2 이음)', () => { it('skew: sin θ 0.99612; the grid of B1–B4 is 93@125 with e 185.8', () => { expect(skewAngle(rec.skewDeg).sin).toBeCloseTo(0.99612, 5); const g = gridLayout(geom.L, 100 / geom.angle.sin, 150, 125); expect(g.A).toBeCloseTo(11996.5, 0); expect([g.n, g.G, g.positions.length]).toEqual([93, 11625, 94]); expect(g.e).toBeCloseTo(185.8, 1); }); it('이음: l_s H16 955 / H19 1147 (fck 27, fy 400); B5·B6 13627 → 2조각 6336 / 8246', () => { expect(lapLength(15.9, 400, 27)).toBe(955); expect(lapLength(19.1, 400, 27)).toBe(1147); const p = splicePlan(13627, 15.9, 400, 27)!; expect(p.pieces).toBe(2); expect(p.staggered.map(Math.round)).toEqual([6336, 8246]); expect(p.over).toBe(false); expect(splicePlan(11997, 15.9, 400, 27)).toBeNull(); }); }); describe('교대 저판 B1–B10 (교대확대기초철근.md §13, 예제 A1 옵션 ①)', () => { it('geometry from the generated abutment: W 4600, H 1100, 벽체 1000 ~ 3400', () => { expect([geom.W, geom.H, geom.xb, geom.xf]).toEqual([4600, 1100, 1000, 3400]); expect(geom.L).toBeCloseTo(12197, 0); }); it('높이: B1·B3 1000, B5·B7 984.1, B6·B8 169.05, B2·B4 150', () => { expect(A1.z.B13).toBe(1000); expect(A1.z.B57).toBeCloseTo(984.1, 6); expect(A1.z.B68).toBeCloseTo(169.05, 6); expect(A1.z.B24).toBe(150); }); it('B1–B4: 93@125, 각 47; B1·B2 6117, B3 3212, B4 4417; 옆붙임 B1 +19.05, B5 +15.9', () => { expect(A1.main.text).toBe('100.4 · 185.8 · 93@125=11.625 · 185.8 · 100.4'); for (const m of ['B1', 'B2', 'B3', 'B4']) expect(bar(A1, m).count).toBe(47); expect([bar(A1, 'B1').length, bar(A1, 'B2').length, bar(A1, 'B3').length, bar(A1, 'B4').length]).toEqual([6117, 6117, 3212, 4417]); expect(A1.sideShift.B1).toBeCloseTo(19.05, 6); expect(A1.sideShift.B5).toBeCloseTo(15.9, 6); }); it('B5–B8: 27@150, e 175; 하면 B6 14 / B8 14, 상면 B5 6 / B7 7; B5·B6 13627 → RB-AF08, B7·B8 11997', () => { expect(A1.dist.bottomText).toBe('100 · 175 · 27@150=4.050 · 175 · 100'); expect(A1.dist.topText).toBe('100 · 175 · 5@150=750 · 2.400 · 6@150=900 · 175 · 100'); expect(['B5', 'B7', 'B6', 'B8'].map((m) => bar(A1, m).count)).toEqual([6, 7, 14, 14]); expect([bar(A1, 'B5').length, bar(A1, 'B6').length, bar(A1, 'B7').length, bar(A1, 'B8').length]).toEqual([13627, 13627, 11997, 11997]); expect(bar(A1, 'B5').splice?.staggered.map(Math.round)).toEqual([6336, 8246]); expect(codes(A1)).toContain('RB-AF08'); }); it('B10: 4단 3@200, 위 여유 250, 깊이 120.65 / 117.5, 다리 296; B10-1 8 × 12553 (2조각 5703 / 7997), B10-2 8 × 4968', () => { expect(A1.side.text).toBe('150 · 3@200=600'); expect(A1.side.levels).toEqual([150, 350, 550, 750]); expect(A1.side.topGap).toBe(250); expect(A1.side.depth1).toBeCloseTo(120.65, 6); expect(A1.side.depth2).toBeCloseTo(117.5, 6); expect(A1.side.hook).toBe(296); expect([bar(A1, 'B10-1').count, bar(A1, 'B10-1').length]).toEqual([8, 12553]); expect(bar(A1, 'B10-1').splice?.staggered.map(Math.round)).toEqual([5703, 7997]); expect([bar(A1, 'B10-2').count, bar(A1, 'B10-2').length]).toEqual([8, 4968]); }); it('B9: 6행 × 47 = 282, 1228, rows 0 2 4 / 22 24 26 of the B5–B8 grid, staggered by s_L', () => { expect([bar(A1, 'B9').count, bar(A1, 'B9').length]).toEqual([282, 1228]); expect(A1.shear.rows.map((r) => r.x)).toEqual([275, 575, 875, 3575, 3875, 4175]); expect(A1.shear.rows[1].s[0] - A1.shear.rows[0].s[0]).toBeCloseTo(125, 6); }); it('A1 has no warning: only RB-AF08 (정보) for B5 · B6 · B10-1', () => { expect(A1.issues.filter((i) => i.level !== 'I')).toEqual([]); expect(A1.issues.filter((i) => i.code === 'RB-AF08').length).toBe(3); }); }); describe('옵션 ② 교대축 직각 · 직사각 · 합성 (§13)', () => { const opt2 = abutFootingRebar(geom, { dir: 'perp' }); it('가운데 격자 92@125, e 45.5, 위치 93 (47 / 46); B1·B2 6100, B4 4400, B3 3200; RB-AF03', () => { expect(opt2.main.positions).toHaveLength(93); expect(opt2.main.positions[1] - opt2.main.positions[0]).toBeCloseTo(125, 9); expect(opt2.main.text).toMatch(/^100 · 45\.\d · 92@125=11\.500/); expect(['B1', 'B3', 'B2', 'B4'].map((m) => bar(opt2, m).count)).toEqual([47, 46, 47, 46]); expect([bar(opt2, 'B1').length, bar(opt2, 'B4').length, bar(opt2, 'B3').length]).toEqual([6100, 4400, 3200]); expect(codes(opt2)).toContain('RB-AF03'); }); it('끝 skew 철근: D 451.9, k 3, r 76.9 → 100 · 2@101 · 125, 한쪽 4개; B1-S H16 8 / B2-S H22 8, 각 4417; RB-AF05', () => { expect(opt2.skewEnd!.D).toBeCloseTo(451.9, 0); expect(opt2.skewEnd!.k).toBe(3); expect(opt2.skewEnd!.r).toBeCloseTo(76.9, 0); expect(opt2.skewEnd!.text).toBe('100 · 2@101 · 125'); expect([bar(opt2, 'B1-S').size, bar(opt2, 'B1-S').count, bar(opt2, 'B1-S').length]).toEqual(['H16', 8, 4417]); expect([bar(opt2, 'B2-S').size, bar(opt2, 'B2-S').count, bar(opt2, 'B2-S').length]).toEqual(['H22', 8, 4417]); expect(codes(opt2)).toContain('RB-AF05'); }); it('삽도 재현: L 12188 → cycle당 46@250, e 40.8, D 447.2 → 2@98.6 · 125', () => { const r = abutFootingRebar({ ...geom, L: 12188 }, { dir: 'perp' }); expect(r.main.positions).toHaveLength(93); expect(r.skewEnd!.D).toBeCloseTo(447.2, 0); expect((125 + r.skewEnd!.r) / 2).toBeCloseTo(98.6, 0); expect(r.skewEnd!.text).toBe('100 · 2@99 · 125'); }); it('r = 0: D a multiple of s gives k bars at s', () => { // A synthetic skew with W·cot = 450, and L so that e = 50 (< s/2) and D = e + 450 = 4·125 exactly. const g: AbutFootingGeom = { ...geom, angle: { sin: 0.995, cotAbs: 450 / geom.W } }; const L = 200 + 450 + 92 * 125 + 2 * 50; const r = abutFootingRebar({ ...g, L }, { dir: 'perp' }); expect(r.skewEnd!.r).toBeCloseTo(0, 6); expect(r.skewEnd!.k).toBe(4); expect(r.skewEnd!.offsets).toHaveLength(4); }); it('skew 90° 합성: 피복 100, l_0 4400; B1·B2 6100 / B3·B4 3200 · 4400, the same for both options', () => { const sq = { ...geom, angle: SQUARE }; for (const dir of ['skew', 'perp'] as const) { const r = abutFootingRebar(sq, { dir }); expect([bar(r, 'B1').length, bar(r, 'B3').length, bar(r, 'B4').length]).toEqual([6100, 3200, 4400]); expect(r.skewEnd).toBeNull(); } expect(abutFootingRebar(sq).main.text.startsWith('100 · 186.')).toBe(true); }); it('직사각 평면: 옵션 ② 고정, 끝 skew 철근 0 → RB-AF04', () => { const r = abutFootingRebar({ ...geom, plan: 'rect', angle: SQUARE }, { dir: 'skew' }); expect(r.dir).toBe('perp'); expect(r.skewEnd).toBeNull(); expect(codes(r)).toContain('RB-AF04'); }); it('뒷굽 짧음: 상면 뒷굽 철근 0 → RB-AF02', () => { // The spec (§13) says 뒷굽 300, but its own rule (§5.2: keep x ≤ x_b + s_W/2) keeps the bar at x 275 ≤ 375 there. // A 뒷굽 of 150 leaves none (275 > 225). expect(abutFootingRebar({ ...geom, xb: 300, xf: 2700 }).dist.topKept[0]).toBe(true); const r = abutFootingRebar({ ...geom, xb: 150, xf: 2550 }); expect(codes(r)).toContain('RB-AF02'); }); it('기본간격 연동: s_L 150 / s_W 200 / s_v 250 → every count and text recomputed, B9 on the new grid', () => { const r = abutFootingRebar(geom, { sL: 150, sW: 200, sV: 250 }); expect(r.main.text).toContain('@150='); expect(r.dist.bottomText).toContain('@200='); expect(r.side.text).toBe('150 · 2@250=500'); expect(bar(r, 'B9').spacing).toBe('300 × 400'); expect(r.shear.rows[1].s[0] - r.shear.rows[0].s[0]).toBeCloseTo(150, 6); expect(bar(r, 'B1').count).not.toBe(47); }); it('철근별 수정: B5–B8 only at 200 → B1–B4 and B10 unchanged, RB-AF10, modified badge; removing it links back', () => { const r = abutFootingRebar(geom, { sDist: 200 }); expect(bar(r, 'B6').count).not.toBe(14); expect(bar(r, 'B6').modified).toBe(true); expect(bar(r, 'B1').modified).toBe(false); expect(bar(r, 'B1').count).toBe(47); expect(bar(r, 'B10-1').count).toBe(8); expect(codes(r)).toContain('RB-AF10'); expect(codes(abutFootingRebar(geom, {}))).not.toContain('RB-AF10'); }); it('부재 독립: the footing result does not read any other member spacing', () => { expect(abutFootingRebar(geom, {}).bars).toEqual(A1.bars); }); }); import { applyFootingRebar } from '../src/core/abut/edit'; import { checkStoredAbutments } from '../src/core/project'; import { abutFootingRebarPlanSvg, abutFootingSectionLSvg, abutFootingSectionWSvg } from '../src/view/abut-footing-rebar-svg'; describe('③ 저판 배근 입력 저장 · 삽도', () => { it('stores only the changed values; undefined links a bar group back; refuses bad values', () => { const a = applyFootingRebar(rec, { sDist: 200, dir: 'perp' }); expect(a.rebar?.footing).toEqual({ sDist: 200, dir: 'perp' }); expect(applyFootingRebar(a, { sDist: undefined }).rebar?.footing).toEqual({ dir: 'perp' }); expect(() => applyFootingRebar(rec, { k9L: 1.5 })).toThrow('B9 배수'); expect(() => applyFootingRebar(rec, { sL: 0 })).toThrow(); }); it('the project file keeps the 배근 input and refuses a wrong direction', () => { expect(() => checkStoredAbutments([applyFootingRebar(rec, { sW: 200, dir: 'perp' })])).not.toThrow(); expect(() => checkStoredAbutments([{ ...rec, rebar: { footing: { dir: 'diag' } } as never }])).toThrow('저판 배근'); }); it('삽도: the dimension strings and a balloon per bar; a pick dims the others', () => { const w = abutFootingSectionWSvg(geom, A1); for (const s of ['100 · 175 · 27@150=4.050 · 175 · 100', '100 · 175 · 5@150=750 · 2.400 · 6@150=900 · 175 · 100', '150 · 3@200=600']) expect(w).toContain(s); for (const m of ['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B9', 'B10']) expect(w).toContain(`data-pick="${m}"`); expect(w).not.toContain('opacity="0.15"'); expect(abutFootingSectionWSvg(geom, A1, { marks: new Set(['B5']), only: false })).toContain('opacity="0.15"'); expect(abutFootingSectionWSvg(geom, A1, { marks: new Set(['B5']), only: true })).toContain('opacity="0"'); expect(abutFootingSectionLSvg(geom, A1)).toContain('100.4 · 185.8 · 93@125=11.625 · 185.8 · 100.4'); expect(abutFootingSectionLSvg(geom, A1)).toContain('B5·B6 이음 l_s 955'); const opt2 = abutFootingRebar(geom, { dir: 'perp' }); expect(abutFootingRebarPlanSvg(geom, opt2, -0.088)).toContain('data-pick="B1-S"'); expect(abutFootingRebarPlanSvg(geom, A1, -0.088)).not.toContain('data-pick="B1-S"'); }); });