From bfd41d7c8db741347c9ec02ac7354a14750d37c4 Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 23 Oct 2026 17:57:30 +0900 Subject: [PATCH] feat(ground): draw the terrain grid around the piers in the 3D view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/core/ground-model.ts | 47 +++++++++++++++++++++++++++++----- src/main.ts | 3 ++- src/view/scene.ts | 27 +++++++++++++++----- src/view/terrain.ts | 37 +++++++++++++++++++++++++++ tests/ground-model.test.ts | 50 ++++++++++++++++++++++++++++++++++-- tests/terrain.test.ts | 52 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 16 deletions(-) create mode 100644 src/view/terrain.ts create mode 100644 tests/terrain.test.ts diff --git a/src/core/ground-model.ts b/src/core/ground-model.ts index f3f21a2..2780f23 100644 --- a/src/core/ground-model.ts +++ b/src/core/ground-model.ts @@ -3,8 +3,18 @@ * 앱은 교각 중심의 지반고만 쓴다. 적용은 사용자가 누를 때만 하며, 자동으로 입력을 바꾸지 않는다. */ -/** Must match GROUND_MODEL_SCHEMA in the Python package. */ -export const GROUND_MODEL_SCHEMA = 1; +/** Must match GROUND_MODEL_SCHEMA in the Python package. Schema 1 (no terrain grid) is still read. */ +export const GROUND_MODEL_SCHEMA = 2; +const READABLE_SCHEMAS = [1, GROUND_MODEL_SCHEMA]; + +/** Terrain sampled on a regular grid (측량 X · Y, m; EL m). z runs over Y rows, X columns; null = no data. */ +export interface TerrainGrid { + origin: [number, number]; + step: number; + nx: number; + ny: number; + z: (number | null)[]; +} export interface GroundModelPier { /** 교각 이름 (입력의 name, 예: P1). */ @@ -19,6 +29,8 @@ export interface GroundModelFile { schema: number; generated: string; piers: GroundModelPier[]; + /** 교각 주변 지형 격자 (schema 2). Absent in schema 1 files. */ + terrainGrid?: TerrainGrid; } function numOrNull(v: unknown): number | null { @@ -34,8 +46,8 @@ function numOrNull(v: unknown): number | null { export function checkGroundModel(raw: unknown): GroundModelFile { const obj = raw as Partial | null; if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.'); - if (obj.schema !== undefined && obj.schema !== GROUND_MODEL_SCHEMA) { - throw new Error(`지반 모델 형식 번호 ${String(obj.schema)}을(를) 읽을 수 없습니다 (지원: ${GROUND_MODEL_SCHEMA}).`); + if (obj.schema !== undefined && !READABLE_SCHEMAS.includes(obj.schema)) { + throw new Error(`지반 모델 형식 번호 ${String(obj.schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`); } if (!Array.isArray(obj.piers)) throw new Error('지반 모델 파일에 교각 목록(piers)이 없습니다.'); const piers = obj.piers.map((p, i) => { @@ -45,7 +57,29 @@ export function checkGroundModel(raw: unknown): GroundModelFile { if (!ground || typeof ground !== 'object') throw new Error(`지반 모델 ${id}: 지반 값(ground)이 없습니다.`); return { id, centerEl: numOrNull(ground.center), excelEl: numOrNull(ground.excel) }; }); - return { schema: GROUND_MODEL_SCHEMA, generated: typeof obj.generated === 'string' ? obj.generated : '', piers }; + const rawGrid = (raw as { terrain_grid?: unknown }).terrain_grid; + const terrainGrid = rawGrid === undefined ? undefined : checkTerrainGrid(rawGrid); + return { + schema: GROUND_MODEL_SCHEMA, + generated: typeof obj.generated === 'string' ? obj.generated : '', + piers, + ...(terrainGrid ? { terrainGrid } : {}), + }; +} + +/** Checks a terrain grid: origin, step, the size, and one value (or null) per node. */ +export function checkTerrainGrid(raw: unknown): TerrainGrid { + const g = raw as Partial | null; + const finite = (v: unknown) => typeof v === 'number' && Number.isFinite(v); + if (!g || typeof g !== 'object') throw new Error('지형 격자(terrain_grid) 형식이 맞지 않습니다.'); + if (!Array.isArray(g.origin) || g.origin.length !== 2 || !g.origin.every(finite)) throw new Error('지형 격자의 기준점(origin)이 맞지 않습니다.'); + if (!finite(g.step) || !(g.step as number > 0)) throw new Error('지형 격자 간격(step)이 맞지 않습니다.'); + if (!Number.isInteger(g.nx) || !Number.isInteger(g.ny) || (g.nx as number) < 2 || (g.ny as number) < 2) { + throw new Error('지형 격자 크기(nx, ny)가 맞지 않습니다.'); + } + if (!Array.isArray(g.z) || g.z.length !== (g.nx as number) * (g.ny as number)) throw new Error('지형 격자 값의 개수가 nx × ny와 다릅니다.'); + if (!g.z.every((v) => v === null || finite(v))) throw new Error('지형 격자 값에 숫자가 아닌 것이 있습니다.'); + return { origin: [g.origin[0], g.origin[1]], step: g.step as number, nx: g.nx as number, ny: g.ny as number, z: g.z as (number | null)[] }; } /** @@ -54,7 +88,7 @@ export function checkGroundModel(raw: unknown): GroundModelFile { */ export function checkStoredGroundModel(raw: unknown): GroundModelFile { const obj = raw as Partial | null; - if (!obj || typeof obj !== 'object' || obj.schema !== GROUND_MODEL_SCHEMA) { + if (!obj || typeof obj !== 'object' || !READABLE_SCHEMAS.includes(obj.schema as number)) { throw new Error('프로젝트 안의 지반 모델 형식 번호가 맞지 않습니다.'); } if (!Array.isArray(obj.piers)) throw new Error('프로젝트 안의 지반 모델에 교각 목록이 없습니다.'); @@ -62,6 +96,7 @@ export function checkStoredGroundModel(raw: unknown): GroundModelFile { const ok = typeof p?.id === 'string' && (p.centerEl === null || typeof p.centerEl === 'number') && (p.excelEl === null || typeof p.excelEl === 'number'); if (!ok) throw new Error(`프로젝트 안의 지반 모델 교각 ${String(p?.id)}의 값이 맞지 않습니다.`); } + if (obj.terrainGrid !== undefined) checkTerrainGrid(obj.terrainGrid); return obj as GroundModelFile; } diff --git a/src/main.ts b/src/main.ts index 4e30e4f..90d8678 100644 --- a/src/main.ts +++ b/src/main.ts @@ -317,7 +317,7 @@ function setupScene(): void { rebar3d.group.visible = state.stage === 'rebar'; rebar3d.setColorMode(state.rebarColor); world.add(rebar3d.group); - const scene = buildPierScene(pier.model); + const scene = buildPierScene(pier.model, groundModel?.terrainGrid); for (const key of Object.keys(state.visible) as PartName[]) { scene.parts[key].visible = state.visible[key]; } @@ -1512,6 +1512,7 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void { try { groundModel = parseGroundModel(await file.text()); setStatus(`${file.name}에서 교각 ${groundModel.piers.length}개의 지반 모델 값을 불러왔습니다. 적용은 교각별로 누르세요.`); + setupScene(); renderAll(); } catch (err) { setError(`지반 모델을 읽지 못했습니다: ${(err as Error).message}`); diff --git a/src/view/scene.ts b/src/view/scene.ts index 4c7d4a2..c2b6345 100644 --- a/src/view/scene.ts +++ b/src/view/scene.ts @@ -1,6 +1,8 @@ import * as THREE from 'three'; import type { PierModel } from '../core/geometry'; import { solplateGeometry } from '../core/solplate'; +import type { TerrainGrid } from '../core/ground-model'; +import { terrainGeometry } from './terrain'; export type PartName = 'coping' | 'columns' | 'footings' | 'piles' | 'blinding' | 'blocks' | 'pads' | 'bearings' | 'solplates' | 'ground'; @@ -73,7 +75,7 @@ export interface PierScene { size: number; } -export function buildPierScene(m: PierModel): PierScene { +export function buildPierScene(m: PierModel, terrain?: TerrainGrid): PierScene { const Ms = m.stretches.map((_, k) => stretchMatrix(m, k)); const parts = {} as Record; for (const key of Object.keys(PART_LABELS) as PartName[]) { @@ -153,15 +155,26 @@ export function buildPierScene(m: PierModel): PierScene { } } - // Ground: a translucent plane at the ground EL, used only to judge soil cover (not a computed input). - const ground = new THREE.PlaneGeometry(60, 60); - ground.rotateX(-Math.PI / 2); - ground.translate(m.copingL / 2, m.groundEl, 0); - add('ground', ground, 0.35); + // Ground: the terrain grid around the piers when the ground model has one (수 m 떨어진 지형까지 보임), + // otherwise a translucent plane at the ground EL, used only to judge soil cover (not a computed input). + if (terrain) { + const material = new THREE.MeshStandardMaterial({ color: COLORS.ground, roughness: 0.9, metalness: 0, transparent: true, opacity: 0.35, side: THREE.DoubleSide }); + material.userData.baseOpacity = 0.35; + parts.ground.add(new THREE.Mesh(terrainGeometry(terrain, m.pivot), material)); + } else { + const ground = new THREE.PlaneGeometry(60, 60); + ground.rotateX(-Math.PI / 2); + ground.translate(m.copingL / 2, m.groundEl, 0); + add('ground', ground, 0.35); + } const root = new THREE.Group(); for (const key of Object.keys(parts) as PartName[]) root.add(parts[key]); - const box = new THREE.Box3().setFromObject(root); + // The camera fits the pier, not the terrain: with a terrain grid the size comes from the members only. + const box = new THREE.Box3(); + for (const key of Object.keys(parts) as PartName[]) { + if (key !== 'ground' || !terrain) box.expandByObject(parts[key]); + } const size = Math.max(...box.getSize(new THREE.Vector3()).toArray()); return { root, parts, size }; } diff --git a/src/view/terrain.ts b/src/view/terrain.ts new file mode 100644 index 0000000..864768f --- /dev/null +++ b/src/view/terrain.ts @@ -0,0 +1,37 @@ +import * as THREE from 'three'; +import type { TerrainGrid } from '../core/ground-model'; + +/** + * Terrain grid → mesh in scene coordinates. The scene origin is the pier centre (pivot), with y up: + * x = X − pivot.e, y = EL, z = −(Y − pivot.n). This is the straight-frame matrix of the pier scene (stretchMatrix, k = 0), + * so the terrain lines up with the footings and copings. A cell is drawn only when its four corners have data. + */ +export function terrainGeometry(grid: TerrainGrid, pivot: { e: number; n: number }): THREE.BufferGeometry { + const { origin, step, nx, ny, z } = grid; + const positions: number[] = []; + const index: number[] = []; + const vertexOf = new Int32Array(nx * ny).fill(-1); + for (let j = 0; j < ny; j++) { + for (let i = 0; i < nx; i++) { + const h = z[j * nx + i]; + if (h === null) continue; + vertexOf[j * nx + i] = positions.length / 3; + positions.push(origin[0] + i * step - pivot.e, h, -(origin[1] + j * step - pivot.n)); + } + } + for (let j = 0; j < ny - 1; j++) { + for (let i = 0; i < nx - 1; i++) { + const a = vertexOf[j * nx + i]; + const b = vertexOf[j * nx + i + 1]; + const c = vertexOf[(j + 1) * nx + i]; + const d = vertexOf[(j + 1) * nx + i + 1]; + if (a < 0 || b < 0 || c < 0 || d < 0) continue; + index.push(a, c, b, b, c, d); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geometry.setIndex(index); + geometry.computeVertexNormals(); + return geometry; +} diff --git a/tests/ground-model.test.ts b/tests/ground-model.test.ts index a4e076c..a75b337 100644 --- a/tests/ground-model.test.ts +++ b/tests/ground-model.test.ts @@ -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); diff --git a/tests/terrain.test.ts b/tests/terrain.test.ts new file mode 100644 index 0000000..eef594c --- /dev/null +++ b/tests/terrain.test.ts @@ -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 { + 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); + }); +});