/** * 지반 모델 결과 (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 | 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 | 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 | 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 | 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 | 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[]) { 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[]) { 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; }