Files
Pier_v1/src/core/ground-model.ts
T

213 lines
11 KiB
TypeScript

/**
* 지반 모델 결과 (RAG/pierzainer_ground의 ground_model.json, schema 1) 읽기.
* 앱은 교각 중심의 지반고만 쓴다. 적용은 사용자가 누를 때만 하며, 자동으로 입력을 바꾸지 않는다.
*/
/** Must match GROUND_MODEL_SCHEMA in the Python package. Schema 1 (no terrain grid) is still read.
* Legacy files from the early Python tool often omitted the schema field entirely; when the
* structure is otherwise a valid ground_model, we accept them as the current format.
*/
export const GROUND_MODEL_SCHEMA = 2;
const READABLE_SCHEMAS = [1, GROUND_MODEL_SCHEMA];
function schemaOrLegacy(raw: Partial<GroundModelFile> | null): number | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const schema = (raw as { schema?: unknown }).schema;
if (schema === undefined) {
const pierList = (raw as { piers?: unknown }).piers;
const looksLikeLegacyGroundModel = Array.isArray(pierList)
&& pierList.every((pier) => {
const item = pier as { id?: unknown; ground?: unknown } | null;
return !!item && typeof item.id === 'string' && item.ground !== undefined && typeof item.ground === 'object';
});
return looksLikeLegacyGroundModel ? GROUND_MODEL_SCHEMA : undefined;
}
return typeof schema === 'number' ? schema : undefined;
}
/** Terrain sampled on a regular grid (JSON X · Y, m; EL m). z runs over X/Y rows; the scene swaps the axes before drawing. */
export interface TerrainGrid {
origin: [number, number];
step: number;
nx: number;
ny: number;
z: (number | null)[];
}
/** A map image georeferenced by its SW, SE, NW, NE survey-coordinate corners (JSON X, Y; the renderer swaps X/Y into the scene). */
export interface GroundMapLayer {
id: 'ground' | 'satellite';
name: string;
mime_type: 'image/png' | 'image/jpeg';
image: string;
survey_corners: [[number, number], [number, number], [number, number], [number, number]];
}
export interface GroundModelPier {
/** 교각 이름 (입력의 name, 예: P1). */
id: string;
/** 지형 DTM으로 구한 교각 중심 지반고 (m). 자료 밖이면 null. */
centerEl: number | null;
/** 엑셀 지반고 (m). 없으면 null. */
excelEl: number | null;
/** 지층 기둥 (시추 자료가 있을 때): 위에서 아래, 각 층의 하단 EL (m). Absent = no soil column. */
layers?: { name: string; bottomEl: number }[];
}
export interface GroundModelFile {
schema: number;
generated: string;
piers: GroundModelPier[];
/** 교각 주변 지형 격자 (schema 2). Absent in schema 1 files. */
terrainGrid?: TerrainGrid;
/** Embedded 2D and satellite imagery for 3D location context, placed below the terrain surface. */
mapLayers?: GroundMapLayer[];
}
function numOrNull(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null;
}
/** Checks the shape of a parsed ground_model.json. Throws with the first problem named in Korean. */
export function checkGroundModel(raw: unknown): GroundModelFile {
const obj = raw as Partial<GroundModelFile> | null;
if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.');
const knownSchema = schemaOrLegacy(obj);
if (knownSchema === undefined) {
throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`);
}
if (!READABLE_SCHEMAS.includes(knownSchema as number)) {
throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`);
}
if (!Array.isArray(obj.piers)) throw new Error('지반 모델 파일에 교각 목록(piers)이 없습니다.');
const piers = obj.piers.map((p, i) => {
const id = (p as { id?: unknown })?.id;
if (typeof id !== 'string' || !id) throw new Error(`지반 모델 ${i + 1}번째 교각의 이름(id)이 없습니다.`);
const ground = (p as { ground?: { center?: unknown; excel?: unknown } }).ground;
if (!ground || typeof ground !== 'object') throw new Error(`지반 모델 ${id}: 지반 값(ground)이 없습니다.`);
const soil = (p as { soil?: { layers?: unknown } | null }).soil;
const layers = soil && soil.layers !== undefined ? checkRawLayers(soil.layers, id) : undefined;
return { id, centerEl: numOrNull(ground.center), excelEl: numOrNull(ground.excel), ...(layers ? { layers } : {}) };
});
const rawGrid = (raw as { terrain_grid?: unknown }).terrain_grid;
const terrainGrid = rawGrid === undefined ? undefined : checkTerrainGrid(rawGrid);
const rawLayers = (raw as { visualization?: { layers?: unknown } }).visualization?.layers;
const mapLayers = rawLayers === undefined ? undefined : checkGroundMapLayers(rawLayers);
return {
schema: GROUND_MODEL_SCHEMA,
generated: typeof obj.generated === 'string' ? obj.generated : '',
piers,
...(terrainGrid ? { terrainGrid } : {}),
...(mapLayers ? { mapLayers } : {}),
};
}
function checkGroundMapLayers(raw: unknown): GroundMapLayer[] {
if (!Array.isArray(raw)) throw new Error('지도 레이어(visualization.layers) 형식이 맞지 않습니다.');
const layers = raw.filter((layer) => ['ground', 'satellite'].includes((layer as { id?: unknown } | null)?.id as string));
if (layers.length === 0) return [];
if (layers.every((layer) => !('survey_corners' in (layer as object)))) return [];
const parsed = layers.map((layer) => {
const item = layer as Partial<GroundMapLayer> | null;
const id = item?.id;
const image = item?.image;
const mimeType = item?.mime_type;
const corners = item?.survey_corners;
if ((id !== 'ground' && id !== 'satellite')
|| typeof image !== 'string'
|| !/^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+={0,2}$/.test(image)
|| (mimeType !== 'image/png' && mimeType !== 'image/jpeg')
|| !Array.isArray(corners)
|| corners.length !== 4
|| !corners.every((point) => Array.isArray(point) && point.length === 2 && point.every((value) => typeof value === 'number' && Number.isFinite(value)))) {
throw new Error(`지도 레이어 ${String(item?.id)} 이미지 또는 측량 좌표 범위가 올바르지 않습니다.`);
}
return {
id,
name: typeof item?.name === 'string' ? item.name : id,
mime_type: mimeType,
image,
survey_corners: corners as GroundMapLayer['survey_corners'],
};
});
if (!parsed.some((layer) => layer.id === 'ground') || !parsed.some((layer) => layer.id === 'satellite')) {
throw new Error('3D 위치 지도에는 2D 지도와 위성사진이 모두 필요합니다.');
}
if (new Set(parsed.map((layer) => layer.id)).size !== 2) throw new Error('지도 레이어 ID가 중복되었습니다.');
return parsed;
}
/** Layers as written by the Python package: {name, top_el, bottom_el}, top to bottom. */
function checkRawLayers(raw: unknown, id: string): { name: string; bottomEl: number }[] {
if (!Array.isArray(raw)) throw new Error(`지반 모델 ${id}: 지층(soil.layers) 형식이 맞지 않습니다.`);
return raw.map((l) => {
const r = l as { name?: unknown; bottom_el?: unknown };
if (typeof r.name !== 'string' || typeof r.bottom_el !== 'number' || !Number.isFinite(r.bottom_el)) {
throw new Error(`지반 모델 ${id}: 지층 이름 또는 하단 EL이 맞지 않습니다.`);
}
return { name: r.name, bottomEl: r.bottom_el };
});
}
/** The soil column of a pier as strata rows (위에서 아래), or undefined when the model has none for it. */
export function strataRowsFor(model: GroundModelFile, pierName: string): { name: string; bottomEl: number }[] | undefined {
return model.piers.find((p) => p.id === pierName)?.layers;
}
/** 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)[] };
}
/**
* Checks the normalised model stored in a project file (the form checkGroundModel returns). The project keeps this
* form, not the raw ground_model.json, so it is checked separately.
*/
export function checkStoredGroundModel(raw: unknown): GroundModelFile {
const obj = raw as Partial<GroundModelFile> | null;
if (!obj || typeof obj !== 'object') throw new Error('프로젝트 안의 지반 모델 형식 번호가 맞지 않습니다.');
const knownSchema = schemaOrLegacy(obj);
if (knownSchema === undefined || !READABLE_SCHEMAS.includes(knownSchema)) {
throw new Error('프로젝트 안의 지반 모델 형식 번호가 맞지 않습니다.');
}
if (!Array.isArray(obj.piers)) throw new Error('프로젝트 안의 지반 모델에 교각 목록이 없습니다.');
for (const p of obj.piers as Partial<GroundModelPier>[]) {
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);
if (obj.mapLayers !== undefined) checkGroundMapLayers(obj.mapLayers);
for (const p of obj.piers as Partial<GroundModelPier>[]) {
if (p.layers === undefined) continue;
const ok = Array.isArray(p.layers) && p.layers.every((l) => typeof l?.name === 'string' && typeof l?.bottomEl === 'number' && Number.isFinite(l.bottomEl));
if (!ok) throw new Error(`프로젝트 안의 지반 모델 교각 ${String(p.id)}의 지층 값이 맞지 않습니다.`);
}
return obj as GroundModelFile;
}
/** Parses the file text. Throws when it is not JSON or does not match the schema. */
export function parseGroundModel(text: string): GroundModelFile {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw new Error('지반 모델 파일이 JSON으로 읽히지 않습니다.');
}
return checkGroundModel(raw);
}
/** The DTM ground EL at the pier centre, or undefined when the model has no value for it. */
export function groundElFor(model: GroundModelFile, pierName: string): number | undefined {
return model.piers.find((p) => p.id === pierName)?.centerEl ?? undefined;
}