From 325c8417332456c5fd924a3c24daf74a255fd8c6 Mon Sep 17 00:00:00 2001 From: ypshim Date: Mon, 26 Oct 2026 10:35:41 +0900 Subject: [PATCH] feat(ground): pile-tip embedment check and soil layers from the ground model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ground.ts: G04 when a pile tip is above the 연암 상면 minus 1.0 × D; G05 (info) when layers are entered but there is no 연암 row. No message while no layers are entered at all. - ground-model.ts: reads the Python soil.layers as strata rows; the project file keeps and checks them. - ② card: a button fills the layer rows from the ground model for the pier. Co-Authored-By: Claude Haiku 5.5 --- src/core/ground-model.ts | 28 ++++++++++++++++++++++- src/core/ground.ts | 34 ++++++++++++++++++++++++++++ src/core/validate.ts | 3 ++- src/main.ts | 14 +++++++++++- tests/ground-inputs.test.ts | 34 +++++++++++++++++++++++++++- tests/ground-model.test.ts | 44 ++++++++++++++++++++++++++++++++++++- 6 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/core/ground-model.ts b/src/core/ground-model.ts index 1a4c980..2045322 100644 --- a/src/core/ground-model.ts +++ b/src/core/ground-model.ts @@ -23,6 +23,8 @@ export interface GroundModelPier { centerEl: number | null; /** 엑셀 지반고 (m). 없으면 null. */ excelEl: number | null; + /** 지층 기둥 (시추 자료가 있을 때): 위에서 아래, 각 층의 하단 EL (m). Absent = no soil column. */ + layers?: { name: string; bottomEl: number }[]; } export interface GroundModelFile { @@ -50,7 +52,9 @@ export function checkGroundModel(raw: unknown): GroundModelFile { 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)이 없습니다.`); - return { id, centerEl: numOrNull(ground.center), excelEl: numOrNull(ground.excel) }; + 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); @@ -62,6 +66,23 @@ export function checkGroundModel(raw: unknown): GroundModelFile { }; } +/** 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; @@ -92,6 +113,11 @@ export function checkStoredGroundModel(raw: unknown): GroundModelFile { if (!ok) throw new Error(`프로젝트 안의 지반 모델 교각 ${String(p?.id)}의 값이 맞지 않습니다.`); } if (obj.terrainGrid !== undefined) checkTerrainGrid(obj.terrainGrid); + 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; } diff --git a/src/core/ground.ts b/src/core/ground.ts index c7aac17..3257459 100644 --- a/src/core/ground.ts +++ b/src/core/ground.ts @@ -1,3 +1,4 @@ +import type { PierModel } from './geometry'; import type { Issue, PierInput } from './types'; /** 위치 choices of the 지반·지층 card (시안 2026-10-26). */ @@ -9,6 +10,9 @@ export const STRATA_NAMES = ['매립토', '퇴적토', '풍화토', '풍화암', /** The 지지층 for the pile tip check (지반 모델 G-7). */ export const SUPPORT_STRATUM = '연암'; +/** 근입 = 이 값 × 말뚝 지름 (지반 모델 G-7, embed_d 1.0). */ +export const PILE_EMBED_D = 1.0; + /** * 지반·지층 입력 검사 (승인 2026-10-26). * G01: 층 하단 EL은 위 경계보다 낮아야 함. G02: 지하수위는 지반고와 가장 아래 층 사이에 있어야 함. @@ -37,3 +41,33 @@ export function validateGround(input: PierInput, issues: Issue[]): void { } } } + +/** + * 말뚝 선단 근입 (승인 2026-10-26): every pile tip must be at least PILE_EMBED_D × D below the 연암 상면. + * G04: a tip above that limit. G05 (info): piles but no 연암 row, so the embedment is not checked. + * The 연암 상면 is the bottom of the row above the 연암 row (ground EL for the first row). + */ +export function validatePileEmbedment(input: PierInput, model: PierModel, issues: Issue[]): void { + if (model.piles.length === 0) return; + const rows = input.strataRows ?? []; + const k = rows.findIndex((r) => r.name === SUPPORT_STRATUM); + if (k < 0) { + // No layers entered yet: nothing to say. Layers entered but no 연암 row: say so. + if (rows.length > 0) { + issues.push({ code: 'G05', level: 'I', pier: input.name, message: '연암 행이 없어 말뚝 선단 근입을 확인하지 않았습니다.' }); + } + return; + } + const top = k === 0 ? input.groundEl : rows[k - 1].bottomEl; + const deepest = Math.max(...model.piles.map((p) => p.bottom)); + const diameter = Math.max(...model.piles.map((p) => p.diameter)); + const limit = top - PILE_EMBED_D * diameter; + if (deepest > limit + 1e-9) { + issues.push({ + code: 'G04', + level: 'W', + pier: input.name, + message: `말뚝 선단 EL ${deepest.toFixed(3)}이 연암 상면 ${top.toFixed(3)} − 근입 ${(PILE_EMBED_D * diameter).toFixed(3)} = ${limit.toFixed(3)}보다 높습니다.`, + }); + } +} diff --git a/src/core/validate.ts b/src/core/validate.ts index 3fdd7c5..e449f88 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -1,6 +1,6 @@ import { DIM, type PierModel } from './geometry'; import type { Issue, PierInput, PierType } from './types'; -import { validateGround } from './ground'; +import { validateGround, validatePileEmbedment } from './ground'; const EXPECTED_COLUMNS: Record = { 'T형': 1, '2주형': 2, '3주형': 3 }; @@ -91,4 +91,5 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[] }); } validateGround(input, issues); + validatePileEmbedment(input, model, issues); } diff --git a/src/main.ts b/src/main.ts index 9c8830c..42d88d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +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 { groundElFor, parseGroundModel, strataRowsFor, type GroundModelFile } from './core/ground-model'; import { GROUND_POSITIONS, STRATA_NAMES } from './core/ground'; import { groundSectionSvg } from './view/ground-svg'; import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table'; @@ -1631,6 +1631,18 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void { setStrataRows(name, [...rows, { name: next, bottomEl: Number(((last?.bottomEl ?? d.groundEl) - 2).toFixed(3)) }]); }); wrap.appendChild(add); + // 지반 모델의 지층 기둥 (Python, 시추 자료가 있을 때): one click fills the rows (승인 2026-10-26). + if (groundModel) { + const modelRows = strataRowsFor(groundModel, name); + if (modelRows) { + const fromModel = el('button', `지반 모델의 지층 ${modelRows.length}개 불러오기`, 'btn small'); + fromModel.type = 'button'; + fromModel.addEventListener('click', () => setStrataRows(name, modelRows.map((r) => ({ ...r })))); + wrap.appendChild(fromModel); + } else { + wrap.appendChild(el('p', '지반 모델에 이 교각의 지층이 없습니다 (시추 자료가 있으면 만들어집니다).', 'muted small')); + } + } // 단면 (삽도): the footings and piles are the generated model; the layers and water level are the inputs. if (pier.model) { diff --git a/tests/ground-inputs.test.ts b/tests/ground-inputs.test.ts index 6508d6b..2ec5c4d 100644 --- a/tests/ground-inputs.test.ts +++ b/tests/ground-inputs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; -import { validateGround } from '../src/core/ground'; +import { validateGround, validatePileEmbedment } from '../src/core/ground'; import { changedKeys } from '../src/core/changes'; import { COPY_ITEMS, applyCopy, planCopy } from '../src/core/copy'; import type { Issue, PierInput } from '../src/core/types'; @@ -103,3 +103,35 @@ describe('단면 그림 (삽도)', () => { expect(svg).toContain('지반고 28.931'); }); }); + +describe('말뚝 선단 근입 (G04·G05)', () => { + // P1 model: pile tips at about EL 2.2 m, D 0.6096 m, so the limit is the 연암 top minus 0.6096. + const model = buildPier(base); + const deepest = Math.max(...model.piles.map((p) => p.bottom)); + + function embed(input: PierInput): string[] { + const issues: Issue[] = []; + validatePileEmbedment(input, model, issues); + return issues.map((i) => i.code); + } + + it('no check and no message when no layers are entered yet', () => { + expect(embed(withGround({}))).toEqual([]); + }); + + it('G04: a pile tip above the 연암 top minus the embedment', () => { + // 연암 top = the 매립토 bottom 2.5 m, so the limit is 2.5 − 0.6096 = 1.89 m, above the tips at 2.2 m. + expect(deepest).toBeGreaterThan(2.5 - 0.6096); + const rows = [{ name: '매립토', bottomEl: 2.5 }, { name: '연암', bottomEl: 1 }]; + expect(embed(withGround({ strataRows: rows }))).toEqual(['G04']); + }); + + it('no G04 when the 연암 top is high enough above the tips', () => { + const rows = [{ name: '매립토', bottomEl: 20 }, { name: '연암', bottomEl: 10 }]; + expect(embed(withGround({ strataRows: rows }))).toEqual([]); + }); + + it('G05 (info): layers entered but no 연암 row, so the embedment is not checked', () => { + expect(embed(withGround({ strataRows: [{ name: '풍화토', bottomEl: 20 }] }))).toEqual(['G05']); + }); +}); diff --git a/tests/ground-model.test.ts b/tests/ground-model.test.ts index 9bb73b0..dd5ab64 100644 --- a/tests/ground-model.test.ts +++ b/tests/ground-model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { groundElFor, parseGroundModel } from '../src/core/ground-model'; +import { groundElFor, parseGroundModel, strataRowsFor } 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'; @@ -151,3 +151,45 @@ describe('프로젝트 파일의 지반 모델', () => { expect(parseProject(serializeProject(project({}))).groundModel).toBeUndefined(); }); }); + +describe('지반 모델의 지층 기둥 (soil.layers)', () => { + const WITH_SOIL = JSON.stringify({ + schema: 2, + piers: [{ + id: 'P1', + ground: { center: 29.355, excel: 28.931 }, + soil: { layers: [{ name: '매립토', top_el: 29.355, bottom_el: 27.5 }, { name: '연암', top_el: 27.5, bottom_el: 10 }] }, + }], + }); + + it('reads the layers as strata rows (name and bottom EL, top to bottom)', () => { + const m = parseGroundModel(WITH_SOIL); + expect(strataRowsFor(m, 'P1')).toEqual([{ name: '매립토', bottomEl: 27.5 }, { name: '연암', bottomEl: 10 }]); + expect(strataRowsFor(m, 'P9')).toBeUndefined(); + }); + + it('a pier without a soil column has no layers', () => { + expect(strataRowsFor(parseGroundModel(SAMPLE), 'P1')).toBeUndefined(); + }); + + it('refuses a soil layers entry that is not a list', () => { + const data = JSON.parse(WITH_SOIL); + data.piers[0].soil.layers = { x: 1 }; + expect(() => parseGroundModel(JSON.stringify(data))).toThrow('지층(soil.layers) 형식'); + }); + + it('keeps the layers in a saved project and refuses a damaged layer', () => { + const m = parseGroundModel(WITH_SOIL); + const text = serializeProject({ + schemaVersion: SCHEMA_VERSION, + project: { name: 'x', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' }, + source: { fileName: 'x.xlsx', sheets: [] }, + piers: [{ id: 'P1', input: loadP1() }], + history: [], + groundModel: m, + }); + expect(strataRowsFor(parseProject(text).groundModel!, 'P1')).toEqual(strataRowsFor(m, 'P1')); + const bad = text.replace('"bottomEl": 10', '"bottomEl": "10"'); + expect(() => parseProject(bad)).toThrow('지층 값이 맞지 않습니다'); + }); +});