Files
Pier_v1/tests/abut-scene.test.ts
T
ypshimandClaude Haiku 5.5 b425a6010b fix(abut): 벽체와 흉벽을 삽도대로 분리
- 벽체: 배면 −0.8 … 전면 1.6의 2400 블록을 기초 상면부터 교좌면 단차까지 (삽도의 회색 블록)
- 흉벽: 교좌면 단차 위에 얹음. 배면 단차부(−0.8 … −0.5)는 브라켓 상면(흉벽 상단 − 0.85)까지, 흉벽(−0.5 … 0)은 흉벽 상단까지
- 둘은 교좌면 단차에서 맞닿고 겹치지 않음. 배면 단차부는 흉벽 부재로 묶음
- 테스트 636건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 16:31:35 +09:00

135 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { abutPlacement, buildAbutGroup, cameraForBox, covers } from '../src/view/abut-scene';
import type { TerrainGrid } from '../src/core/ground-model';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const input = toAbutInput(parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]);
const GROUND_EL = 33.984; // 운곡교 A1 지반고 (입력 파일, spec_abut §10.1)
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 count = (name: string) => (group.getObjectByName(name) as THREE.Group | undefined)?.children.length ?? 0;
describe('교대 A1 3D 장면 (AA3·AA3b·AA3c)', () => {
it('has the members: 5 wall blocks, 10 parapet parts (2 per step), 1 footing, 18 piles, 2 wings, 4 haunches', () => {
expect(count('wall')).toBe(5); // one 벽체 block per 교좌면 step (삽도)
expect(count('parapet')).toBe(10); // per step: back strip (흉벽 배면 단차) and the 흉벽 (삽도)
expect(count('footing')).toBe(1);
expect(count('piles')).toBe(18);
expect(count('wings')).toBe(2);
expect(count('haunches')).toBe(4);
});
it('spans from the pile tip (19.295 m) up to the highest parapet end (47.18 m)', () => {
const box = new THREE.Box3().setFromObject(group);
expect(box.min.y).toBeCloseTo(19.295, 2);
expect(box.max.y).toBeGreaterThan(47.0);
expect(box.max.y).toBeLessThan(48.0);
});
it('spans the axis from IP1 (s = 0) to IP3 (s = L) along x', () => {
const box = new THREE.Box3().setFromObject(group);
expect(box.min.x).toBeLessThanOrEqual(0.01);
expect(box.max.x).toBeGreaterThanOrEqual(model.length - 0.01);
});
it('a group without foundation (no footing or piles) still builds', () => {
const bare = buildAbutGroup(model, buildMembers(model), buildWings(model, buildMembers(model)));
expect((bare.getObjectByName('footing') as THREE.Group | undefined)).toBeUndefined();
expect(bare.getObjectByName('wall')).toBeDefined();
});
});
describe('교대 배치 (교각 장면과 같은 좌표계)', () => {
const pivot = { e: 179138.0886505, n: 259865.6772855 };
it('the IP3 end maps to the pier-scene position of IP3 (E − pivot.E, EL, −(N − pivot.N))', () => {
const v = new THREE.Vector3(model.length, 0, 0).applyMatrix4(abutPlacement(model, pivot));
expect(v.x).toBeCloseTo(input.ip3.e - pivot.e, 4);
expect(v.z).toBeCloseTo(-(input.ip3.n - pivot.n), 4);
});
it('the IP1 end maps to the pier-scene position of IP1', () => {
const v = new THREE.Vector3(0, 0, 0).applyMatrix4(abutPlacement(model, pivot));
expect(v.x).toBeCloseTo(model.ip1.e - pivot.e, 4);
expect(v.z).toBeCloseTo(-(model.ip1.n - pivot.n), 4);
});
it('a placed group keeps the local parts: the parapet end at IP3 stays at 46.835 m', () => {
const placed = buildAbutGroup(model, members, wings, pivot);
const box = new THREE.Box3().setFromObject(placed);
expect(box.max.y).toBeGreaterThan(47.0);
expect(placed.matrixAutoUpdate).toBe(true);
placed.updateMatrixWorld(true);
// The placed group must sit at IP3 in the pier frame (x = E − pivot.E, z = −(N − pivot.N)).
const ip3 = new THREE.Vector3(input.ip3.e - pivot.e, 0, -(input.ip3.n - pivot.n));
const boxAtIp3 = new THREE.Box3().setFromObject(placed);
expect(boxAtIp3.containsPoint(new THREE.Vector3(ip3.x, 40, ip3.z))).toBe(true);
});
});
describe('교대 지반 (지반고 pad)', () => {
const pivot = { e: 179138.0886505, n: 259865.6772855 };
it('the ground pad sits at 지반고 33.984 m and covers the axis and the wings', () => {
const g = buildAbutGroup(model, members, wings, pivot, GROUND_EL);
const ground = g.getObjectByName('ground') as THREE.Group;
expect(ground).toBeDefined();
const box = new THREE.Box3().setFromObject(ground);
expect(box.min.y).toBeCloseTo(GROUND_EL - 0.025, 3);
expect(box.max.y).toBeCloseTo(GROUND_EL + 0.025, 3);
// Covers the axis length plus the margin on both ends (x is the axis direction).
expect(box.max.x - box.min.x).toBeGreaterThan(model.length);
});
it('without a 지반고 there is no ground pad', () => {
const g = buildAbutGroup(model, members, wings, pivot);
expect(g.getObjectByName('ground')).toBeUndefined();
});
});
describe('교대 지형 모델', () => {
const pivot = { e: 179138.0886505, n: 259865.6772855 };
// A flat terrain grid at 33.984 m: N (origin[0]) 259800…259899, E (origin[1]) 179100…179199, 1 m step.
const grid: TerrainGrid = { origin: [259800, 179100], step: 1, nx: 100, ny: 100, z: Array(100 * 100).fill(33.984) };
const small: TerrainGrid = { origin: [259800, 179100], step: 1, nx: 10, ny: 10, z: Array(100).fill(33.984) };
it('covers() is true when the footprint lies inside the grid, false outside', () => {
expect(covers(grid, [model.ip1, { e: model.ip1.e, n: model.ip1.n }], 2)).toBe(true);
expect(covers(small, [model.ip1], 2)).toBe(false);
});
it('a covering terrain replaces the flat pad', () => {
const g = buildAbutGroup(model, members, wings, pivot, GROUND_EL, grid);
const ground = g.getObjectByName('ground') as THREE.Group;
const mesh = ground.children[0] as THREE.Mesh;
expect(mesh.geometry.type).toBe('BufferGeometry');
});
it('a terrain that does not cover the abutment falls back to the flat pad', () => {
const g = buildAbutGroup(model, members, wings, pivot, 33.984, small);
const mesh = (g.getObjectByName('ground') as THREE.Group).children[0] as THREE.Mesh;
expect(mesh.geometry.type).toBe('BoxGeometry');
});
});
describe('교대 선택 시 카메라', () => {
it('the camera target is the centre of the abutment box and the camera stands back from it', () => {
const box = new THREE.Box3(new THREE.Vector3(0, 19, -2), new THREE.Vector3(12, 47, 2));
const cam = cameraForBox(box);
expect(cam.target.x).toBeCloseTo(6, 6);
expect(cam.target.y).toBeCloseTo(33, 6);
expect(cam.position.distanceTo(cam.target)).toBeGreaterThan(12);
});
});