feat(ground): pile-tip embedment check and soil layers from the ground model

- 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 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-26 10:35:41 +09:00
1 parent 16ab317094
commit 325c841733
6 files changed
+152 -5

No files matched your search

+27 -1
View File
@@ -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<TerrainGrid> | 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<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;
}
+34
View File
@@ -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)}보다 높습니다.`,
});
}
}
+2 -1
View File
@@ -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<PierType, number> = { '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);
}
+13 -1
View File
@@ -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) {