From c3606011b59a689a64c515ca29c2370f6e6d6b7d Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 23 Oct 2026 16:48:27 +0900 Subject: [PATCH] feat(ground): load ground_model.json and apply the pier-centre ground EL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ground-model.ts: read and check schema 1 of the Python ground model. - ② 지반·지층 card: import the json, show the DTM value beside the excel value, and apply it only when the user presses the button (tracked as an override, revertible). - groundEl becomes an editable, tracked input; the project file keeps the imported model and checks it on open. Co-Authored-By: Claude Haiku 5.5 --- src/core/changes.ts | 2 + src/core/ground-model.ts | 59 ++++++++++++++++++++++++++ src/core/project.ts | 4 ++ src/main.ts | 58 ++++++++++++++++++++++++- tests/ground-model.test.ts | 86 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 src/core/ground-model.ts create mode 100644 tests/ground-model.test.ts diff --git a/src/core/changes.ts b/src/core/changes.ts index ca20510..8f9cc34 100644 --- a/src/core/changes.ts +++ b/src/core/changes.ts @@ -19,6 +19,7 @@ export const EDITABLE_FIELDS = [ { key: 'copingH2Mm', label: '테이퍼 높이 h2', unit: 'mm' }, { key: 'levelWidthMm', label: 'Level 폭', unit: 'mm' }, { key: 'pileLengthMm', label: '말뚝 길이', unit: 'mm' }, + { key: 'groundEl', label: '지반고 EL', unit: 'm' }, ] as const; /** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */ @@ -41,6 +42,7 @@ export const FIELD_LABELS: Record = { copingH2Mm: '테이퍼 높이 h2', levelWidthMm: 'Level 폭', pileLengthMm: '말뚝 길이', + groundEl: '지반고 EL', pileRows: '열별 본수', }; diff --git a/src/core/ground-model.ts b/src/core/ground-model.ts new file mode 100644 index 0000000..8ed7b0d --- /dev/null +++ b/src/core/ground-model.ts @@ -0,0 +1,59 @@ +/** + * 지반 모델 결과 (RAG/pierzainer_ground의 ground_model.json, schema 1) 읽기. + * 앱은 교각 중심의 지반고만 쓴다. 적용은 사용자가 누를 때만 하며, 자동으로 입력을 바꾸지 않는다. + */ + +/** Must match GROUND_MODEL_SCHEMA in the Python package. */ +export const GROUND_MODEL_SCHEMA = 1; + +export interface GroundModelPier { + /** 교각 이름 (입력의 name, 예: P1). */ + id: string; + /** 지형 DTM으로 구한 교각 중심 지반고 (m). 자료 밖이면 null. */ + centerEl: number | null; + /** 엑셀 지반고 (m). 없으면 null. */ + excelEl: number | null; +} + +export interface GroundModelFile { + schema: number; + generated: string; + piers: GroundModelPier[]; +} + +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('지반 모델 파일 형식이 아닙니다.'); + if (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 ?? {}; + return { id, centerEl: numOrNull(ground.center), excelEl: numOrNull(ground.excel) }; + }); + return { schema: obj.schema, generated: typeof obj.generated === 'string' ? obj.generated : '', piers }; +} + +/** 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; +} diff --git a/src/core/project.ts b/src/core/project.ts index 241a577..5c28599 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -1,4 +1,5 @@ import type { FckByMember, Material } from './rebar/common'; +import { checkGroundModel, type GroundModelFile } from './ground-model'; import type { PierInput } from './types'; /** Project file schema (spec §8). Bump only with a migration. */ @@ -37,6 +38,8 @@ export interface ProjectFile { bearingDrawing?: Record; /** ② 부재별 fck (MPa). Absent in older files: every member uses the 공통 fck. */ fckByMember?: FckByMember; + /** 지반 모델 (ground_model.json)의 교각별 지반고. Absent = 불러오지 않음. Applied to a pier only by the user. */ + groundModel?: GroundModelFile; } /** Serialises a project to the pretty-printed JSON stored in <교량명>_교각설계.pzr.json. */ @@ -68,6 +71,7 @@ export function parseProject(text: string): ProjectFile { } } } + if (obj.groundModel !== undefined) checkGroundModel(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; diff --git a/src/main.ts b/src/main.ts index 175d1f7..4e30e4f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common'; import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity'; import { parseDrawingRows } from './core/drawing-import'; +import { groundElFor, parseGroundModel, type GroundModelFile } from './core/ground-model'; import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table'; import { columnDiameterIssues } from './core/column-check'; import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec'; @@ -29,6 +30,9 @@ let bearingRules: BearingRules = { ...BEARING_RULES_DEFAULT }; /** 받침 기준 for the project (Q16, 승인 2026-10-23): 'excel' by default. Drawing values per pier name. */ let bearingBasis: BearingBasis = 'excel'; let bearingDrawing: Record = {}; + +/** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */ +let groundModel: GroundModelFile | null = null; import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection'; import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options'; import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project'; @@ -74,7 +78,7 @@ const MEMBERS: { id: MemberId; label: string; keys: EditableKey[]; note: string { id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm', 'copingH1Mm', 'copingH2Mm', 'levelWidthMm'], note: '폭, 길이, 끝단 높이 h1, 테이퍼 높이 h2, Level 폭을 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' }, { id: 'column', label: '기둥', keys: ['columnDiameterMm'], note: '기둥 직경 D를 입력합니다 (1200–3000 mm, 50 mm 단위). 높이와 위치는 계산값입니다.' }, { id: 'footing', label: '확대기초 · 말뚝', keys: ['footingLengthMm', 'footingWidthMm', 'footingHeightMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileLengthMm'], note: '확대기초 치수, 비림, 말뚝 길이와 규격을 입력합니다. 규칙 미정 항목(모따기, 경사, 복합 말뚝)은 다음 단계에서 추가합니다.' }, - { id: 'ground', label: '지반·지층', keys: [], note: '지반고는 입력값을 표시만 합니다. 지층 입력은 다음 단계에서 추가합니다.' }, + { id: 'ground', label: '지반·지층', keys: ['groundEl'], note: '지반고는 지반 모델 값을 적용하거나 직접 입력합니다. 지층 입력은 다음 단계에서 추가합니다.' }, ]; interface Pending { @@ -1494,6 +1498,54 @@ function renderBearingBasis(wrap: HTMLDivElement, pier: Pier): void { } } +/** ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button. */ +function renderGroundModel(box: HTMLDivElement, pier: Pier): void { + const wrap = el('div', undefined, 'ground-model'); + wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl')); + const importInput = el('input'); + importInput.type = 'file'; + importInput.accept = '.json'; + importInput.id = 'ground-import'; + importInput.addEventListener('change', async () => { + const file = importInput.files?.[0]; + if (!file) return; + try { + groundModel = parseGroundModel(await file.text()); + setStatus(`${file.name}에서 교각 ${groundModel.piers.length}개의 지반 모델 값을 불러왔습니다. 적용은 교각별로 누르세요.`); + renderAll(); + } catch (err) { + setError(`지반 모델을 읽지 못했습니다: ${(err as Error).message}`); + } finally { + importInput.value = ''; + } + }); + const importLabel = el('label', '지반 모델 파일 불러오기 (json)'); + importLabel.appendChild(importInput); + wrap.appendChild(importLabel); + + if (!groundModel) { + wrap.appendChild(el('p', '지반 모델을 불러오면 이 교각의 지반고를 엑셀 값과 비교하고 적용할 수 있습니다.', 'muted small')); + } else { + const entry = groundModel.piers.find((q) => q.id === pier.input.name); + const center = groundElFor(groundModel, pier.input.name); + if (!entry) { + wrap.appendChild(el('p', `지반 모델에 ${pier.input.name} 교각이 없습니다.`, 'warn-text')); + } else if (center === undefined) { + wrap.appendChild(el('p', '지형 자료 범위 밖이라 지반 모델 값이 없습니다. 입력된 지반고를 그대로 씁니다.', 'warn-text')); + } else { + const diff = entry.excelEl === null ? '—' : `${center - entry.excelEl >= 0 ? '+' : ''}${(center - entry.excelEl).toFixed(3)} m`; + wrap.appendChild(el('p', `지형 계산 ${center.toFixed(3)} m · 엑셀 ${entry.excelEl?.toFixed(3) ?? '—'} m · 차이 ${diff}`)); + const apply = el('button', `지반고 ${center.toFixed(3)} m 적용`, 'btn small'); + apply.type = 'button'; + apply.disabled = pier.draft.groundEl === center; + apply.addEventListener('click', () => editField(pier.input.name, 'groundEl', String(center))); + wrap.appendChild(apply); + } + wrap.appendChild(el('p', '적용하면 지반고가 변경으로 기록되고, 되돌리기로 엑셀 값으로 돌아갈 수 있습니다. 3D에 반영하려면 다시 생성하세요.', 'muted small')); + } + box.appendChild(wrap); +} + function renderBearingRef(box: HTMLDivElement, pier: Pier): void { const m = pier.model; const wrap = el('div', undefined, 'bearing-ref'); @@ -1671,6 +1723,7 @@ function renderPlan(): void { } if (member.id === 'coping') renderCopingInfo(fields, pier); if (member.id === 'bearing') renderBearingRef(fields, pier); + if (member.id === 'ground') renderGroundModel(fields, pier); if (FCK_MEMBERS.includes(member.id as FckMember)) renderMemberFck(fields, member.id as FckMember); regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`; regen.disabled = pendingKeys(pier).length === 0; @@ -1706,6 +1759,7 @@ function snapshot(): string { fckByMember: state.fckByMember, bearingBasis, bearingDrawing, + groundModel, }); } @@ -1724,6 +1778,7 @@ function currentProjectText(): string { fckByMember: state.fckByMember, bearingBasis, bearingDrawing, + groundModel: groundModel ?? undefined, }); } @@ -1811,6 +1866,7 @@ function openProjectText(text: string, saved: boolean): void { state.fckByMember = { ...parsed.fckByMember }; bearingBasis = parsed.bearingBasis ?? 'excel'; bearingDrawing = Object.fromEntries(Object.entries(parsed.bearingDrawing ?? {}).map(([k, v]) => [k, [...v]])); + groundModel = parsed.groundModel ?? null; state.piers = parsed.piers.map((p) => ({ ...buildEntry(p.input, false), defaults: p.defaults ?? p.input, diff --git a/tests/ground-model.test.ts b/tests/ground-model.test.ts new file mode 100644 index 0000000..52883bd --- /dev/null +++ b/tests/ground-model.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { groundElFor, parseGroundModel } from '../src/core/ground-model'; +import { changedKeys, overrideKeys, revertDraft, type PierValues } from '../src/core/changes'; +import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project'; +import { loadP1 } from './helpers'; + +/** The shape written by RAG/pierzainer_ground (schema 1). */ +const SAMPLE = JSON.stringify({ + schema: 1, + generated: '2026-10-23', + coord: '측량 X(북) · Y(동), m. 치수 mm', + piers: [ + { id: 'P1', ground: { center: 29.355, excel: 28.931, used: 29.355, source: '계산' }, footing: { cover: 0.972 } }, + { id: 'P2', ground: { center: null, excel: null, used: null, source: '없음' } }, + ], +}); + +describe('지반 모델 읽기 (ground_model.json, schema 1)', () => { + it('reads the pier-centre ground EL and the excel value', () => { + const m = parseGroundModel(SAMPLE); + expect(m.piers[0]).toEqual({ id: 'P1', centerEl: 29.355, excelEl: 28.931 }); + expect(groundElFor(m, 'P1')).toBe(29.355); + }); + + it('a pier outside the terrain has no value, and an unknown pier has none either', () => { + const m = parseGroundModel(SAMPLE); + expect(groundElFor(m, 'P2')).toBeUndefined(); + expect(groundElFor(m, 'P9')).toBeUndefined(); + }); + + it('refuses another schema number instead of guessing the layout', () => { + expect(() => parseGroundModel(SAMPLE.replace('"schema":1', '"schema":2'))).toThrow('형식 번호 2'); + }); + + it('refuses a file without a pier list or without pier names', () => { + expect(() => parseGroundModel('{"schema": 1}')).toThrow('교각 목록'); + expect(() => parseGroundModel('{"schema": 1, "piers": [{"ground": {}}]}')).toThrow('이름(id)'); + }); + + it('refuses text that is not JSON', () => { + expect(() => parseGroundModel('not json')).toThrow('JSON으로 읽히지 않습니다'); + }); +}); + +describe('지반고 적용 (사용자가 누를 때만, 변경으로 기록)', () => { + const base = loadP1(); + const pier: PierValues = { defaults: base, input: base, draft: { ...base, groundEl: 29.355 } }; + + it('an applied value counts as an override and can be reverted to the excel value', () => { + expect(overrideKeys(pier)).toContain('groundEl'); + expect(revertDraft(pier, 'groundEl').groundEl).toBe(base.groundEl); + }); + + it('the draft and the generated input are compared, so the change waits for regeneration', () => { + expect(changedKeys(pier.input, pier.draft)).toEqual(['groundEl']); + }); +}); + +describe('프로젝트 파일의 지반 모델', () => { + function project(extra: Partial): ProjectFile { + const p1 = loadP1(); + return { + schemaVersion: SCHEMA_VERSION, + project: { name: 'x', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' }, + source: { fileName: 'x.xlsx', sheets: [] }, + piers: [{ id: 'P1', input: p1 }], + history: [], + ...extra, + }; + } + + it('keeps the imported ground model across save and open', () => { + const m = parseGroundModel(SAMPLE); + const reopened = parseProject(serializeProject(project({ groundModel: m }))); + expect(reopened.groundModel).toEqual(m); + }); + + it('refuses a damaged ground model inside the project file', () => { + const text = serializeProject(project({})).replace('"history": []', '"history": [], "groundModel": {"schema": 1}'); + expect(() => parseProject(text)).toThrow('교각 목록'); + }); + + it('opens an older file that has no ground model', () => { + expect(parseProject(serializeProject(project({}))).groundModel).toBeUndefined(); + }); +});