213 lines
11 KiB
TypeScript
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;
|
|
}
|