feat(ground): accept ground_model.json written before the schema number

- A file with no `schema` key is read as schema 1, but every pier must have a
  `ground` object, so a renamed field is refused instead of loading as empty.
- `schema` null or any other number is still refused.
- The project file keeps the normalised model and checks it with its own
  check (checkStoredGroundModel), so a saved model reopens.
- Temporary: remove the no-schema branch once examples/out is regenerated.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-23 17:41:24 +09:00
1 parent c3606011b5
commit be76425c8f
4 files changed
+453 -6

No files matched your search

+27 -4
View File
@@ -25,21 +25,44 @@ 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. */
/**
* Checks the shape of a parsed ground_model.json. Throws with the first problem named in Korean.
* A file with no `schema` key is the layout written before the number was added: it is read as schema 1, and the
* `ground` object is required so that a renamed field is refused instead of loading as empty values.
* TODO: remove the no-schema branch once the 옛 파일 (examples/out) is regenerated with schema 1.
*/
export function checkGroundModel(raw: unknown): GroundModelFile {
const obj = raw as Partial<GroundModelFile> | null;
if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.');
if (obj.schema !== GROUND_MODEL_SCHEMA) {
if (obj.schema !== undefined && obj.schema !== GROUND_MODEL_SCHEMA) {
throw new Error(`지반 모델 형식 번호 ${String(obj.schema)}을(를) 읽을 수 없습니다 (지원: ${GROUND_MODEL_SCHEMA}).`);
}
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 ?? {};
const ground = (p as { ground?: { center?: unknown; excel?: unknown } }).ground;
if (!ground || typeof ground !== 'object') throw new Error(`지반 모델 ${id}: 지반 값(ground)이 없습니다.`);
return { id, centerEl: numOrNull(ground.center), excelEl: numOrNull(ground.excel) };
});
return { schema: obj.schema, generated: typeof obj.generated === 'string' ? obj.generated : '', piers };
return { schema: GROUND_MODEL_SCHEMA, generated: typeof obj.generated === 'string' ? obj.generated : '', piers };
}
/**
* 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' || obj.schema !== GROUND_MODEL_SCHEMA) {
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)}의 값이 맞지 않습니다.`);
}
return obj as GroundModelFile;
}
/** Parses the file text. Throws when it is not JSON or does not match the schema. */
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FckByMember, Material } from './rebar/common';
import { checkGroundModel, type GroundModelFile } from './ground-model';
import { checkStoredGroundModel, type GroundModelFile } from './ground-model';
import type { PierInput } from './types';
/** Project file schema (spec §8). Bump only with a migration. */
@@ -71,7 +71,7 @@ export function parseProject(text: string): ProjectFile {
}
}
}
if (obj.groundModel !== undefined) checkGroundModel(obj.groundModel);
if (obj.groundModel !== undefined) checkStoredGroundModel(obj.groundModel);
obj.piers.forEach((p, i) => {
if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`);
const input = p.input;
+393
View File
@@ -0,0 +1,393 @@
{
"generated": "2026-10-23",
"coord": "측량 X(북) · Y(동), m. 치수 mm",
"terrain": {
"file": "C0050202-001.dxf",
"spot_blocks": 433,
"spot_texts": 190,
"contours": 67,
"line3d_vertices": 0,
"points_used": 3429,
"by_source": {
"spot": 431,
"text": 5,
"contour": 2993,
"line3d": 0
},
"removed": 1,
"boreholes": 18,
"extent": [
259779.6342279386,
179066.3432863709,
260208.0132279157,
179261.3784074617
]
},
"removed_points": [
{
"X": 260155.762358268,
"Y": 179189.228890184,
"Z": -18.115,
"source": "spot",
"reason": "EL 범위 밖 (0.0–200.0)"
}
],
"boreholes_dxf": [
{
"id": "BB-24",
"X": 260110.5200305235,
"Y": 179142.87077588285
},
{
"id": "BB-28",
"X": 259832.7395636612,
"Y": 179078.6772609205
},
{
"id": "NBB-21",
"X": 260169.7415555437,
"Y": 179217.2592038325
},
{
"id": "NBB-22",
"X": 260119.0843895569,
"Y": 179207.447663092
},
{
"id": "NBB-23",
"X": 260063.7209158014,
"Y": 179200.7170868495
},
{
"id": "NBB-24",
"X": 260013.7365954409,
"Y": 179193.2691158374
},
{
"id": "NBB-25",
"X": 259959.8230206913,
"Y": 179182.585023065
},
{
"id": "NBB-26",
"X": 259908.1691879086,
"Y": 179170.4141072329
},
{
"id": "NBB-27",
"X": 259862.0303893618,
"Y": 179162.6491102049
},
{
"id": "NBB-28",
"X": 259804.6978413376,
"Y": 179143.6022597326
},
{
"id": "NBB-29",
"X": 260162.3349768046,
"Y": 179177.2095564581
},
{
"id": "NBB-30",
"X": 260115.11201831407,
"Y": 179163.3224655297
},
{
"id": "NBB-31",
"X": 260052.92384203407,
"Y": 179154.4466218247
},
{
"id": "NBB-32",
"X": 260004.4464295526,
"Y": 179155.3905176037
},
{
"id": "NBB-33",
"X": 259953.9248062317,
"Y": 179147.6435384383
},
{
"id": "NBB-34",
"X": 259903.7958026133,
"Y": 179135.2767765673
},
{
"id": "NBB-35",
"X": 259855.9217242609,
"Y": 179124.5131579286
},
{
"id": "NBB-36",
"X": 259804.9274191266,
"Y": 179111.1866481418
}
],
"borehole_issues": [],
"piers": [
{
"id": "P1",
"X": 259865.676,
"Y": 179138.094,
"axis_dir": [
0.16722,
-0.98592
],
"ground": {
"center": 29.355,
"corners": [
29.536,
29.441,
29.18,
29.27
],
"min": 29.18,
"max": 29.536,
"used": 29.355,
"source": "계산",
"dtm_spacing": 19.0,
"excel": 28.931,
"diff_vs_excel": 0.424
},
"footing": {
"L_mm": 10400.0,
"W_mm": 8150.0,
"top_el": 28.208,
"bottom_el": 26.208,
"cover": 0.972
},
"pile": {
"tip_el": 2.208,
"d_mm": 609.6
},
"soil": null,
"alerts": [
{
"code": "W09",
"level": "경고",
"pier": "P1",
"message": "엑셀 지반고 28.931와 지형 29.355의 차이 +0.424 m가 허용 0.3 m를 넘습니다."
},
{
"code": "I05",
"level": "정보",
"pier": "P1",
"message": "시추 자료가 없어 지층을 판정하지 않았습니다."
}
]
},
{
"id": "P2",
"X": 259914.439,
"Y": 179149.112,
"axis_dir": [
0.167151,
-0.985931
],
"ground": {
"center": 28.167,
"corners": [],
"min": 28.167,
"max": 28.167,
"used": 28.167,
"source": "계산",
"dtm_spacing": 19.5,
"excel": null,
"diff_vs_excel": null
},
"footing": {
"L_mm": null,
"W_mm": null,
"top_el": null,
"bottom_el": null,
"cover": null
},
"pile": {
"tip_el": null,
"d_mm": null
},
"soil": null,
"alerts": [
{
"code": "I05",
"level": "정보",
"pier": "P2",
"message": "시추 자료가 없어 지층을 판정하지 않았습니다."
}
]
},
{
"id": "P3",
"X": 259963.44,
"Y": 179159.017,
"axis_dir": [
0.167117,
-0.985937
],
"ground": {
"center": 28.12,
"corners": [],
"min": 28.12,
"max": 28.12,
"used": 28.12,
"source": "계산",
"dtm_spacing": 48.6,
"excel": null,
"diff_vs_excel": null
},
"footing": {
"L_mm": null,
"W_mm": null,
"top_el": null,
"bottom_el": null,
"cover": null
},
"pile": {
"tip_el": null,
"d_mm": null
},
"soil": null,
"alerts": [
{
"code": "I07",
"level": "정보",
"pier": "P3",
"message": "지반고를 만든 지형 점이 최대 48.6 m 떨어져 있습니다 (기준 30 m). 현장 측량값을 확인하세요."
},
{
"code": "I05",
"level": "정보",
"pier": "P3",
"message": "시추 자료가 없어 지층을 판정하지 않았습니다."
}
]
},
{
"id": "P4",
"X": 260012.654,
"Y": 179167.802,
"axis_dir": [
0.167117,
-0.985937
],
"ground": {
"center": 28.608,
"corners": [],
"min": 28.608,
"max": 28.608,
"used": 28.608,
"source": "계산",
"dtm_spacing": 44.3,
"excel": null,
"diff_vs_excel": null
},
"footing": {
"L_mm": null,
"W_mm": null,
"top_el": null,
"bottom_el": null,
"cover": null
},
"pile": {
"tip_el": null,
"d_mm": null
},
"soil": null,
"alerts": [
{
"code": "I07",
"level": "정보",
"pier": "P4",
"message": "지반고를 만든 지형 점이 최대 44.3 m 떨어져 있습니다 (기준 30 m). 현장 측량값을 확인하세요."
},
{
"code": "I05",
"level": "정보",
"pier": "P4",
"message": "시추 자료가 없어 지층을 판정하지 않았습니다."
}
]
},
{
"id": "P5",
"X": 260062.055,
"Y": 179175.464,
"axis_dir": [
0.1671,
-0.98594
],
"ground": {
"center": 27.242,
"corners": [],
"min": 27.242,
"max": 27.242,
"used": 27.242,
"source": "계산",
"dtm_spacing": 2.7,
"excel": null,
"diff_vs_excel": null
},
"footing": {
"L_mm": null,
"W_mm": null,
"top_el": null,
"bottom_el": null,
"cover": null
},
"pile": {
"tip_el": null,
"d_mm": null
},
"soil": null,
"alerts": [
{
"code": "I05",
"level": "정보",
"pier": "P5",
"message": "시추 자료가 없어 지층을 판정하지 않았습니다."
}
]
},
{
"id": "P6",
"X": 260116.582,
"Y": 179182.59,
"axis_dir": [
0.167062,
-0.985946
],
"ground": {
"center": 28.766,
"corners": [],
"min": 28.766,
"max": 28.766,
"used": 28.766,
"source": "계산",
"dtm_spacing": 14.3,
"excel": null,
"diff_vs_excel": null
},
"footing": {
"L_mm": null,
"W_mm": null,
"top_el": null,
"bottom_el": null,
"cover": null
},
"pile": {
"tip_el": null,
"d_mm": null
},
"soil": null,
"alerts": [
{
"code": "I05",
"level": "정보",
"pier": "P6",
"message": "시추 자료가 없어 지층을 판정하지 않았습니다."
}
]
}
]
}
+31
View File
@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { groundElFor, parseGroundModel } from '../src/core/ground-model';
import { changedKeys, overrideKeys, revertDraft, type PierValues } from '../src/core/changes';
@@ -42,6 +43,29 @@ describe('지반 모델 읽기 (ground_model.json, schema 1)', () => {
});
});
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.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);
});
it('refuses a pier that has no ground object (a renamed field must not load as empty values)', () => {
const data = JSON.parse(LEGACY);
delete data.piers[1].ground;
expect(() => parseGroundModel(JSON.stringify(data))).toThrow('P2: 지반 값(ground)이 없습니다');
});
it('refuses a null schema number instead of treating it as missing', () => {
expect(() => parseGroundModel(LEGACY.replace('{', '{"schema": null,'))).toThrow('형식 번호 null');
});
});
describe('지반고 적용 (사용자가 누를 때만, 변경으로 기록)', () => {
const base = loadP1();
const pier: PierValues = { defaults: base, input: base, draft: { ...base, groundEl: 29.355 } };
@@ -80,6 +104,13 @@ describe('프로젝트 파일의 지반 모델', () => {
expect(() => parseProject(text)).toThrow('교각 목록');
});
it('refuses a stored model whose pier values are not numbers or null', () => {
const m = parseGroundModel(SAMPLE);
const bad = { ...m, piers: [{ id: 'P1', centerEl: '29.3', excelEl: null }] };
const text = serializeProject(project({})).replace('"history": []', `"history": [], "groundModel": ${JSON.stringify(bad)}`);
expect(() => parseProject(text)).toThrow('P1의 값이 맞지 않습니다');
});
it('opens an older file that has no ground model', () => {
expect(parseProject(serializeProject(project({}))).groundModel).toBeUndefined();
});