feat(abut): 교대 형상·입력 패널과 교각 받침·솔플레이트 개편

- 교대 형상(src/core/abut, 3D 장면, 목록·카메라·맞춤)과 교대 입력 패널 추가
- 교각 받침 기준 카드 제거, 솔플레이트 규칙을 교각별 입력으로 변경
- 받침 ELEVATION·솔플레이트 TABLE을 모델값으로 연결
- 교대 입력값 카드: 형식 행에 시점·종점 표시, 제목을 "A1 입력값 [입력]"으로 변경
- 테스트 614건 통과, 타입 검사 통과

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-03 14:51:34 +09:00
1 parent b1b2ad1ddb
commit 1d31d2f3a9
44 files changed
+2571 -120

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { applyAbutEdit, applyBearingEdit, restoreAbutDefault, setWingForm } from '../src/core/abut/edit';
import { parseAbutments } from '../src/core/abut/parse';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
describe('교대 입력 편집 (교대 편집 카드)', () => {
it('a valid change returns a new record and leaves the old one unchanged', () => {
const next = applyAbutEdit(rec, 'groundEl', 34.5);
expect(next.groundEl).toBe(34.5);
expect(rec.groundEl).toBeCloseTo(33.984, 3);
});
it('a non-number, a pile length of 0 or a skew out of range are refused with the field name', () => {
expect(() => applyAbutEdit(rec, 'seatEl', NaN)).toThrow('교좌면 EL');
expect(() => applyAbutEdit(rec, 'pileLengthMm', 0)).toThrow('말뚝길이');
expect(() => applyAbutEdit(rec, 'skewDeg', 200)).toThrow('skew');
});
it('the 교좌면 EL must stay above the 기초 하단 EL', () => {
expect(() => applyAbutEdit(rec, 'seatEl', rec.foundationBottomEl)).toThrow('기초 하단 EL보다 높아야');
});
it('a wing form change sets one side and keeps the other', () => {
const next = setWingForm(rec, 'left', 'short');
expect(next.wings).toEqual({ left: 'short', right: 'general' });
expect(rec.wings).toBeUndefined();
});
});
describe('교대 기본값 복원과 받침 편집', () => {
it('restores the imported defaults after several changes (a copy, not the same object)', () => {
const changed = setWingForm(applyAbutEdit(rec, 'groundEl', 34.5), 'left', 'none');
const back = restoreAbutDefault(rec);
expect(back).toEqual(rec);
expect(back).not.toBe(rec);
expect(changed.groundEl).toBe(34.5);
});
it('a bearing z change keeps the others; a 교좌면 override sets that bearing only', () => {
const z = applyBearingEdit(rec, 3, 'z', 44.7);
expect(z.bearings.find((b) => b.no === 3)!.z).toBe(44.7);
expect(z.bearings.find((b) => b.no === 1)!.z).toBe(rec.bearings[0].z);
const seat = applyBearingEdit(rec, 2, 'seatEl', 44.5);
expect(seat.bearings.find((b) => b.no === 2)!.seatEl).toBe(44.5);
const auto = applyBearingEdit(seat, 2, 'seatEl', undefined);
expect(auto.bearings.find((b) => b.no === 2)!.seatEl).toBeUndefined();
});
it('refuses an unknown bearing, a non-number z, and a 교좌면 at or below the 기초 하단', () => {
expect(() => applyBearingEdit(rec, 99, 'z', 44)).toThrow('받침 99번');
expect(() => applyBearingEdit(rec, 1, 'z', undefined)).toThrow('z는 숫자');
expect(() => applyBearingEdit(rec, 1, 'seatEl', rec.foundationBottomEl)).toThrow('기초 하단');
});
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import { buildAbut, type AbutInput } from '../src/core/abut/geometry';
// Abut 시트의 A1 (운곡교, 2026-10-30 6차). 시트 x = 북, y = 동 → 내부 (E, N) = (y, x).
const A1: AbutInput = {
name: 'A1',
type: 'A1',
seatEl: 44.367,
ip1: { e: 179131.917023, n: 259815.91973 },
ip2: { e: 179131.907126, n: 259815.91805 },
ip3: { e: 179119.891533, n: 259817.959709 },
bearings: [
{ no: 1, pos: { e: 179121.1404, n: 259818.5035 }, z: 44.492 },
{ no: 2, pos: { e: 179123.6148, n: 259818.0835 }, z: 44.563 },
{ no: 3, pos: { e: 179126.0891, n: 259817.6635 }, z: 44.634 },
{ no: 4, pos: { e: 179128.5634, n: 259817.2435 }, z: 44.705 },
{ no: 5, pos: { e: 179131.0377, n: 259816.8236 }, z: 44.776 },
],
};
describe('교대 A1 축과 받침 (AA1, spec_abut §10.1)', () => {
const m = buildAbut(A1);
it('axis IP1 → IP3 is 12.197 m', () => {
expect(m.length).toBeCloseTo(12.197, 3);
});
it('bearing s from IP1: 11.057 / 8.547 / 6.037 / 3.528 / 1.018 m (①–⑤)', () => {
const expected = [11.057, 8.547, 6.037, 3.528, 1.018];
m.bearings.forEach((b, i) => expect(b.s).toBeCloseTo(expected[i], 2));
});
it('seat EL ①–⑤: 44.367 / 44.438 / 44.509 / 44.580 / 44.651 (block 125 mm, pad included)', () => {
const expected = [44.367, 44.438, 44.509, 44.58, 44.651];
m.bearings.forEach((b, i) => {
expect(b.seatEl).toBeCloseTo(expected[i], 3);
expect(b.blockHeightMm).toBe(125);
});
});
it('step boundaries from IP1: 2.273 / 4.783 / 7.292 / 9.802 m', () => {
const expected = [2.273, 4.783, 7.292, 9.802];
expect(m.stepBounds).toHaveLength(4);
m.stepBounds.forEach((s, i) => expect(s).toBeCloseTo(expected[i], 2));
});
it('the bearings are on the left of 1 → n for A1 (front), so the layout matches the type (AW03 clear)', () => {
expect(m.frontLeftOfBearingDir).toBe(true);
expect(m.layoutMatchesType).toBe(true);
});
it('the bearing 1 lies 0.745 m from the axis (spec §10.1 전면 판정)', () => {
expect(Math.abs(m.bearings[0].lateral)).toBeCloseTo(0.745, 2);
});
it('an A2 label on the same layout fails the type check (AW03)', () => {
expect(buildAbut({ ...A1, type: 'A2' }).layoutMatchesType).toBe(false);
});
it('a bearing with its own 교좌면 EL uses it and its block height is z − seat', () => {
const own = buildAbut({ ...A1, bearings: A1.bearings.map((b, i) => (i === 2 ? { ...b, seatEl: 44.5 } : b)) });
expect(own.bearings[2].seatEl).toBeCloseTo(44.5, 6);
expect(own.bearings[2].blockHeightMm).toBe(134);
expect(own.bearings[0].blockHeightMm).toBe(125);
});
it('refuses an abutment with fewer than two bearings or coincident IP1 and IP3', () => {
expect(() => buildAbut({ ...A1, bearings: A1.bearings.slice(0, 1) })).toThrow('받침이 2개 이상');
expect(() => buildAbut({ ...A1, ip3: A1.ip1 })).toThrow('같은 점');
});
});
// AA2 (spec_abut §4.7, §10.1, §10.2): 날개벽 선, 날개벽 경사, AW01, 접속슬래브 폭.
const SKEW = 95 + 2 / 60 + 52 / 3600; // 95°02′52″
const GIRDER = 75 + 56 / 60 + 55.9 / 3600; // 거더 방향 CAD 75°56′55.9″
const A1_WING: AbutInput = { ...A1, skewDeg: SKEW, slopeI: -0.0191, girderAzimuthDeg: GIRDER };
// A2 합성 예제 (spec §10.2): A1을 중심 C 기준으로 180° 돌리고, 받침 번호를 거꾸로 매겨 1 → n 방향이 A1과 같게 한다.
const A2_WING: AbutInput = {
name: 'A2',
type: 'A2',
seatEl: 44.367,
ip1: { e: 179161.0318, n: 259932.3259 },
ip2: { e: 179161.0417, n: 259932.3276 },
ip3: { e: 179173.0573, n: 259930.2859 },
bearings: [
{ no: 1, pos: { e: 179161.9111, n: 259931.422 }, z: 44.492 },
{ no: 2, pos: { e: 179164.3854, n: 259931.0021 }, z: 44.563 },
{ no: 3, pos: { e: 179166.8597, n: 259930.5821 }, z: 44.634 },
{ no: 4, pos: { e: 179169.334, n: 259930.1621 }, z: 44.705 },
{ no: 5, pos: { e: 179171.8084, n: 259929.7421 }, z: 44.776 },
],
skewDeg: SKEW,
slopeI: -0.0191,
girderAzimuthDeg: GIRDER,
};
describe('교대 날개벽 · 접속슬래브 폭 (AA2, spec_abut §4.7–4.8, §10)', () => {
const w = buildAbut(A1_WING).wing!;
it('A1 wing line is 255.32° CAD, on the 배면 side (not the bridge side)', () => {
expect(w.lineDeg).toBeCloseTo(255.32, 1);
});
it('AW01: the wing line is within 1° of the girder direction (0.63°)', () => {
expect(w.girderDiffDeg).toBeCloseTo(0.63, 1);
expect(w.aw01Ok).toBe(true);
});
it('A1 wing slope is +1.91 % (A1 = −i), wing rising away from the wall', () => {
expect(w.slopePct).toBeCloseTo(1.91, 2);
});
it('slab width 11.253 m with both general wings; 11.725 with one; 12.197 with none', () => {
expect(w.slabWidthMm).toBeCloseTo(11253, -1);
expect(buildAbut({ ...A1_WING, wings: { left: 'general', right: 'none' } }).wing!.slabWidthMm).toBeCloseTo(11725, -1);
expect(buildAbut({ ...A1_WING, wings: { left: 'none', right: 'none' } }).wing!.slabWidthMm).toBeCloseTo(12197, -1);
});
it('A2 synthetic: the bearings are on the right of 1 → n, so the layout matches A2 (AW03 clear)', () => {
const m2 = buildAbut(A2_WING);
expect(m2.frontLeftOfBearingDir).toBe(false);
expect(m2.layoutMatchesType).toBe(true);
expect(m2.bearings[0].s).toBeCloseTo(1.018, 2);
expect(m2.bearings[4].s).toBeCloseTo(11.057, 2);
});
it('A2 synthetic: the wing line is 75.32° (배면 쪽, 교량 반대쪽), not 255.32° (the wrong 1→n rotation)', () => {
const w2 = buildAbut(A2_WING).wing!;
expect(w2.lineDeg).toBeCloseTo(75.32, 1);
expect(w2.aw01Ok).toBe(true);
expect(w2.slopePct).toBeCloseTo(-1.91, 2);
expect(w2.slabWidthMm).toBeCloseTo(11253, -1);
});
it('without skew there is no wing block', () => {
expect(buildAbut(A1).wing).toBeUndefined();
});
});
+59
View File
@@ -0,0 +1,59 @@
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';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
// 운곡교 A1, 기준 입력 파일에서 읽음 (spec_abut §10.1).
const input = toAbutInput(parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]);
const model = buildAbut(input);
const m = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
describe('교대 A1 벽체 · 흉벽 · 저판 · 말뚝 (AA3, spec_abut §10.1)', () => {
it('저판 상면 41.995 (기초 하단 40.895 + 두께 1.1)', () => {
expect(m.footingTopEl).toBeCloseTo(41.995, 3);
});
it('wall height ① 구간 2.372 m, five segments with the seat EL of each bearing', () => {
expect(m.wallSegments).toHaveLength(5);
const first = m.wallSegments.find((w) => Math.abs(w.seatEl - 44.367) < 1e-6)!;
expect(first.heightM).toBeCloseTo(2.372, 3);
});
it('wall segments tile the axis from 0 to L with the step boundaries', () => {
const w = m.wallSegments;
expect(w[0].sFrom).toBeCloseTo(0, 9);
expect(w[w.length - 1].sTo).toBeCloseTo(model.length, 9);
expect(w[0].sTo).toBeCloseTo(model.stepBounds[0], 9);
for (let i = 1; i < w.length; i++) expect(w[i].sFrom).toBeCloseTo(w[i - 1].sTo, 9);
});
it('흉벽 상단 EL 47.180 at IP1 end and 46.835 at IP3 end; slope −2.829 %', () => {
expect(m.parapetTopStartEl).toBeCloseTo(47.18, 2);
expect(m.parapetTopEndEl).toBeCloseTo(46.835, 2);
expect(m.parapetSlope * 100).toBeCloseTo(-2.829, 2);
});
it('말뚝 3열 × 6본 = 18, rows at t −1.15 / +0.85 / +2.15, spacing ≈ 2.179 m', () => {
expect(m.piles.map((r) => r.t)).toEqual([-1.15, 0.85, 2.15]);
expect(m.piles.every((r) => r.s.length === 6)).toBe(true);
const gap = m.piles[0].s[1] - m.piles[0].s[0];
expect(gap).toBeCloseTo(2.179, 2);
expect(m.piles[0].s[0]).toBeCloseTo(0.65, 6);
});
it('말뚝 선단 EL 19.295 (40.895 − 21.6 m)', () => {
expect(m.pileBottomEl).toBeCloseTo(19.295, 3);
});
it('without a foundation the footing and piles have no EL (no crash)', () => {
const bare = buildMembers(model);
expect(bare.footingBottomEl).toBeUndefined();
expect(bare.pileBottomEl).toBeUndefined();
expect(bare.piles[0].s).toHaveLength(6);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { structureOrder } from '../src/core/abut/order';
describe('구조물 목록 순서 (A1 · P1 … · A2)', () => {
it('A1 first, piers in order, A2 last', () => {
const list = structureOrder([{ name: 'A2', type: 'A2' }, { name: 'A1', type: 'A1' }], ['P2', 'P1']);
expect(list.map((s) => s.name)).toEqual(['A1', 'P2', 'P1', 'A2']);
expect(list[0].kind).toBe('abut');
expect(list[1].kind).toBe('pier');
expect(list[3].type).toBe('A2');
});
it('no abutments: only the piers', () => {
expect(structureOrder([], ['P1']).map((s) => s.name)).toEqual(['P1']);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { ABUT_DETAIL_LABELS, abutInputRows, abutSummaryRows } from '../src/core/abut/summary';
import { parseAbutments } from '../src/core/abut/parse';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
describe('교대 패널 행 (시안 입력값 카드 · 상세 축약)', () => {
it('the 입력값 card has the nine rows of the mockup', () => {
const rows = abutInputRows(rec);
expect(rows).toHaveLength(9);
expect(rows[1]).toEqual(['교좌면 EL (구간 ①)', '44.367 m']);
expect(rows[0][1]).toMatch(/^A1 \(시점\) · /);
expect(rows[8][1]).toBe('5개 · z = 패드 상면');
});
it('the 상세 shows 8 rows, all of them computed rows of the summary', () => {
const all = new Map(abutSummaryRows(rec));
expect(ABUT_DETAIL_LABELS).toHaveLength(8);
for (const label of ABUT_DETAIL_LABELS) expect(all.has(label)).toBe(true);
});
});
+83
View File
@@ -0,0 +1,83 @@
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';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const load = (buf: Buffer) => XLSX.read(buf, { type: 'buffer' });
describe('교대 입력 읽기 (운곡교P1입력.xlsx, AA 입력)', () => {
const { abutments, issues } = parseAbutments(load(readFileSync(RAG)));
const a1 = abutments.find((a) => a.name === 'A1')!;
it('reads A1 without issues', () => {
expect(issues).toEqual([]);
expect(abutments.map((a) => a.name)).toEqual(['A1']);
expect(a1.type).toBe('A1');
expect(a1.seatEl).toBeCloseTo(44.367, 3);
expect(a1.foundation).toBe('말뚝기초');
expect(a1.pileLengthMm).toBe(21600);
expect(a1.skewDeg).toBeCloseTo(95 + 2 / 60 + 52 / 3600, 6);
expect(a1.slopeI).toBeCloseTo(-0.0191, 6);
});
it('IP1·IP2·IP3 use E = y_m, N = x_m (2026-11-03 규약)', () => {
expect(a1.ip1.e).toBeCloseTo(179131.917023, 6);
expect(a1.ip1.n).toBeCloseTo(259815.91973, 6);
expect(a1.ip3.e).toBeCloseTo(179119.891533, 6);
});
it('reads the five bearings with their own 교좌면 EL and the 받침높이 as written (275 mm)', () => {
expect(a1.bearings.map((b) => b.no)).toEqual([1, 2, 3, 4, 5]);
expect(a1.bearings.map((b) => b.seatEl)).toEqual([44.367, 44.438, 44.509, 44.58, 44.651]);
expect(a1.bearingHeightMm).toBe(275);
});
it('the parsed A1 gives the AA1 and AA2 values (length 12.197 m, wing 255.32°, slope +1.91 %)', () => {
const m = buildAbut(toAbutInput(a1));
expect(m.length).toBeCloseTo(12.197, 3);
expect(m.layoutMatchesType).toBe(true);
expect(m.wing!.lineDeg).toBeCloseTo(255.32, 1);
expect(m.wing!.slopePct).toBeCloseTo(1.91, 2);
});
});
describe('교대 입력 오류 표시', () => {
const rows = (type: string, skew: string) => [
['교대명', '형식', '교좌면\nEL_m', '기초 하단\nEL_m', '지반고\nEL_m', '기초 형식', '말뚝길이\n_mm', 'skew', '종단경사'],
['A1', type, 44.367, 40.895, 33.984, '말뚝기초', 21600, skew, -0.0191],
[],
['교대명', 'IP점', 'x_m', 'y_m'],
['A1', 'IP1', 259815.91973, 179131.917023],
['', 'IP2', 259815.91805, 179131.907126],
['', 'IP3', 259817.959709, 179119.891533],
[],
['교대명', '받침번호', 'x_m', 'y_m', 'z_m_EL', '방위각', '위치', '교좌면_EL', '받침높이'],
['A1', 1, 259818.5035, 179121.1404, 44.492, "75°56'55.9\"", '전열', 44.367, 275],
['', 2, 259818.0835, 179123.6148, 44.563, "75°57'14.1\"", '전열', 44.438, 275],
];
const book = (r: unknown[][]) => {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(r), '교대');
return wb;
};
it('a wrong 형식 (A3) is an error and the abutment is left out', () => {
const r = parseAbutments(book(rows('A3', "95°02'52\"")));
expect(r.issues.map((i) => i.code)).toContain('AP02');
expect(r.abutments).toEqual([]);
});
it('an unreadable skew is an error', () => {
const r = parseAbutments(book(rows('A1', 'abc')));
expect(r.issues.map((i) => i.code)).toContain('AP03');
});
it('a clean sheet with the same layout reads with no issues', () => {
const r = parseAbutments(book(rows('A1', "95°02'52\"")));
expect(r.issues).toEqual([]);
expect(r.abutments[0].bearings).toHaveLength(2);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { parseAbutments } from '../src/core/abut/parse';
import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project';
import { loadP1 } from './helpers';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const abutments = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments;
function project(extra: Partial<ProjectFile> = {}): ProjectFile {
return {
schemaVersion: SCHEMA_VERSION,
project: { name: '운곡교 교각', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' },
source: { fileName: '운곡교P1입력.xlsx', sheets: ['교각', 'IP', '받침'] },
piers: [{ id: 'P1', input: loadP1() }],
history: [],
...extra,
};
}
describe('교대 기록의 저장과 불러오기 (AB)', () => {
it('the A1 record survives a save and open unchanged', () => {
const text = serializeProject(project({ abutments }));
const back = parseProject(text);
expect(back.abutments).toEqual(abutments);
});
it('an old file without abutments opens with none', () => {
const back = parseProject(serializeProject(project()));
expect(back.abutments).toBeUndefined();
});
it('a damaged abutment record is refused and the message names it', () => {
const bad = JSON.parse(JSON.stringify(abutments)) as typeof abutments;
bad[0].seatEl = NaN as unknown as number;
expect(() => parseProject(serializeProject(project({ abutments: bad })))).toThrow('A1');
});
it('a wrong 형식 is refused', () => {
const bad = JSON.parse(JSON.stringify(abutments)) as typeof abutments;
(bad[0] as unknown as { type: string }).type = 'A3';
expect(() => parseProject(serializeProject(project({ abutments: bad })))).toThrow('A1 또는 A2');
});
it('the imported defaults are saved and opened with the edited values', () => {
const edited = abutments.map((a) => ({ ...a, groundEl: 34.5 }));
const back = parseProject(serializeProject(project({ abutments: edited, abutDefaults: abutments })));
expect(back.abutments![0].groundEl).toBe(34.5);
expect(back.abutDefaults).toEqual(abutments);
});
});
+134
View File
@@ -0,0 +1,134 @@
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 segments, 1 parapet, 1 footing, 18 piles, 2 wings, 4 haunches', () => {
expect(count('wall')).toBe(5);
expect(count('parapet')).toBe(1);
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);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { ABUT_SHAPE_DEFAULT, resolveShape, shapeIssues } from '../src/core/abut/shape';
describe('교대 형상 입력 (시안 ② 벽체·흉벽 · 확대기초·말뚝)', () => {
it('the defaults equal the spec values (wall +1.6 … −0.8, footing +2.8 … −1.8, piles 650 / 2000)', () => {
const r = resolveShape();
expect(r.wallFrontM).toBeCloseTo(1.6, 9);
expect(r.wallBackM).toBeCloseTo(-0.8, 9);
expect(r.parapetBackM).toBeCloseTo(-0.5, 9);
expect(r.footingFrontM).toBeCloseTo(2.8, 9);
expect(r.footingBackM).toBeCloseTo(-1.8, 9);
expect(r.pileEdgeM).toBeCloseTo(0.65, 9);
expect(shapeIssues({})).toEqual([]);
expect(ABUT_SHAPE_DEFAULT.wallThicknessMm).toBe(2400);
});
it('a changed wall thickness moves the back face (front − thickness)', () => {
expect(resolveShape({ wallThicknessMm: 2000 }).wallBackM).toBeCloseTo(-0.4, 9);
});
it('refuses a footing that does not cover the wall, and non-positive sizes', () => {
expect(shapeIssues({ footingBackMm: -500 }).join(' ')).toContain('뒷굽');
expect(shapeIssues({ footingFrontMm: 1000 }).join(' ')).toContain('앞굽');
expect(shapeIssues({ wallThicknessMm: 0 }).join(' ')).toContain('벽체 두께');
expect(shapeIssues({ pileSpacingMm: 0 }).join(' ')).toContain('말뚝');
});
});
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { applyShapeEdit, SHAPE_EDIT_FIELDS } from '../src/core/abut/edit';
import { buildAbut } from '../src/core/abut/geometry';
import { buildMembers } from '../src/core/abut/members';
import { parseAbutments } from '../src/core/abut/parse';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
describe('형상 입력이 계산에 반영됨 (시안 ② 카드)', () => {
it('the card has one field per shape value, in the mockup order', () => {
expect(SHAPE_EDIT_FIELDS.map((f) => f.key)[0]).toBe('wallFrontMm');
expect(SHAPE_EDIT_FIELDS).toHaveLength(10);
});
it('a footing that does not cover the wall is refused by the edit rule', () => {
expect(() => applyShapeEdit(rec, 'footingBackMm', -500)).toThrow('뒷굽');
expect(rec.shape).toBeUndefined();
});
it('a changed footing thickness moves the footing top used by the walls', () => {
const edited = applyShapeEdit(rec, 'footingThicknessMm', 1300);
const model = buildAbut({ name: 'A1', type: 'A1', seatEl: rec.seatEl, ip1: rec.ip1, ip2: rec.ip2, ip3: rec.ip3, bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl })) });
const base = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
const more = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, edited.shape);
expect(more.footingTopEl - base.footingTopEl).toBeCloseTo(0.2, 6);
});
it('a changed pile spacing changes the number of piles per row', () => {
const model = buildAbut({ name: 'A1', type: 'A1', seatEl: rec.seatEl, ip1: rec.ip1, ip2: rec.ip2, ip3: rec.ip3, bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl })) });
const wider = buildMembers(model, {}, { pileSpacingMm: 3000 });
expect(wider.piles[0].s.length).toBeLessThan(6);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { abutSummaryRows } from '../src/core/abut/summary';
import { parseAbutments } from '../src/core/abut/parse';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const rows = new Map(abutSummaryRows(rec));
describe('교대 상세 (삽도 항목, spec_abut §10.1)', () => {
it('the 삽도 values of A1: 저판 상면 41.995, 벽체 2.372 m, 브라켓 상면 46.017, 흉벽 상단 47.180 (IP1 끝) / 46.835 (IP3 끝)', () => {
expect(rows.get('저판 상면 EL')).toBe('41.995 m');
expect(rows.get('벽체 높이 (구간 ①)')).toBe('2.372 m');
expect(rows.get('브라켓 상면 EL (구간 ①)')).toBe('46.017 m');
expect(rows.get('흉벽 상단 EL (IP1 끝 / IP3 끝)')).toBe('47.180 / 46.835 m');
});
it('the piles, joints, 접속슬래브 width and 받침블럭 height match the spec: 18 piles, tip 19.295, 1 joint, 11253 mm, 125 mm', () => {
expect(rows.get('말뚝 (열 × 본수)')).toBe('3 × 6 = 18 본');
expect(rows.get('말뚝 선단 EL')).toBe('19.295 m');
expect(rows.get('수축줄눈 (ceil(L/10) − 1)')).toBe('1 개');
// 11.2535 m exactly (450 / sin Δ + 20 per wing); rounding to mm gives 11253 or 11254.
expect(Math.abs(parseInt(String(rows.get('접속슬래브 폭')), 10) - 11253)).toBeLessThanOrEqual(1);
expect(rows.get('받침블럭 높이 (패드 포함)')).toBe('125 mm');
});
it('the 계단 경계 from IP3 are 2.395 · 4.905 · 7.415 · 9.924 m', () => {
expect(rows.get('계단 경계 (IP3 쪽에서)')).toBe('2.395 / 4.905 / 7.415 / 9.924 m');
});
});
+156
View File
@@ -0,0 +1,156 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { buildAbut, type AbutInput } from '../src/core/abut/geometry';
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import { buildWings, WING_LENGTH_M } from '../src/core/abut/wings';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const a1Input = toAbutInput(parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]);
const SKEW = 95 + 2 / 60 + 52 / 3600;
const GIRDER = 75 + 56 / 60 + 55.9 / 3600;
// A2 합성 예제 (spec §10.2): ① 쪽이 IP1 끝.
const a2Input: AbutInput = {
name: 'A2',
type: 'A2',
seatEl: 44.367,
ip1: { e: 179161.0318, n: 259932.3259 },
ip2: { e: 179161.0417, n: 259932.3276 },
ip3: { e: 179173.0573, n: 259930.2859 },
bearings: [
{ no: 1, pos: { e: 179161.9111, n: 259931.422 }, z: 44.492 },
{ no: 2, pos: { e: 179164.3854, n: 259931.0021 }, z: 44.563 },
{ no: 3, pos: { e: 179166.8597, n: 259930.5821 }, z: 44.634 },
{ no: 4, pos: { e: 179169.334, n: 259930.1621 }, z: 44.705 },
{ no: 5, pos: { e: 179171.8084, n: 259929.7421 }, z: 44.776 },
],
skewDeg: SKEW,
slopeI: -0.0191,
girderAzimuthDeg: GIRDER,
foundationBottomEl: 40.895,
pileLengthMm: 21600,
};
describe('교대 A2 합성 · 날개벽 (AA3b, spec_abut §10.2)', () => {
const model = buildAbut(a2Input);
const members = buildMembers(model, { bottomEl: 40.895, pileLengthMm: 21600 });
const [left, right] = buildWings(model, members);
it('좌 날개벽 (① 쪽, IP1 끝): top 46.838 at the start, 46.714 at the end, bottom end 45.214', () => {
expect(left.side).toBe('left');
expect(left.sEnd).toBeCloseTo(0, 9);
expect(left.topStartEl).toBeCloseTo(46.838, 2);
expect(left.topEndEl).toBeCloseTo(46.714, 2);
expect(left.bottomEndEl).toBeCloseTo(45.214, 2);
});
it('우 날개벽 (⑤ 쪽, IP3 끝): top 47.183 → 47.059, bottom end 45.559', () => {
expect(right.side).toBe('right');
expect(right.sEnd).toBeCloseTo(model.length, 9);
expect(right.topStartEl).toBeCloseTo(47.183, 2);
expect(right.topEndEl).toBeCloseTo(47.059, 2);
expect(right.bottomEndEl).toBeCloseTo(45.559, 2);
});
it('the wing is 6.5 m long along the wing line and 0.45 m thick inward', () => {
const [p0, p1] = left.corners;
expect(Math.hypot(p1.e - p0.e, p1.n - p0.n)).toBeCloseTo(WING_LENGTH_M, 6);
const [, , q2, q3] = left.corners;
expect(Math.hypot(q2.e - p1.e, q2.n - p1.n)).toBeCloseTo(0.45, 6);
expect(Math.hypot(q3.e - p0.e, q3.n - p0.n)).toBeCloseTo(0.45, 6);
});
it('the wing starts at the axis end with the wing direction on the 배면 side (AA2 line 75.32°)', () => {
const [p0, p1] = left.corners;
const deg = (Math.atan2(p1.n - p0.n, p1.e - p0.e) * 180) / Math.PI;
const cad = ((deg % 360) + 360) % 360;
expect(cad).toBeCloseTo(75.32, 1);
});
it('a 단축형 wing bottom sits at the footing top (41.995)', () => {
const short = buildWings(model, members, { left: 'short', right: 'none' });
expect(short).toHaveLength(1);
expect(short[0].bottomEndEl).toBeCloseTo(41.995, 3);
});
it('no wing when the side is 없음', () => {
expect(buildWings(model, members, { left: 'none', right: 'none' })).toEqual([]);
});
});
describe('교대 A1 날개벽 (운곡교P1입력.xlsx)', () => {
const model = buildAbut(a1Input);
const members = buildMembers(model, { bottomEl: a1Input.foundationBottomEl, pileLengthMm: a1Input.pileLengthMm });
const wings = buildWings(model, members);
it('A1 has two general wings; ① 쪽 wing starts at the IP3 end (the bearing ① is near IP3)', () => {
expect(wings.map((w) => w.side)).toEqual(['left', 'right']);
const left = wings[0];
expect(left.sEnd).toBeCloseTo(model.length, 9);
expect(left.topStartEl).toBeCloseTo(46.835, 2);
// A1 날개벽 경사는 +1.91 % (벽체에서 멀어질수록 높아짐): 끝이 0.124 m 높다.
expect(left.topEndEl - left.topStartEl).toBeCloseTo(0.124, 2);
});
});
describe('교대 날개벽 하단 선 · 헌치 (AA3c, spec_abut §4.7)', () => {
const a1 = buildAbut(a1Input);
const a1m = buildMembers(a1, { bottomEl: a1Input.foundationBottomEl, pileLengthMm: a1Input.pileLengthMm });
const [a1Left] = buildWings(a1, a1m);
const a2 = buildAbut(a2Input);
const a2m = buildMembers(a2, { bottomEl: 40.895, pileLengthMm: 21600 });
const [a2Left] = buildWings(a2, a2m);
it('뒷굽 끝까지의 날개벽 거리 1.807 m (단축형 표와 같다)', () => {
expect(a1Left.heelDistanceM).toBeCloseTo(1.807, 2);
expect(a2Left.heelDistanceM).toBeCloseTo(1.807, 2);
});
it('하단선: 뒷굽 끝까지 저판 상면 41.995, 그 밖은 단부 하단까지 직선 (A2 좌: 45.214)', () => {
const line = a2Left.bottomLine;
expect(line[0].el).toBeCloseTo(41.995, 3);
expect(line[1].dM).toBeCloseTo(1.807, 2);
expect(line[1].el).toBeCloseTo(41.995, 3);
expect(line[2].el).toBeCloseTo(45.214, 2);
});
it('two 300 × 300 haunches: upper at the 흉벽 배면, lower at the 벽체 배면, legs 0.3 m', () => {
expect(a2Left.haunches.map((h) => h.kind)).toEqual(['upper', 'lower']);
const [c0, c1, c2] = a2Left.haunches[0].corners;
expect(Math.hypot(c1.e - c0.e, c1.n - c0.n)).toBeCloseTo(0.3, 6);
expect(Math.hypot(c2.e - c0.e, c2.n - c0.n)).toBeCloseTo(0.3, 6);
});
it('upper haunch runs from the bracket top (흉벽 상단 − 0.85) to the wing top; lower from the footing top to the bracket top', () => {
const [up, low] = a2Left.haunches;
expect(up.zBottomEl).toBeCloseTo(46.838 - 0.85, 2);
expect(low.zBottomEl).toBeCloseTo(41.995, 3);
expect(low.zTopEl).toBeCloseTo(up.zBottomEl, 6);
expect(up.zTopEl).toBeGreaterThan(up.zBottomEl);
});
});
describe('날개벽 수평 단 (QA-12 초안 적용, spec_abut §4.7)', () => {
const a2 = buildAbut(a2Input);
const a2m = buildMembers(a2, { bottomEl: 40.895, pileLengthMm: 21600 });
const [a2Left] = buildWings(a2, a2m);
it('등단면 down to 1.5 m below the wing end, then 변화단면1 to the seat EL and 변화단면2 to the footing top', () => {
const [equal, v1, v2] = a2Left.bands;
expect(equal.name).toBe('등단면');
expect(equal.zBottomEl).toBeCloseTo(45.214, 2);
expect(v1.name).toBe('변화단면1');
expect(v1.zBottomEl).toBeCloseTo(44.367, 3);
expect(v2.name).toBe('변화단면2');
expect(v2.zBottomEl).toBeCloseTo(41.995, 3);
});
it('bands are contiguous from the wing top to the footing top', () => {
const b = buildWings(a2, a2m)[0].bands;
for (let i = 1; i < b.length; i++) expect(b[i].zTopEl).toBeCloseTo(b[i - 1].zBottomEl, 6);
expect(b[b.length - 1].zBottomEl).toBeCloseTo(41.995, 3);
});
});
+2 -2
View File
@@ -21,8 +21,8 @@ describe('straight axis (regression against spec §12)', () => {
expect(fs[fs.length - 1].s - bp1).toBeCloseTo(10.965, 3);
expect(bs[0].s - bp1).toBeCloseTo(1.029, 3);
expect(bs[bs.length - 1].s - bp1).toBeCloseTo(11.05, 3);
expect(fs[0].n).toBeCloseTo(-0.698, 3);
expect(bs[0].n).toBeCloseTo(0.6, 3);
expect(fs[0].n).toBeCloseTo(0.698, 3);
expect(bs[0].n).toBeCloseTo(-0.6, 3);
});
it('straight axis: every point is on stretch 0 and round-trips', () => {
+1 -1
View File
@@ -20,7 +20,7 @@ describe('bearing position / direction coordinate agreement', () => {
});
it('keeps every real Sanwoe bearing approximately aligned with its coping', () => {
const dir = fileURLToPath(new URL('../pierzainer_ground/', import.meta.url));
const dir = fileURLToPath(new URL('../pierzainer_ground/data/input/sanwoe/', import.meta.url));
const name = readdirSync(dir).find((name) => name.includes('산외교') && name.endsWith('.xlsx'))!;
const { piers, issues } = parseWorkbook(XLSX.read(readFileSync(`${dir}/${name}`), { type: 'buffer' }));
expect(issues).toEqual([]);
-12
View File
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { bearingSectionSvg } from '../src/view/bearing-section-svg';
import { solplateDetailSvg } from '../src/view/solplate-svg';
import { loadP1 } from './helpers';
const model = buildPier(loadP1());
@@ -22,14 +21,3 @@ describe('교량받침 상세 단면 (승인 2026-10-26, C안 2026-11-03)', () =
expect(bearingSectionSvg(model, 99)).toBe('');
});
});
describe('솔플레이트 상세 (승인 2026-10-26, C안 2026-11-03)', () => {
it('shows L, W, T1 and T2 of the pier plan', () => {
const block = model.blocks.find((b) => b.no === 1)!;
const svg = solplateDetailSvg(block);
expect(svg).toContain('L 1000');
expect(svg).toContain('W 1100');
expect(svg).toContain('T1 27.5');
expect(svg).toContain('T2 22.5');
});
});
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { elevationRows, solplateRows } from '../src/core/bearing-table';
import { elevationRows, pairCopingSpans, solplateRows } from '../src/core/bearing-table';
import { loadP1 } from './helpers';
const model = buildPier(loadP1());
@@ -55,3 +55,19 @@ describe('솔플레이트 TABLE (모델값, 2026-11-03)', () => {
expect(rows[0].checkRhs).toBeCloseTo(5, 9);
});
});
describe('S1·S2 코핑 치수 (2026-11-03)', () => {
it('S1 + S2 + S1 = the coping width B (3100); S1 is bearing centre to the coping edge', () => {
const s = pairCopingSpans(model, 0)!;
expect(s.copingWidth).toBe(3100);
expect(s.S1Low + s.S2 + s.S1High).toBe(3100);
// Pair 1 · 6 (P1): the 후열 sits at n −0.600 (low side), the 전열 at n +0.698 (high side). S1 low 950 (후열), S2 1298, S1 high 852 (전열).
expect(s.S1Low).toBe(950);
expect(s.S2).toBe(1298);
expect(s.S1High).toBe(852);
});
it('an unknown pair gives null', () => {
expect(pairCopingSpans(model, 99)).toBeNull();
});
});
+28
View File
@@ -0,0 +1,28 @@
import * as THREE from 'three';
import { describe, expect, it } from 'vitest';
import { fitCamera } from '../src/view/fit';
describe('카메라 맞춤 (전체 모델이 화면에 들어옴)', () => {
const sphere = new THREE.Sphere(new THREE.Vector3(10, 30, 0), 50);
it('the target moves to the sphere centre and the sphere lies inside the view cone', () => {
const cam = fitCamera(new THREE.Vector3(1, 1, 1).multiplyScalar(20), new THREE.Vector3(), sphere, 40, 1.6);
expect(cam.target.distanceTo(sphere.center)).toBeLessThan(1e-9);
// The distance from the camera to the centre must cover the radius at the vertical half-angle.
const d = cam.position.distanceTo(sphere.center);
expect(d * Math.sin(THREE.MathUtils.degToRad(20))).toBeGreaterThanOrEqual(sphere.radius - 1e-6);
});
it('a narrow window (aspect < 1) backs off further than a wide one', () => {
const wide = fitCamera(new THREE.Vector3(0, 0, 100), new THREE.Vector3(), sphere, 40, 2);
const narrow = fitCamera(new THREE.Vector3(0, 0, 100), new THREE.Vector3(), sphere, 40, 0.5);
expect(narrow.position.distanceTo(sphere.center)).toBeGreaterThan(wide.position.distanceTo(sphere.center));
});
it('a camera already far enough keeps its distance', () => {
const far = new THREE.Vector3(0, 0, 1000);
const cam = fitCamera(far, new THREE.Vector3(), sphere, 40, 1.6);
// The distance is kept from the old target (origin): 1000 m, measured from the new centre.
expect(cam.position.distanceTo(sphere.center)).toBeCloseTo(1000, 6);
});
});
+3 -2
View File
@@ -33,8 +33,9 @@ describe('일체 블록 모델 (승인 2026-10-26)', () => {
const f = model.blocks.find((b) => b.no === 1)!;
const b = model.blocks.find((x) => x.no === 6)!;
const half = INTEGRATED_BLOCK.length / 2000;
expect(ib.n - half).toBeCloseTo(f.n - 0.6, 2);
expect(ib.n + half).toBeCloseTo(b.n + 0.6, 2);
// x = 북 convention (2026-11-03): the front row is on the high-n side, so the block spans min..max of the pair.
expect(ib.n - half).toBeCloseTo(Math.min(f.n, b.n) - 0.6, 2);
expect(ib.n + half).toBeCloseTo(Math.max(f.n, b.n) + 0.6, 2);
});
it('분리 keeps no integrated blocks', () => {
+10 -10
View File
@@ -12,14 +12,14 @@ const model = buildPier(loadP1());
describe('P1 regression — spec.md §12', () => {
it('axis length and azimuth', () => {
expect(model.lengthIp).toBeCloseTo(12.166, 3);
expect(model.azimuthDeg).toBeCloseTo(350.37, 2);
expect(model.azimuthDeg).toBeCloseTo(99.63, 2);
});
it('BP1, BP2 coordinates (국토좌표 E, N)', () => {
expect(model.bp1.e).toBeCloseTo(259866.681, 2);
expect(model.bp1.n).toBeCloseTo(179132.173, 2);
expect(Math.abs(model.bp2.e - 259864.674)).toBeLessThan(TOL);
expect(Math.abs(model.bp2.n - 179144.004)).toBeLessThan(TOL);
expect(model.bp1.e).toBeCloseTo(179132.173, 2);
expect(model.bp1.n).toBeCloseTo(259866.681, 2);
expect(Math.abs(model.bp2.e - 179144.004)).toBeLessThan(TOL);
expect(Math.abs(model.bp2.n - 259864.674)).toBeLessThan(TOL);
});
it('IP2 reference recalculation and d_IP2', () => {
@@ -27,15 +27,15 @@ describe('P1 regression — spec.md §12', () => {
});
it('column centre coordinates', () => {
expect(Math.abs(model.pivot.e - 259865.677)).toBeLessThan(TOL);
expect(Math.abs(model.pivot.n - 179138.089)).toBeLessThan(TOL);
expect(Math.abs(model.pivot.e - 179138.089)).toBeLessThan(TOL);
expect(Math.abs(model.pivot.n - 259865.677)).toBeLessThan(TOL);
});
it('bearing positions: front row across-axis −0.698, back row +0.600; front s from 0.943 to 10.965', () => {
it('bearing positions: front row across-axis +0.698, back row −0.600; front s from 0.943 to 10.965', () => {
const front = model.bearings.filter((b) => b.row === '전열');
const back = model.bearings.filter((b) => b.row === '후열');
expect(front[0].n).toBeCloseTo(-0.698, 3);
expect(back[0].n).toBeCloseTo(0.6, 3);
expect(front[0].n).toBeCloseTo(0.698, 3);
expect(back[0].n).toBeCloseTo(-0.6, 3);
expect(front[0].s).toBeCloseTo(0.943, 3);
expect(front[4].s).toBeCloseTo(10.965, 3);
expect(back[0].s).toBeCloseTo(1.029, 3);
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { validatePier } from '../src/core/validate';
import type { Issue, PierInput } from '../src/core/types';
import { loadP1 } from './helpers';
// 교좌면 EL of the P1 sheet (받침 1–10), 2026-11-03.
const SEAT = [43.411, 43.483, 43.555, 43.626, 43.698, 43.411, 43.483, 43.555, 43.626, 43.698];
function withSeat(input: PierInput, seats: number[]): PierInput {
return { ...input, bearings: input.bearings.map((b) => ({ ...b, seatEl: seats[b.no - 1] })) };
}
describe('교좌면 EL 입력 (2026-11-03)', () => {
it('the P1 교좌면 EL values reproduce the 두부보 상면 slope: the model does not change', () => {
const base = buildPier(loadP1());
const seated = buildPier(withSeat(loadP1(), SEAT));
for (const b of base.blocks) {
const s = seated.blocks.find((x) => x.no === b.no)!;
expect(s.bottom).toBeCloseTo(b.bottom, 6);
expect(s.top).toBeCloseTo(b.top, 6);
}
});
it('an input 교좌면 EL sets the block bottom and so h1 = (EL.X − 50 mm) − 교좌면 EL', () => {
const m = buildPier(withSeat(loadP1(), [43.4, ...SEAT.slice(1)]));
const b1 = m.blocks.find((x) => x.no === 1)!;
expect(b1.bottom).toBeCloseTo(43.4, 6);
expect((b1.top - b1.bottom) * 1000).toBeCloseTo(111, 6);
});
it('without 교좌면 EL the seatCalc is the slope value (the check reference)', () => {
const m = buildPier(loadP1());
expect(m.blocks.every((b) => Math.abs(b.seatCalc - b.bottom) < 1e-9)).toBe(true);
});
it('warns (W10) when the input 교좌면 EL differs from the slope by more than 5 mm, and keeps the input', () => {
const input = withSeat(loadP1(), [43.5, ...SEAT.slice(1)]);
const m = buildPier(input);
const issues: Issue[] = [];
validatePier(input, m, issues);
const w = issues.filter((i) => i.code === 'W10');
expect(w.length).toBe(1);
expect(w[0].message).toContain('1번');
expect(m.blocks.find((x) => x.no === 1)!.bottom).toBeCloseTo(43.5, 6);
});
it('no W10 for the P1 values', () => {
const input = withSeat(loadP1(), SEAT);
const m = buildPier(input);
const issues: Issue[] = [];
validatePier(input, m, issues);
expect(issues.filter((i) => i.code === 'W10')).toEqual([]);
});
});
+2 -1
View File
@@ -210,6 +210,7 @@ describe('꺾인 코핑 IP4 (spec §4.1)', () => {
it('a bend on a straight P1 sheet (IP1, IP2, IP3 collinear) is refused: IP4 would land on IP3', () => {
const p1 = loadP1();
const ips = p1.ips.map((p) => (p.name === 'IP4' ? { ...p, angleDeg: 5 } : p));
expect(() => buildPier({ ...p1, ips })).toThrow('꺾임각과 IP 배치');
// Both messages refuse the bend. Which check fires first depends on the axis orientation (x = 북, 2026-11-03).
expect(() => buildPier({ ...p1, ips })).toThrow('꺾임');
});
});