- ground-model.ts: read schema 2 terrain_grid (origin, step, nx, ny, z), checked the same way as the stored project form; schema 1 is still read. - view/terrain.ts: the grid as a mesh in pier-scene coordinates (x = E − pivot E, z = −(N − pivot N), y = EL), same as the pier frame. Cells with a missing corner are not drawn. - scene.ts: draws the terrain in place of the 60 m plane when the model has a grid; the camera fits the members, not the terrain. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
164 lines
7.0 KiB
TypeScript
164 lines
7.0 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
||
import { describe, expect, it } from 'vitest';
|
||
import { groundElFor, parseGroundModel } from '../src/core/ground-model';
|
||
import { changedKeys, overrideKeys, revertDraft, type PierValues } from '../src/core/changes';
|
||
import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project';
|
||
import { loadP1 } from './helpers';
|
||
|
||
/** The shape written by RAG/pierzainer_ground (schema 1). */
|
||
const SAMPLE = JSON.stringify({
|
||
schema: 1,
|
||
generated: '2026-10-23',
|
||
coord: '측량 X(북) · Y(동), m. 치수 mm',
|
||
piers: [
|
||
{ id: 'P1', ground: { center: 29.355, excel: 28.931, used: 29.355, source: '계산' }, footing: { cover: 0.972 } },
|
||
{ id: 'P2', ground: { center: null, excel: null, used: null, source: '없음' } },
|
||
],
|
||
});
|
||
|
||
describe('지반 모델 읽기 (ground_model.json, schema 1)', () => {
|
||
it('reads the pier-centre ground EL and the excel value', () => {
|
||
const m = parseGroundModel(SAMPLE);
|
||
expect(m.piers[0]).toEqual({ id: 'P1', centerEl: 29.355, excelEl: 28.931 });
|
||
expect(groundElFor(m, 'P1')).toBe(29.355);
|
||
});
|
||
|
||
it('a pier outside the terrain has no value, and an unknown pier has none either', () => {
|
||
const m = parseGroundModel(SAMPLE);
|
||
expect(groundElFor(m, 'P2')).toBeUndefined();
|
||
expect(groundElFor(m, 'P9')).toBeUndefined();
|
||
});
|
||
|
||
it('refuses another schema number instead of guessing the layout', () => {
|
||
expect(() => parseGroundModel(SAMPLE.replace('"schema":1', '"schema":3'))).toThrow('형식 번호 3');
|
||
});
|
||
|
||
it('refuses a file without a pier list or without pier names', () => {
|
||
expect(() => parseGroundModel('{"schema": 1}')).toThrow('교각 목록');
|
||
expect(() => parseGroundModel('{"schema": 1, "piers": [{"ground": {}}]}')).toThrow('이름(id)');
|
||
});
|
||
|
||
it('refuses text that is not JSON', () => {
|
||
expect(() => parseGroundModel('not json')).toThrow('JSON으로 읽히지 않습니다');
|
||
});
|
||
});
|
||
|
||
const LEGACY_SAMPLE = JSON.stringify({ schema: 1, piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 } }] });
|
||
|
||
describe('지형 격자 (schema 2)', () => {
|
||
const WITH_GRID = JSON.stringify({
|
||
schema: 2,
|
||
generated: '2026-10-23',
|
||
piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 } }],
|
||
terrain_grid: { origin: [259815.676, 179088.094], step: 5, nx: 2, ny: 2, z: [28, null, 29, 30] },
|
||
});
|
||
|
||
it('reads the terrain grid as terrainGrid', () => {
|
||
const m = parseGroundModel(WITH_GRID);
|
||
expect(m.schema).toBe(2);
|
||
expect(m.terrainGrid).toEqual({ origin: [259815.676, 179088.094], step: 5, nx: 2, ny: 2, z: [28, null, 29, 30] });
|
||
});
|
||
|
||
it('a schema 1 file has no terrain grid', () => {
|
||
expect(parseGroundModel(LEGACY_SAMPLE).terrainGrid).toBeUndefined();
|
||
});
|
||
|
||
it('refuses a grid whose value count is not nx × ny', () => {
|
||
expect(() => parseGroundModel(WITH_GRID.replace('"z":[28,null,29,30]', '"z":[28,29]'))).toThrow('nx × ny');
|
||
});
|
||
|
||
it('refuses a grid value that is not a number or null', () => {
|
||
expect(() => parseGroundModel(WITH_GRID.replace('"z":[28,null,29,30]', '"z":[28,"x",29,30]'))).toThrow('숫자가 아닌');
|
||
});
|
||
|
||
it('refuses an unknown schema number', () => {
|
||
expect(() => parseGroundModel(WITH_GRID.replace('"schema":2', '"schema":3'))).toThrow('형식 번호 3');
|
||
});
|
||
|
||
it('keeps the grid in a saved project', () => {
|
||
const m = parseGroundModel(WITH_GRID);
|
||
const text = serializeProject({
|
||
schemaVersion: SCHEMA_VERSION,
|
||
project: { name: 'x', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' },
|
||
source: { fileName: 'x.xlsx', sheets: [] },
|
||
piers: [{ id: 'P1', input: loadP1() }],
|
||
history: [],
|
||
groundModel: m,
|
||
});
|
||
expect(parseProject(text).groundModel?.terrainGrid).toEqual(m.terrainGrid);
|
||
});
|
||
});
|
||
|
||
describe('번호 없는 옛 지반 모델 (B′: 임시 경로)', () => {
|
||
// examples/out/ground_model.json as written before the schema number existed (P1–P6, same values as the report).
|
||
const LEGACY = readFileSync(new URL('./fixtures/ground-model-legacy.json', import.meta.url), 'utf-8');
|
||
|
||
it('reads the unversioned file and reports it as schema 1', () => {
|
||
const m = parseGroundModel(LEGACY);
|
||
expect(m.schema).toBe(2); // normalised to the current schema
|
||
expect(m.piers.map((p) => p.id)).toEqual(['P1', 'P2', 'P3', 'P4', 'P5', 'P6']);
|
||
expect(groundElFor(m, 'P1')).toBe(29.355);
|
||
expect(m.piers[0].excelEl).toBe(28.931);
|
||
});
|
||
|
||
it('refuses a pier that has no ground object (a renamed field must not load as empty values)', () => {
|
||
const data = JSON.parse(LEGACY);
|
||
delete data.piers[1].ground;
|
||
expect(() => parseGroundModel(JSON.stringify(data))).toThrow('P2: 지반 값(ground)이 없습니다');
|
||
});
|
||
|
||
it('refuses a null schema number instead of treating it as missing', () => {
|
||
expect(() => parseGroundModel(LEGACY.replace('{', '{"schema": null,'))).toThrow('형식 번호 null');
|
||
});
|
||
});
|
||
|
||
describe('지반고 적용 (사용자가 누를 때만, 변경으로 기록)', () => {
|
||
const base = loadP1();
|
||
const pier: PierValues = { defaults: base, input: base, draft: { ...base, groundEl: 29.355 } };
|
||
|
||
it('an applied value counts as an override and can be reverted to the excel value', () => {
|
||
expect(overrideKeys(pier)).toContain('groundEl');
|
||
expect(revertDraft(pier, 'groundEl').groundEl).toBe(base.groundEl);
|
||
});
|
||
|
||
it('the draft and the generated input are compared, so the change waits for regeneration', () => {
|
||
expect(changedKeys(pier.input, pier.draft)).toEqual(['groundEl']);
|
||
});
|
||
});
|
||
|
||
describe('프로젝트 파일의 지반 모델', () => {
|
||
function project(extra: Partial<ProjectFile>): ProjectFile {
|
||
const p1 = loadP1();
|
||
return {
|
||
schemaVersion: SCHEMA_VERSION,
|
||
project: { name: 'x', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' },
|
||
source: { fileName: 'x.xlsx', sheets: [] },
|
||
piers: [{ id: 'P1', input: p1 }],
|
||
history: [],
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
it('keeps the imported ground model across save and open', () => {
|
||
const m = parseGroundModel(SAMPLE);
|
||
const reopened = parseProject(serializeProject(project({ groundModel: m })));
|
||
expect(reopened.groundModel).toEqual(m);
|
||
});
|
||
|
||
it('refuses a damaged ground model inside the project file', () => {
|
||
const text = serializeProject(project({})).replace('"history": []', '"history": [], "groundModel": {"schema": 1}');
|
||
expect(() => parseProject(text)).toThrow('교각 목록');
|
||
});
|
||
|
||
it('refuses a stored model whose pier values are not numbers or null', () => {
|
||
const m = parseGroundModel(SAMPLE);
|
||
const bad = { ...m, piers: [{ id: 'P1', centerEl: '29.3', excelEl: null }] };
|
||
const text = serializeProject(project({})).replace('"history": []', `"history": [], "groundModel": ${JSON.stringify(bad)}`);
|
||
expect(() => parseProject(text)).toThrow('P1의 값이 맞지 않습니다');
|
||
});
|
||
|
||
it('opens an older file that has no ground model', () => {
|
||
expect(parseProject(serializeProject(project({}))).groundModel).toBeUndefined();
|
||
});
|
||
});
|