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 { abutFootingGeom, abutFootingRebar } from '../src/core/abut/rebar/footing'; import { skewAngle } from '../src/core/abut/rebar/skew-layout'; import { abutWingGeom, abutWingRebar, wingLevelCount } from '../src/core/abut/rebar/wing'; import { DEFAULT_MATERIAL } from '../src/core/rebar/common'; // 예제 A1 좌측 날개벽 (교대날개벽철근.md §11). 기획서 값은 fck 27 · 벽체 높이 2500 기준 (흉벽 상단 46.835 / 47.180). 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 }, { parapetHeightMm: 2500 }); const wings = buildWings(model, members); const footing = abutFootingRebar(abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg))); const fck27 = { ...DEFAULT_MATERIAL, fck: 27 }; const geomOf = (side: 'left' | 'right') => abutWingGeom(model, members, wings.find((w) => w.side === side)!, footing.z.B68); const left = geomOf('left'); const L1 = abutWingRebar(left, undefined, fck27, 'A1'); const bar = (r: typeof L1, mark: string) => r.bars.find((b) => b.mark === mark); describe('교대 날개벽 배근 A1 좌 (AD7, 교대날개벽철근.md §11)', () => { it('geometry: 길이 6500, 뒷굽 끝 1807, 상단 시작 46.835 (저판 상면 위 4840), 일반형', () => { expect(left.form).toBe('general'); expect(left.L).toBeCloseTo(6500, 3); expect(left.heelD).toBeCloseTo(1807, 0); expect(left.topStart - left.footTop).toBeCloseTo(4840, 0); }); it('삽도 재현 (삽도 = 우측 날개벽): 전면 100 · 193 · 18@250 · 4@250 · 707, 배면 100 · 193 · 36@125 · 9@125 · 622; 연직 WR12 5 + WR11 18, WR2 10 + WR1 36', () => { const R = abutWingRebar(geomOf('right'), undefined, fck27); expect(R.vertical.front.text).toBe('100 · 193 · 18@250=4.500 · 4@250=1.000 · 707'); expect(R.vertical.back.text).toBe('100 · 193 · 36@125=4.500 · 9@125=1.125 · 621.7'); // 삽도 622 (두께 · cot φ 39.7로 반올림) expect(['WR12', 'WR11', 'WR2', 'WR1'].map((m) => bar(R, m)?.count)).toEqual([5, 18, 10, 36]); }); it('좌측은 skew로 배면이 두께 · cot φ = 39.6 짧음: 배면 기준선 1667.4 → 8@125 · 667.4, WR2 9 (전면은 같음)', () => { expect(L1.vertical.front.text).toBe('100 · 193 · 18@250=4.500 · 4@250=1.000 · 707'); expect(L1.vertical.back.text).toBe('100 · 193 · 36@125=4.500 · 8@125=1.000 · 667.4'); expect(['WR12', 'WR11', 'WR2', 'WR1'].map((m) => bar(L1, m)?.count)).toEqual([5, 18, 9, 36]); }); it('수평 단 (맨 위 2단 경사, 사용자 결정 2026-11-04): 전면 19 (교대 쪽) / 20 (끝), 배면 38 / 39', () => { expect([L1.levels.front.start, L1.levels.front.end, L1.levels.back.start, L1.levels.back.end]).toEqual([19, 20, 38, 39]); expect(wingLevelCount(4840, 100, 250)).toBe(19); }); it('WR6 2개, WR7-1 · 7-2 · 7-3 3 / 6 / 10단, WR10 38단', () => { expect(bar(L1, 'WR6')?.count).toBe(2); expect(L1.haunchLevels).toEqual({ 'WR7-1': 3, 'WR7-2': 6, 'WR7-3': 10 }); expect(bar(L1, 'WR10')?.count).toBe(38); }); it('WR9 (사용자 도면 2026-11-04): 저판 상면 헌치 보강, 벽체 배면 ~ 뒷굽 끝 @250 → 우 4개 · 좌 3개 (skew로 갈고리가 저판 배면 쪽으로 밀려 범위가 짧음); 연직 다리 330 → 헌치 빗변과 나란한 대각 → 하면망에서 갈고리 246', () => { expect(bar(abutWingRebar(geomOf('right'), undefined, fck27), 'WR9')?.count).toBe(4); expect(bar(L1, 'WR9')?.count).toBe(3); const p = L1.paths.find((q) => q.mark === 'WR9')!; expect(p.pts.length).toBe(4); expect(p.pts[0][2] - p.pts[1][2]).toBeCloseTo(330, 6); // 연직 다리 expect(p.pts[2][2]).toBeCloseTo(p.pts[3][2], 6); // 갈고리는 수평 expect(Math.hypot(p.pts[3][0] - p.pts[2][0], p.pts[3][1] - p.pts[2][1])).toBeCloseTo(246, 6); // 대각은 45° (헌치 빗변과 나란히): 수평 이동 = 높이 차. const run = Math.hypot(p.pts[2][0] - p.pts[1][0], p.pts[2][1] - p.pts[1][1]); expect(run).toBeCloseTo(p.pts[1][2] - p.pts[2][2], 3); expect(abutWingRebar(left, undefined, fck27, '', 400).paths.find((q) => q.mark === 'WR9')!.pts[0][2] - p.pts[1][2]).toBeCloseTo(400, 6); }); it('우측: 상단은 우측 흉벽 상단 47.180 기준 (저판 상면 위 5185)', () => { const right = geomOf('right'); expect(right.topStart - right.footTop).toBeCloseTo(5185, 0); }); it('단축형: WR1 · WR11 · WR6 없음 (QA-14)', () => { const shortW = buildWings(model, members, { left: 'short', right: 'general' }).find((w) => w.side === 'left')!; const S = abutWingRebar(abutWingGeom(model, members, shortW, footing.z.B68), undefined, fck27); expect(['WR1', 'WR11', 'WR6'].map((m) => bar(S, m))).toEqual([undefined, undefined, undefined]); expect(bar(S, 'WR2')!.count).toBeGreaterThan(0); }); it('모든 철근이 날개벽 · 교대 범위 안 (t: 벽체 전면 ~ 날개벽 끝, z: 저판 하면망 위 ~ 상단)', () => { const tMin = left.p0[1] + left.L * left.w[1] - 50; for (const p of L1.paths) { for (const [, t, z] of p.pts) { expect(t, p.mark).toBeGreaterThan(tMin); expect(t, p.mark).toBeLessThan(left.wallFrontT + 1); expect(z, p.mark).toBeLessThan(left.topStart + left.slope * left.L); expect(z, p.mark).toBeGreaterThan(footing.z.B68 - (p.mark === 'WR6' ? 800 : 400)); } } }); it('간격 수정 → RB-AW03', () => { const r = abutWingRebar(left, { sHoriz: 150 }, fck27); expect(r.issues.map((i) => i.code)).toContain('RB-AW03'); expect(bar(r, 'WR3')!.modified).toBe(true); }); }); import { applyWingRebar } 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 = applyWingRebar(rec, { sHoriz: 150, delta: 30 }); expect(a.rebar?.wing).toEqual({ sHoriz: 150, delta: 30 }); expect(applyWingRebar(a, { sHoriz: undefined }).rebar?.wing).toEqual({ delta: 30 }); expect(() => applyWingRebar(rec, { k20: 0 })).toThrow('WR20 배수'); expect(() => applyWingRebar(rec, { sH: -1 })).toThrow(); }); it('the project file keeps the 날개벽 input and refuses a non-number', () => { expect(() => checkStoredAbutments([applyWingRebar(rec, { c: 80 })])).not.toThrow(); expect(() => checkStoredAbutments([{ ...rec, rebar: { wing: { c: 'x' } } as never }])).toThrow('날개벽 배근'); }); }); import { abutWingCornerSvg, abutWingFaceSvg } from '../src/view/abut-wing-rebar-svg'; describe('③ 날개벽 삽도', () => { it('전면도 · 배면도: 연직 배치 치수, 단 수, 번호 풍선; 선택하면 나머지 흐리게 / 숨김', () => { const f = abutWingFaceSvg(left, L1, 'front'); expect(f).toContain('100 · 193 · 18@250=4.500 · 4@250=1.000 · 707'); for (const m of ['WR11', 'WR12', 'WR13', 'WR14', 'WR15', 'WR6']) expect(f).toContain(`data-pick="${m}"`); const b = abutWingFaceSvg(left, L1, 'back', { marks: new Set(['WR2']), only: true }); for (const m of ['WR1', 'WR2', 'WR3', 'WR4', 'WR5', 'WR9']) expect(b).toContain(`data-pick="${m}"`); expect(b).toContain('opacity="0"'); }); it('헌치 · 모서리 평면: WR7 띠별 단 수, WR10 · WR9 풍선', () => { const svg = abutWingCornerSvg(left, L1); expect(svg).toContain('위 3 · 가운데 6 · 아래 10단'); for (const m of ['WR7', 'WR10', 'WR9']) expect(svg).toContain(`data-pick="${m}"`); }); });