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:
ypshimandClaude Haiku 5.5 committed 2026-10-23 17:57:30 +09:00
1 parent be76425c8f
commit bfd41d7c8d
6 files changed
+200 -16

No files matched your search

+41 -6
View File
@@ -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<GroundModelFile> | 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<TerrainGrid> | 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<GroundModelFile> | 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;
}
+2 -1
View File
@@ -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}`);
+20 -7
View File
@@ -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<PartName, THREE.Group>;
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 };
}
+37
View File
@@ -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;
}