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 { buildAbutGroup } from '../src/view/abut-scene'; const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]; const input = toAbutInput(rec); const model = buildAbut(input); const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm }); const wings = buildWings(model, members); const group = buildAbutGroup(model, members, wings); const named = (name: string) => group.getObjectByName(name) as THREE.Group | undefined; describe('교대 3D: 받침블럭과 접속슬래브 (spec §4.2, §4.8)', () => { it('has a block and a pad for each of the 5 bearings', () => { expect(named('blocks')?.children).toHaveLength(2 * model.bearings.length); }); it('draws the 접속슬래브 as one slab', () => { expect(named('slab')?.children).toHaveLength(1); }); it('the slab does not overlap either wing in plan (spec §4.8: 날개벽 안쪽면 + 20)', () => { // Axis frame: s along IP1 → IP3, t positive to the 전면, as toAxis() in abut-scene.ts. const axis = (p: { e: number; n: number }) => { const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n }; const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide }; return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * f.e + d.n * f.n }; }; const inside = (x: number, y: number, poly: { s: number; t: number }[]) => { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { if (poly[i].t > y !== poly[j].t > y && x < ((poly[j].s - poly[i].s) * (y - poly[i].t)) / (poly[j].t - poly[i].t) + poly[i].s) c = !c; } return c; }; const pos = (named('slab') as THREE.Group).children[0] as THREE.Mesh; const attr = pos.geometry.getAttribute('position'); const polys = wings.map((w) => w.corners.map(axis)); for (let i = 0; i < attr.count; i++) { for (const poly of polys) expect(inside(attr.getX(i), attr.getZ(i), poly)).toBe(false); } }); it('the slab reaches 7 m behind the 흉벽 back face and is 450 thick', () => { const box = new THREE.Box3().setFromObject(named('slab') as THREE.Group); expect(box.min.z).toBeCloseTo(members.shape.parapetBackM - 0.005 - 7, 2); expect(box.max.y - box.min.y).toBeGreaterThan(0.45); }); it('the 흉벽 and the 벽체 only touch (no shared volume) and the back ledge is vertical-step free', () => { const volume = (a: THREE.Object3D, b: THREE.Object3D) => { const box = new THREE.Box3().setFromObject(a).intersect(new THREE.Box3().setFromObject(b)); return Math.max(0, box.max.x - box.min.x) * Math.max(0, box.max.y - box.min.y) * Math.max(0, box.max.z - box.min.z); }; for (const w of (named('wall') as THREE.Group).children) { for (const p of (named('parapet') as THREE.Group).children) expect(volume(w, p)).toBeLessThan(1e-9); } // back face: the ledge (t −0.8 … −0.5) and the 흉벽 back (−0.5) meet at one horizontal edge, not a vertical offset const backs = (named('wall') as THREE.Group).children.map((w) => new THREE.Box3().setFromObject(w).min.z); expect(Math.min(...backs)).toBeCloseTo(members.shape.wallBackM, 6); }); it('the 날개벽 meets the 흉벽 back face above the 브라켓 and the 벽체 back face below it, and never enters them', () => { // All 벽체 and 흉벽 parts lie at t ≥ 벽체 배면 (−0.8). A wing vertex with t ≤ −0.8 therefore has no volume in common with them. const z = (g: THREE.Object3D) => { const pos = (g as THREE.Mesh).geometry.getAttribute('position'); let max = -Infinity; for (let i = 0; i < pos.count; i++) max = Math.max(max, pos.getZ(i)); return max; }; // Upper part (above the 브라켓 상면) meets the 흉벽 back face (−0.5); the lower part meets the 벽체 back face (−0.8). const backTop = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM; for (const w of (named('wings') as THREE.Group).children) { expect(z(w)).toBeLessThanOrEqual(members.shape.parapetBackM + 1e-6); const pos = (w as THREE.Mesh).geometry.getAttribute('position'); for (let i = 0; i < pos.count; i++) { if (pos.getZ(i) > members.shape.wallBackM + 1e-6) expect(pos.getY(i)).toBeGreaterThanOrEqual(backTop(pos.getX(i)) - 1e-6); } } for (const w of (named('wall') as THREE.Group).children) expect(new THREE.Box3().setFromObject(w).min.z).toBeGreaterThanOrEqual(members.shape.wallBackM - 1e-6); }); it('the 접속슬래브 stays on the 브라켓 상면 over the back strip, not in it', () => { const backTop = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM; const pos = ((named('slab') as THREE.Group).children[0] as THREE.Mesh).geometry.getAttribute('position'); for (let i = 0; i < pos.count; i++) { if (pos.getZ(i) >= members.shape.wallBackM) expect(pos.getY(i)).toBeGreaterThanOrEqual(backTop(pos.getX(i)) - 1e-6); } }); });