import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import * as XLSX from 'xlsx'; import * as THREE from 'three'; 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 { applyShapeEdit, applySlabEdit, setSlabForm } from '../src/core/abut/edit'; import { abutMemberRows } from '../src/core/abut/member-list'; import { resolveSlab, slabChangedKeys, slabGradePct, slabIssues, slabSection, slabSlopeLabel } from '../src/core/abut/slab'; import { checkStoredAbutments } from '../src/core/project'; import { abutPlanOutline, buildAbutGroup } from '../src/view/abut-scene'; import { abutSlabPlanSvg, abutSlabSectionSvg } from '../src/view/abut-slab-svg'; const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]; const build = (r = rec) => { const model = buildAbut(toAbutInput(r)); const members = buildMembers(model, { bottomEl: r.foundationBottomEl, pileLengthMm: r.pileLengthMm }, r.shape); const wings = buildWings(model, members, r.wings, r.wingDims); return { model, members, wings }; }; const SHAPE = { parapetBackM: -0.5, wallBackM: -0.8, bracketHeightM: 0.85 }; describe('접속슬래브 입력 (2026-11-04, spec_abut §4.8, 시안 ② 접속슬래브 카드)', () => { it('defaults: 매립형 · 7000 × 450 · 간격 5 / 20; the form sets the slope, an old stored slope is dropped', () => { expect(resolveSlab()).toEqual({ form: 'buried', lengthMm: 7000, thicknessMm: 450, parapetGapMm: 5, wingGapMm: 20, haunchLengthMm: 2000 }); const old = { form: 'exposed', slope: 'down4' } as unknown as Parameters[0]; expect(resolveSlab(old)).toEqual({ ...resolveSlab(), form: 'exposed' }); expect(setSlabForm({ ...rec, slab: old }, 'exposed').slab).toEqual({ form: 'exposed' }); expect(slabGradePct(resolveSlab(old), 1.91)).toBe(1.91); // 노출형 = 종단경사, whatever was stored expect(slabChangedKeys({ form: 'exposed' })).toEqual(['form']); expect(slabChangedKeys(undefined)).toEqual([]); }); it('refuses a slab that does not fit under the 흉벽 상단, and a 헌치 as long as the slab', () => { expect(slabIssues(resolveSlab({ thicknessMm: 900 }), 850).join(' ')).toContain('브라켓 높이'); expect(slabIssues(resolveSlab({ form: 'exposed', haunchLengthMm: 7000 }), 850).join(' ')).toContain('헌치 길이'); expect(() => applySlabEdit(rec, 'lengthMm', 0)).toThrow('길이'); expect(() => applyShapeEdit({ ...rec, slab: { thicknessMm: 600 } }, 'bracketHeightMm', 500)).toThrow('접속슬래브 두께'); }); it('slope text and value: 매립형 하향 4 % = −4; 노출형 종단경사 = the wing slope (A1 = −i)', () => { const { model } = build(); expect(slabGradePct(resolveSlab(), model.wing!.slopePct)).toBe(-4); expect(slabGradePct(resolveSlab({ form: 'exposed' }), 1.91)).toBe(1.91); expect(slabSlopeLabel(resolveSlab({ form: 'exposed' }), 1.91)).toBe('종단경사 +1.91 %'); }); it('매립형 section: on the 브라켓 상면, flat to the 벽체 배면, then falling 4 %; 450 thick everywhere', () => { const st = slabSection(resolveSlab(), -4, SHAPE); expect(st.map((p) => p.t)).toEqual([-0.505, -0.8, -7.505]); for (const p of st) expect(p.top - p.bottom).toBeCloseTo(0.45, 9); expect(st[0].bottom).toBeCloseTo(-0.85, 9); expect(st[1].bottom).toBeCloseTo(-0.85, 9); expect(st[2].bottom).toBeCloseTo(-0.85 - 0.04 * 6.705, 9); }); it('노출형 section (삽도): top = 흉벽 상단 at the near end, end on the 브라켓 (850 thick), 헌치 2000 down to 450', () => { const st = slabSection(resolveSlab({ form: 'exposed' }), 1.91, SHAPE); expect(st.map((p) => p.t)).toEqual([-0.505, -0.8, -2.8, -7.505]); expect(st[0].top).toBeCloseTo(0, 9); expect(st[0].bottom).toBeCloseTo(-0.85, 9); expect(st[1].bottom).toBeCloseTo(-0.85, 9); expect(st[2].top - st[2].bottom).toBeCloseTo(0.45, 9); expect(st[3].top - st[3].bottom).toBeCloseTo(0.45, 9); expect(st[3].top).toBeCloseTo(0.0191 * 7, 9); // rises with the grade }); it('a wider 날개벽과 간격 narrows the slab by twice the change', () => { const base = build().model.wing!.slabWidthMm; const wider = build({ ...rec, slab: { wingGapMm: 70 } }).model.wing!.slabWidthMm; expect(base - wider).toBeCloseTo(100, 0); }); it('plan: 모따기 at both 흉벽 corners (6 corners); none when the wing haunch is 0', () => { const { model, members, wings } = build(); expect(abutPlanOutline(model, members, wings).slab).toHaveLength(6); const flat = build({ ...rec, wingDims: { left: { haunchMm: 0 }, right: { haunchMm: 0 } } }); expect(abutPlanOutline(flat.model, flat.members, flat.wings).slab).toHaveLength(4); const long = abutPlanOutline(model, members, wings, resolveSlab({ lengthMm: 8000, parapetGapMm: 10 })).slab; expect(Math.max(...long.map(([, t]) => t))).toBeCloseTo(members.shape.parapetBackM - 0.01, 9); expect(Math.min(...long.map(([, t]) => t))).toBeCloseTo(members.shape.parapetBackM - 8.01, 9); }); it('3D 노출형: the slab top reaches the 흉벽 상단 and its bottom stays on the 브라켓 over the back strip', () => { const { model, members, wings } = build(); const group = buildAbutGroup(model, members, wings, undefined, undefined, undefined, { form: 'exposed' }); const mesh = (group.getObjectByName('slab') as THREE.Group).children[0] as THREE.Mesh; const pos = mesh.geometry.getAttribute('position'); const top = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length; let nearTop = -Infinity; for (let i = 0; i < pos.count; i++) { const s = pos.getX(i); const t = pos.getZ(i); if (t > members.shape.wallBackM + 1e-6) expect(pos.getY(i)).toBeGreaterThanOrEqual(top(s) - members.shape.bracketHeightM - 1e-4); if (Math.abs(t - (members.shape.parapetBackM - 0.005)) < 1e-6) nearTop = Math.max(nearTop, pos.getY(i) - top(s)); } expect(nearTop).toBeCloseTo(0, 4); const box = new THREE.Box3().setFromObject(mesh); expect(box.min.z).toBeCloseTo(members.shape.parapetBackM - 0.005 - 7, 4); }); it('the member row shows the form, slope and width, and marks a change', () => { const row = abutMemberRows({ ...rec, slab: { form: 'exposed' } }, '11.253 m').find((r) => r.id === 'slab')!; expect(row.summary).toBe('노출형 · 종단경사 · 폭 11.253 m'); expect(row.changed).toBe(true); }); it('the project file keeps the slab input and refuses a wrong form', () => { expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'exposed', lengthMm: 8000 } }])).not.toThrow(); expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'exposed', slope: 'down4' } as never }])).not.toThrow(); // earlier files expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'open' } }])).toThrow('접속슬래브'); expect(() => checkStoredAbutments([{ ...rec, slab: { lengthMm: 'x' } }])).toThrow('접속슬래브'); }); }); describe('접속슬래브 삽도 (평면, 단면 + 접합 상세)', () => { const { model, members, wings } = build(); const sh = members.shape; const sectionOf = (form: 'buried' | 'exposed') => { const slab = resolveSlab({ form }); return abutSlabSectionSvg({ form, slopeLabel: slabSlopeLabel(slab, model.wing!.slopePct), section: slabSection(slab, slabGradePct(slab, model.wing!.slopePct), sh), parapetTopEl: 46.867, bracketHeightM: sh.bracketHeightM, parapetBackM: sh.parapetBackM, wallBackM: sh.wallBackM, lengthMm: slab.lengthMm, thicknessMm: slab.thicknessMm, parapetGapMm: slab.parapetGapMm, haunchLengthMm: slab.haunchLengthMm, }); }; it('평면: width, length and both chamfers', () => { const svg = abutSlabPlanSvg({ outline: abutPlanOutline(model, members, wings), leftSEnd: wings.find((w) => w.side === 'left')!.sEnd, widthM: model.wing!.slabWidthMm / 1000, lengthMm: 7000, chamferMm: { left: 300, right: 300 }, }); for (const s of [`폭 ${(model.wing!.slabWidthMm / 1000).toFixed(3)} m`, '>7000<', '모따기 300 × 300', '접속슬래브']) expect(svg).toContain(s); }); it('단면 + 접합 상세 (중간 생략): 7000, 450, 흉벽 높이 850, 얹힘 295, accessories and EL; 노출형 adds 헌치 2000', () => { const buried = sectionOf('buried'); for (const s of ['7000 (중간 생략)', '>450<', '>850<', '>295<', '경사 하향 4 %', '흉벽 상단 EL.46.867', '브라켓 상면 EL.46.017', '간격 5 · 아스팔트 충진재', '스티로폼', '고무판 150 × 150 × 15', '타르페이퍼 T = 15', '다웰 D25 @400 · 강관 Φ60 L300']) expect(buried).toContain(s); expect(buried).not.toContain('헌치'); expect(buried.match(/295<']) expect(exposed).toContain(s); }); });