feat(ground): draw the terrain grid around the piers in the 3D view
- 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>
This commit is contained in:
1 parent
be76425c8f
commit
bfd41d7c8d
6 files changed
+200
-16
No files matched your search
@@ -30,7 +30,7 @@ describe('지반 모델 읽기 (ground_model.json, schema 1)', () => {
|
||||
});
|
||||
|
||||
it('refuses another schema number instead of guessing the layout', () => {
|
||||
expect(() => parseGroundModel(SAMPLE.replace('"schema":1', '"schema":2'))).toThrow('형식 번호 2');
|
||||
expect(() => parseGroundModel(SAMPLE.replace('"schema":1', '"schema":3'))).toThrow('형식 번호 3');
|
||||
});
|
||||
|
||||
it('refuses a file without a pier list or without pier names', () => {
|
||||
@@ -43,13 +43,59 @@ describe('지반 모델 읽기 (ground_model.json, schema 1)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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(1);
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as THREE from 'three';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import type { TerrainGrid } from '../src/core/ground-model';
|
||||
import { buildPierScene, frameMatrix } from '../src/view/scene';
|
||||
import { terrainGeometry } from '../src/view/terrain';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
const model = buildPier(loadP1());
|
||||
|
||||
function grid(over: Partial<TerrainGrid>): TerrainGrid {
|
||||
return { origin: [259815.676, 179088.094], step: 5, nx: 2, ny: 2, z: [30, 31, 32, 33], ...over };
|
||||
}
|
||||
|
||||
describe('지형 격자 → 장면 메시 (terrain.ts)', () => {
|
||||
it('a node maps to the same scene point as the pier frame (x = E − pivot E, z = −(N − pivot N))', () => {
|
||||
// A national-grid point on the first stretch: origin + 3·u − 2·nrm (the frame used by the pier scene).
|
||||
const st = model.stretches[0];
|
||||
const q = { e: st.origin.e + 3 * st.u.e - 2 * st.nrm.e, n: st.origin.n + 3 * st.u.n - 2 * st.nrm.n };
|
||||
const expected = new THREE.Vector3(3, 0, -2).applyMatrix4(frameMatrix(model));
|
||||
const g = terrainGeometry(grid({ origin: [q.e, q.n], z: [0, 0, 0, 0] }), model.pivot);
|
||||
const p = new THREE.Vector3().fromBufferAttribute(g.getAttribute('position') as THREE.BufferAttribute, 0);
|
||||
expect(p.x).toBeCloseTo(expected.x, 6);
|
||||
expect(p.y).toBeCloseTo(expected.y, 6);
|
||||
expect(p.z).toBeCloseTo(expected.z, 6);
|
||||
});
|
||||
|
||||
it('the grid node at the pier centre sits at the scene origin, with the sampled EL as height', () => {
|
||||
const centre = grid({ origin: [model.pivot.e - 2.5, model.pivot.n - 2.5], step: 5, nx: 2, ny: 2, z: [28.9, 1, 1, 1] });
|
||||
const g = terrainGeometry(centre, model.pivot);
|
||||
const p = new THREE.Vector3().fromBufferAttribute(g.getAttribute('position') as THREE.BufferAttribute, 0);
|
||||
expect(p.x).toBeCloseTo(-2.5, 9);
|
||||
expect(p.z).toBeCloseTo(2.5, 9);
|
||||
expect(p.y).toBeCloseTo(28.9, 5); // stored as Float32
|
||||
});
|
||||
|
||||
it('a cell with a missing corner is not drawn; a full cell gives two triangles', () => {
|
||||
expect(terrainGeometry(grid({ z: [30, null, 32, 33] }), model.pivot).getIndex()!.count).toBe(0);
|
||||
expect(terrainGeometry(grid({}), model.pivot).getIndex()!.count).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('장면에 지형을 넣은 뒤', () => {
|
||||
it('the camera size follows the members, not the extent of the terrain', () => {
|
||||
const small = grid({ origin: [model.pivot.e - 20, model.pivot.n - 20], step: 10, nx: 5, ny: 5, z: new Array(25).fill(28) });
|
||||
const wide = grid({ origin: [model.pivot.e - 200, model.pivot.n - 200], step: 10, nx: 41, ny: 41, z: new Array(41 * 41).fill(28) });
|
||||
const a = buildPierScene(model, small).size;
|
||||
const b = buildPierScene(model, wide).size;
|
||||
expect(b).toBeCloseTo(a, 6);
|
||||
expect(buildPierScene(model, wide).parts.ground.children.length).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user