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 { buildWings } from '../src/core/abut/wings'; import { resolveSlab, slabGradePct, slabSection } from '../src/core/abut/slab'; import { abutSlabGeom, abutSlabRebar, type AbutSlabGeom } from '../src/core/abut/rebar/slab'; import { abutPlanOutline } from '../src/view/abut-scene'; import { DEFAULT_MATERIAL } from '../src/core/rebar/common'; // 예제 A1 (교대접속슬래브철근.md §11), fck 27. 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 geomFor = (forms?: { left: 'general' | 'short' | 'none'; right: 'general' | 'short' | 'none' }, slabIn?: Parameters[0]): AbutSlabGeom => { const wings = buildWings(model, members, forms); const slab = resolveSlab(slabIn); const grade = slabGradePct(slab, model.wing?.slopePct ?? 0); const ch = (sEnd: number) => (wings.find((w) => Math.abs(w.sEnd - sEnd) < 1e-9)?.haunchM ?? 0) * 1000; return abutSlabGeom( slab.form, abutPlanOutline(model, members, wings, slab).slab, slabSection(slab, grade, members.shape), { startEl: members.parapetTopStartEl, endEl: members.parapetTopEndEl, L: model.length, bottomEl: members.footingBottomEl! }, [ch(0), ch(model.length)], slab.lengthMm, grade, ); }; const geom = geomFor(); const fck27 = { ...DEFAULT_MATERIAL, fck: 27 }; const A1 = abutSlabRebar(geom, undefined, fck27, 'A1'); const bar = (r: typeof A1, m: string) => r.bars.find((b) => b.mark === m); describe('교대 접속슬래브 배근 A1 (AD8, 교대접속슬래브철근.md §11)', () => { it('geometry: 폭 ≈ 11253 (② 평면 측면선 사이 11257.3 — 날개벽 두께를 교대축 방향으로 띄운 skew 차이), 길이 7000, 모따기 양측 300', () => { expect(Math.abs(geom.B - 11253)).toBeLessThan(5); expect(geom.near - geom.far).toBeCloseTo(7000, 6); expect([geom.chamfer0, geom.chamferL]).toEqual([300, 300]); }); it('A1: R1 36 · R3 71 (35@300, e ≈ 276.5), R2 22 · R4 43 (21@300, e 250)', () => { expect(['R1', 'R3', 'R2', 'R4'].map((m) => bar(A1, m)?.count)).toEqual([36, 71, 22, 43]); expect(A1.across.e).toBeCloseTo((geom.B - 200 - 10500) / 2, 6); expect(A1.along.text).toBe('100 · 250 · 21@300=6.300 · 250 · 100'); }); it('삽도 재현 (B 11670): 100 · 185 · 37@300=11.100 · 185 · 100, 양끝 첫 R1 · R3이 모따기에 걸려 짧아짐 (RB-AS02)', () => { const g = { ...geom, sideL: { ...geom.sideL, s: geom.side0.s + 11670 }, B: 11670 }; const r = abutSlabRebar(g, undefined, fck27); expect(r.across.text).toBe('100 · 185 · 37@300=11.100 · 185 · 100'); expect(r.issues.map((i) => i.code)).toContain('RB-AS02'); }); it('높이: 상면 R1 = 상면 − 80, R2 = 상면 − 97.45, 하면 R4 = 하면 + 97.45, R3 = 하면 + 80 (t_s 450 → 370 / 352.55 / 97.45 / 80)', () => { expect([A1.z.R1, A1.z.R2, A1.z.R4, A1.z.R3]).toEqual([80, expect.closeTo(97.45, 6), expect.closeTo(97.45, 6), 80]); const r1 = A1.paths.find((p) => p.mark === 'R1')!; const r3 = A1.paths.find((p) => p.mark === 'R3' && Math.abs(p.pts[0][0] - r1.pts[0][0]) < 1)!; expect(r1.pts[0][2] - r3.pts[0][2]).toBeCloseTo(370 - 80, 6); }); it('길이: R1 · R3 = 6800 (측면 skew와 경사만큼 김), R2 · R4 = B − 2c / sin θ ≈ 11052', () => { const r1 = bar(A1, 'R1')!.length; expect(r1).toBeGreaterThanOrEqual(6800); expect(r1).toBeLessThan(6800 * 1.006); // 3D 길이: 흉벽 상단이 교대축을 따라 기울어 (345 / 12.2 m) 평면 길이보다 약 4.5 김. expect(Math.abs(bar(A1, 'R2')!.length - (geom.B - 200 / geom.sin))).toBeLessThan(6); }); it('모따기: A1은 짧아지는 철근 없음', () => { expect(A1.trimmed).toBe(0); }); it('R8 54개 · 627 (= 290 + (22.2 + 22.2)/2 … 135° 갈고리 151 × 2)', () => { expect(bar(A1, 'R8')!.count).toBe(54); }); it('R7: 양측 날개벽 4 · 한쪽 없음 2 · 양측 없음 0, 길이 2·l_d + 341 = 1515 (skew로 대각이 조금 길어 +25 이내)', () => { expect(bar(A1, 'R7')!.count).toBe(4); expect(bar(A1, 'R7')!.length - 1515).toBeGreaterThanOrEqual(0); expect(bar(A1, 'R7')!.length - 1515).toBeLessThan(25); const one = abutSlabRebar(geomFor({ left: 'general', right: 'none' }), undefined, fck27); expect(bar(one, 'R7')!.count).toBe(2); const none = abutSlabRebar(geomFor({ left: 'none', right: 'none' }), undefined, fck27); expect(bar(none, 'R7')).toBeUndefined(); }); it('날개벽 형식: 한쪽 없음 B ≈ 11725 · 양측 없음 B ≈ 12197 → 개수 다시 계산', () => { expect(Math.abs(geomFor({ left: 'general', right: 'none' }).B - 11725)).toBeLessThan(5); expect(Math.abs(geomFor({ left: 'none', right: 'none' }).B - 12197)).toBeLessThan(5); }); it('기본간격 연동: s_a 200 → R1 @400, R3 @200, R8 @1600', () => { const r = abutSlabRebar(geom, { sA: 200 }, fck27); expect([bar(r, 'R1')!.spacing, bar(r, 'R3')!.spacing, bar(r, 'R8')!.spacing.split(' × ')[0]]).toEqual(['400', '200', '1.600']); }); it('노출형도 같은 규칙: 상면 철근은 상면, 하면 철근은 하면을 따라 (사용자 결정 2026-11-05)', () => { const ex = abutSlabRebar(geomFor(undefined, { form: 'exposed' }), undefined, fck27); expect(['R1', 'R3', 'R2', 'R4'].map((m) => bar(ex, m)?.count)).toEqual([36, 71, 22, 43]); const r3 = ex.paths.find((p) => p.mark === 'R3')!; expect(r3.pts.length).toBeGreaterThan(2); // 헌치 · 벽체 배면에서 꺾임 }); }); import { applySlabRebar } from '../src/core/abut/edit'; import { checkStoredAbutments } from '../src/core/project'; describe('③ 접속슬래브 배근 입력 저장', () => { it('stores only the changed values; undefined links a group back; refuses bad values', () => { const a = applySlabRebar(rec, { sMain: 200, k8: 6 }); expect(a.rebar?.slab).toEqual({ sMain: 200, k8: 6 }); expect(applySlabRebar(a, { sMain: undefined }).rebar?.slab).toEqual({ k8: 6 }); expect(() => applySlabRebar(rec, { k8: 1.5 })).toThrow('R8 배수'); expect(() => applySlabRebar(rec, { cv: 0 })).toThrow(); }); it('the project file keeps the 접속슬래브 input and refuses a non-number', () => { expect(() => checkStoredAbutments([applySlabRebar(rec, { c: 90 })])).not.toThrow(); expect(() => checkStoredAbutments([{ ...rec, rebar: { slab: { c: 'x' } } as never }])).toThrow('접속슬래브 배근'); }); }); import { abutSlabPlanSvg, abutSlabSectionSvg } from '../src/view/abut-slab-rebar-svg'; describe('③ 접속슬래브 삽도', () => { it('평면: 교대축 · 길이 배치 치수, 하면 · 상면 반쪽, 번호 풍선 R1–R4 · R7 · R8', () => { const svg = abutSlabPlanSvg(geom, A1); expect(svg).toContain(A1.across.text); expect(svg).toContain('100 · 250 · 21@300=6.300 · 250 · 100'); for (const m of ['R1', 'R2', 'R3', 'R4', 'R7', 'R8']) expect(svg).toContain(`data-pick="${m}"`); }); it('단면: 매립형 · 경사 −4 %, 선택하면 나머지 흐리게 / 숨김', () => { const svg = abutSlabSectionSvg(geom, A1); expect(svg).toContain('매립형'); expect(svg).toContain('−4.0 %'.replace('−', '-')); expect(abutSlabSectionSvg(geom, A1, { marks: new Set(['R8']), only: true })).toContain('opacity="0"'); }); });