- 벽체 블록, 흉벽 배면 단차부, 흉벽 본체를 날개벽 선 방향(s = s_axis + (ws/wt)·t)으로 밀린 프리즘으로 만듦 - 벽체 전면·배면이 저판과 같은 skew 평행사변형을 이루고, 날개벽 바깥 선과 맞닿음 - 테스트: 같은 교좌면 단계 안에서 벽체와 흉벽이 공통 체적 없이 맞닿음 (인접 단계는 경계 상자만 겹치므로 제외). 전체 641건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
153 lines
8.5 KiB
TypeScript
153 lines
8.5 KiB
TypeScript
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);
|
||
};
|
||
// Same 교좌면 step only: the skewed prisms of neighbouring steps share a face, and their bounding boxes overlap slightly.
|
||
const walls = (named('wall') as THREE.Group).children;
|
||
const parapets = (named('parapet') as THREE.Group).children;
|
||
walls.forEach((w, k) => {
|
||
expect(volume(w, parapets[2 * k])).toBeLessThan(1e-9);
|
||
expect(volume(w, parapets[2 * k + 1])).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 upper 헌치 and the 접속슬래브 stay 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;
|
||
for (const h of (named('haunches') as THREE.Group).children) {
|
||
const b = new THREE.Box3().setFromObject(h);
|
||
if (b.max.z < members.shape.parapetBackM + 1e-6) continue; // lower 헌치, behind the 벽체
|
||
expect(b.min.y).toBeGreaterThanOrEqual(Math.max(backTop(b.min.x), backTop(b.max.x)) - 1e-6);
|
||
}
|
||
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);
|
||
}
|
||
});
|
||
|
||
it('the 헌치 sits on the 날개벽 내측면 (the face between the two wings), on the centre side, and does not enter the wing', () => {
|
||
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 };
|
||
};
|
||
// Inner face of each wing: s = sEnd + a·0.45 + (ws/wt)·t (a = +1 at IP1, −1 at IP3). Haunch vertices must lie on the centre side of it.
|
||
const inner = wings.map((w) => {
|
||
const p0 = axis(w.corners[0]);
|
||
const p1 = axis(w.corners[1]);
|
||
const a = w.sEnd === 0 ? 1 : -1;
|
||
return { sEnd: w.sEnd, a, k: (p1.s - p0.s) / (p1.t - p0.t), tRoot: p0.t };
|
||
});
|
||
for (const h of (named('haunches') as THREE.Group).children) {
|
||
const pos = (h as THREE.Mesh).geometry.getAttribute('position');
|
||
for (let i = 0; i < pos.count; i++) {
|
||
const x = pos.getX(i);
|
||
const z = pos.getZ(i);
|
||
const w = x < model.length / 2 ? inner[0] : inner[1];
|
||
const sIn = w.sEnd + w.a * 0.45 + w.k * (z - w.tRoot);
|
||
expect(w.a * (x - sIn)).toBeGreaterThanOrEqual(-1e-6);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('the 저판 plan is a skew parallelogram: its back corners sit on the wing outer lines, so the 날개벽 sits flush on the 저판 edge', () => {
|
||
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 p0 = axis(wings[0].corners[0]);
|
||
const p1 = axis(wings[0].corners[1]);
|
||
const k = (p1.s - p0.s) / (p1.t - p0.t); // s per m of t along the wing
|
||
const pos = ((named('footing') as THREE.Group).children[0] as THREE.Mesh).geometry.getAttribute('position');
|
||
const back = [...Array(pos.count).keys()].map((i) => ({ x: pos.getX(i), z: pos.getZ(i) })).filter((p) => Math.abs(p.z - members.shape.footingBackM) < 1e-6);
|
||
expect(back.length).toBeGreaterThanOrEqual(2);
|
||
for (const p of back) {
|
||
const onLeft = Math.abs(p.x - k * p.z) < 1e-6;
|
||
const onRight = Math.abs(p.x - (model.length + k * p.z)) < 1e-6;
|
||
expect(onLeft || onRight).toBe(true);
|
||
}
|
||
});
|
||
});
|