feat(ground): 지반·지층 card inputs, layer rows, water level, section drawing

- types/ground.ts: 위치 (육상·하상·해상), 지하수위 고려 and EL, 지층 행
  (name and 하단 EL, from the top down). Checks G01 (layer order), G02 (water
  level range), G03 (layer name).
- The 지반고 still feeds the soil cover check; the layer rows and water level
  are inputs and do not change the 3D model.
- view/ground-svg.ts: the 삽도 drawn from the inputs and the generated
  footings and piles on one EL scale.
- 일괄 적용 copies 위치 and 지하수위 to the checked piers (속성 복사 item,
  off by default). Changes list shows arrays and booleans as text.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-26 10:11:42 +09:00
1 parent 0e70cfb969
commit 16ab317094
8 files changed
+400 -6

No files matched your search

+5 -1
View File
@@ -23,7 +23,7 @@ export const EDITABLE_FIELDS = [
] as const;
/** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */
export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows'] as const;
export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'strataRows'] as const;
export type EditableKey = (typeof TRACKED_KEYS)[number];
@@ -44,6 +44,10 @@ export const FIELD_LABELS: Record<EditableKey, string> = {
pileLengthMm: '말뚝 길이',
groundEl: '지반고 EL',
pileRows: '열별 본수',
groundPosition: '지반 위치',
groundWaterOn: '지하수위 고려',
groundWaterEl: '지하수위 EL',
strataRows: '지층 행',
};
/** Keys whose values differ between two inputs. Arrays are compared by value. */
+2 -1
View File
@@ -5,7 +5,7 @@ import type { PierInput } from './types';
* 속성 복사 (spec §9 · plan C7). Copies chosen attributes from one pier to others.
* Values the spec never copies (coordinates, IP, elevations, names) are not in any item, so they cannot be copied.
*/
export type CopyItem = 'coping-width' | 'coping-length' | 'pile-length' | 'pile-rows';
export type CopyItem = 'coping-width' | 'coping-length' | 'pile-length' | 'pile-rows' | 'ground-position';
interface ItemRule {
label: string;
@@ -21,6 +21,7 @@ export const COPY_ITEMS: Record<CopyItem, ItemRule> = {
'coping-length': { label: '코핑 길이 L', keys: ['copingLengthMm'], defaultOn: false, pileItem: false },
'pile-length': { label: '말뚝 길이', keys: ['pileLengthMm'], defaultOn: false, pileItem: true },
'pile-rows': { label: '열별 본수', keys: ['pileRows'], defaultOn: false, pileItem: true },
'ground-position': { label: '지반 위치·지하수위', keys: ['groundPosition', 'groundWaterOn', 'groundWaterEl'], defaultOn: false, pileItem: false },
};
export const DEFAULT_COPY_ITEMS: CopyItem[] = (Object.keys(COPY_ITEMS) as CopyItem[]).filter((k) => COPY_ITEMS[k].defaultOn);
+39
View File
@@ -0,0 +1,39 @@
import type { Issue, PierInput } from './types';
/** 위치 choices of the 지반·지층 card (시안 2026-10-26). */
export const GROUND_POSITIONS = ['육상', '하상', '해상'] as const;
/** 지층 names, in the order of the RAG strata list (지반모델링계획). */
export const STRATA_NAMES = ['매립토', '퇴적토', '풍화토', '풍화암', '연암', '경암'] as const;
/** The 지지층 for the pile tip check (지반 모델 G-7). */
export const SUPPORT_STRATUM = '연암';
/**
* 지반·지층 입력 검사 (승인 2026-10-26).
* G01: 층 하단 EL은 위 경계보다 낮아야 함. G02: 지하수위는 지반고와 가장 아래 층 사이에 있어야 함.
* G03: 층 이름은 지층 목록에 있어야 함. Ground values are inputs only; they do not change the 3D model.
*/
export function validateGround(input: PierInput, issues: Issue[]): void {
const rows = input.strataRows ?? [];
let upper = input.groundEl;
rows.forEach((row, i) => {
const label = `지층 ${i + 1}행 (${row.name})`;
if (!(STRATA_NAMES as readonly string[]).includes(row.name)) {
issues.push({ code: 'G03', level: 'W', pier: input.name, message: `${label}: 층 이름이 지층 목록에 없습니다.` });
}
if (!(row.bottomEl < upper)) {
issues.push({ code: 'G01', level: 'W', pier: input.name, message: `${label}: 하단 EL ${row.bottomEl.toFixed(3)}이 위 경계 ${upper.toFixed(3)}보다 낮아야 합니다.` });
}
upper = row.bottomEl;
});
if (input.groundWaterOn) {
const w = input.groundWaterEl;
const lowest = rows.length ? rows[rows.length - 1].bottomEl : undefined;
if (w === undefined || !Number.isFinite(w)) {
issues.push({ code: 'G02', level: 'W', pier: input.name, message: '지하수위를 고려하지만 지하수위 EL이 없습니다.' });
} else if (w > input.groundEl || (lowest !== undefined && w < lowest)) {
issues.push({ code: 'G02', level: 'W', pier: input.name, message: `지하수위 EL ${w.toFixed(3)}이 지반고와 가장 아래 층 사이에 있지 않습니다.` });
}
}
}
+14
View File
@@ -56,6 +56,20 @@ export interface PierInput {
pileKind?: '강관' | 'PHC';
/** 공통 규칙 for the solplate: centre thickness T, 솔플 여유 margin (mm), bearing height h3 (mm). */
solplate?: { T: number; margin: number; h3: number };
/** 지반·지층 (승인 2026-10-26): 위치, 지하수위 고려와 EL, 지층별 행 (위에서 아래, 행의 EL = 그 층의 하단 EL).
* Absent = 육상, 지하수위 없음, 지층 행 없음. */
groundPosition?: GroundPosition;
groundWaterOn?: boolean;
groundWaterEl?: number;
strataRows?: StrataRow[];
}
export type GroundPosition = '육상' | '하상' | '해상';
export interface StrataRow {
name: string;
/** 이 층의 하단 EL (m). */
bottomEl: number;
}
export type IssueLevel = 'E' | 'W' | 'I';
+2
View File
@@ -1,5 +1,6 @@
import { DIM, type PierModel } from './geometry';
import type { Issue, PierInput, PierType } from './types';
import { validateGround } from './ground';
const EXPECTED_COLUMNS: Record<PierType, number> = { 'T형': 1, '2주형': 2, '3주형': 3 };
@@ -89,4 +90,5 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[]
message: `받침블럭 높이 ${(minBlock * 1000).toFixed(0)} mm가 피복 + 철근 지름(${((cover + barDia) * 1000).toFixed(0)} mm)보다 작습니다. 블럭 철근은 코핑 안에 묻힙니다.`,
});
}
validateGround(input, issues);
}
+154 -4
View File
@@ -17,6 +17,8 @@ import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial,
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 { GROUND_POSITIONS, STRATA_NAMES } from './core/ground';
import { groundSectionSvg } from './view/ground-svg';
import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table';
import { columnDiameterIssues } from './core/column-check';
import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec';
@@ -37,7 +39,7 @@ import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/se
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';
import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup';
import type { Issue, PierInput, PierType } from './core/types';
import type { Issue, PierInput, PierType, StrataRow } from './core/types';
import { buildPierScene, setRebarView, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
@@ -78,7 +80,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: ['groundEl'], note: '지반고는 지반 모델 값을 적용하거나 직접 입력합니다. 지층 입력은 다음 단계에서 추가합니다.' },
{ id: 'ground', label: '지반·지층', keys: ['groundEl', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'strataRows'], note: '지반고는 계산(흙덮개)에 쓰고, 지층은 행으로, 지하수위는 직접 입력합니다. 위치는 육상·하상·해상 중 고릅니다.' },
];
interface Pending {
@@ -1498,9 +1500,157 @@ 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. */
/** Text of one tracked value in the 변경 list. */
function valueText(key: EditableKey, v: unknown): string {
if (v === undefined || v === null) return '없음';
if (key === 'strataRows') {
const rows = v as StrataRow[];
return rows.length ? rows.map((r) => `${r.name} ${r.bottomEl}`).join(' / ') : '없음';
}
if (typeof v === 'boolean') return v ? '예' : '아니오';
return String(v);
}
/** Sets one 지반·지층 input in the draft (applied to the model by 다시 생성). */
function editGround(name: string, key: 'groundPosition' | 'groundWaterOn' | 'groundWaterEl', value: unknown): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
if (key === 'groundWaterEl' && value !== undefined && !Number.isFinite(value)) {
setError('지하수위 EL은 숫자로 입력하세요. 값을 되돌렸습니다.');
renderAll();
return;
}
p.draft = { ...p.draft, [key]: value };
renderAll();
}
function setStrataRows(name: string, rows: StrataRow[]): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
p.draft = { ...p.draft, strataRows: rows };
renderAll();
}
/** 일괄 적용: the source's 위치 and 지하수위 go to the checked piers, which are then regenerated. */
function applyGroundPositionTo(source: Pier, targets: Pier[]): void {
for (const target of targets) {
target.draft = applyCopy(target.draft, planCopy(source.draft, target.draft, ['ground-position']));
}
regenerateAllPiers();
renderAll();
setStatus(`${targets.length}기에 위치와 지하수위를 적용했습니다.`);
}
/**
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
* The ground model import and the 지반고 apply stay at the bottom, as before.
*/
function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
const name = pier.input.name;
const d = pier.draft;
const wrap = el('div', undefined, 'ground-model');
wrap.appendChild(el('p', '위치', 'lbl'));
const positions = el('div', undefined, 'opts');
for (const v of GROUND_POSITIONS) {
const b = el('button', v, 'btn small');
b.type = 'button';
b.setAttribute('aria-pressed', String((d.groundPosition ?? '육상') === v));
b.addEventListener('click', () => editGround(name, 'groundPosition', v));
positions.appendChild(b);
}
wrap.appendChild(positions);
wrap.appendChild(el('p', '지하수위', 'lbl'));
const water = el('div', undefined, 'ground-water');
const check = el('input');
check.type = 'checkbox';
check.checked = d.groundWaterOn === true;
check.id = 'ground-water-on';
check.addEventListener('change', () => editGround(name, 'groundWaterOn', check.checked));
const checkLabel = el('label', ' 지하수위 고려');
checkLabel.prepend(check);
const level = el('input');
level.type = 'number';
level.step = 'any';
level.id = 'ground-water-el';
level.placeholder = '지하수위 EL (m)';
level.value = d.groundWaterEl === undefined ? '' : String(d.groundWaterEl);
level.disabled = !check.checked;
level.addEventListener('change', () => editGround(name, 'groundWaterEl', level.value === '' ? undefined : Number(level.value)));
water.append(checkLabel, level, el('span', 'm', 'muted small'));
wrap.appendChild(water);
// 지층 행 (승인 2026-10-26): one row per layer, from the top down; the EL is the layer's bottom EL.
const rows = d.strataRows ?? [];
wrap.appendChild(el('p', `지층 행 (위에서 아래 · 하단 EL m · 지반고 ${d.groundEl.toFixed(3)} 아래)`, 'lbl'));
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['지층', '하단 EL (m)', '']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
rows.forEach((row, i) => {
const tr = el('tr');
const select = el('select');
for (const s of STRATA_NAMES) {
const o = el('option', s);
o.value = s;
select.appendChild(o);
}
if (!(STRATA_NAMES as readonly string[]).includes(row.name)) {
const o = el('option', row.name);
o.value = row.name;
select.appendChild(o);
}
select.value = row.name;
select.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, name: select.value } : r))));
const bottomInput = el('input');
bottomInput.type = 'number';
bottomInput.step = '0.001';
bottomInput.value = String(row.bottomEl);
bottomInput.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, bottomEl: Number(bottomInput.value) } : r))));
const del = el('button', '삭제', 'btn small');
del.type = 'button';
del.addEventListener('click', () => setStrataRows(name, rows.filter((_, j) => j !== i)));
tr.append(...[select, bottomInput, del].map((x) => {
const td = el('td');
td.appendChild(x);
return td;
}));
body.appendChild(tr);
});
table.appendChild(body);
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
const add = el('button', '지층 행 추가', 'btn small');
add.type = 'button';
add.addEventListener('click', () => {
const last = rows[rows.length - 1];
const next = last ? (STRATA_NAMES as readonly string[])[Math.min((STRATA_NAMES as readonly string[]).indexOf(last.name) + 1, STRATA_NAMES.length - 1)] : STRATA_NAMES[0];
setStrataRows(name, [...rows, { name: next, bottomEl: Number(((last?.bottomEl ?? d.groundEl) - 2).toFixed(3)) }]);
});
wrap.appendChild(add);
// 단면 (삽도): the footings and piles are the generated model; the layers and water level are the inputs.
if (pier.model) {
const fig = el('figure', undefined, 'ground-fig');
fig.innerHTML = groundSectionSvg(d, pier.model);
fig.appendChild(el('figcaption', '단면 · 기초와 말뚝은 생성본, 지층과 지하수위는 입력값', 'muted small'));
wrap.appendChild(fig);
}
// 일괄 적용: copies the 위치 and 지하수위 to the other checked piers (속성 복사 item 'ground-position').
const others = state.piers.filter((q) => q.checked && q.input.name !== name);
const batch = el('button', `${name} 외 일괄 대상 ${others.length}기에 적용`, 'btn small');
batch.type = 'button';
batch.title = '위치와 지하수위 설정을 체크한 다른 교각에 적용합니다.';
batch.disabled = others.length === 0;
batch.addEventListener('click', () => applyGroundPositionTo(pier, others));
wrap.appendChild(batch);
// ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button.
wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl'));
const importInput = el('input');
importInput.type = 'file';
@@ -1734,7 +1884,7 @@ function renderPlan(): void {
changes.replaceChildren();
const pending = pier ? pendingKeys(pier) : [];
for (const key of pier ? overrideKeys(pier) : []) {
const li = el('li', `${FIELD_LABELS[key]}: 기본 ${pier!.defaults[key] ?? '없음'} → 현재 ${pier!.draft[key] ?? '없음'}${pending.includes(key) ? ' (미반영)' : ''}`);
const li = el('li', `${FIELD_LABELS[key]}: 기본 ${valueText(key, pier!.defaults[key])} → 현재 ${valueText(key, pier!.draft[key])}${pending.includes(key) ? ' (미반영)' : ''}`);
const revert = el('button', '되돌리기', 'btn small');
revert.type = 'button';
revert.addEventListener('click', () => revertField(pier!.input.name, key));
+79
View File
@@ -0,0 +1,79 @@
import type { PierModel } from '../core/geometry';
import type { PierInput } from '../core/types';
import { SUPPORT_STRATUM } from '../core/ground';
/** Layer fills from the top down (시안 2026-10-26: 지층 1 light, deeper layers darker). */
const LAYER_FILLS = ['#EADFC8', '#DCCBA8', '#C9B38D', '#B9A37E', '#A8926F', '#97815F'];
const WIDTH = 400;
const PLOT_H = 300;
const TOP_PAD = 24;
/**
* 지반·지층 단면 (삽도). Drawn from the inputs and the generated footings and piles, on one vertical EL scale.
* Horizontal positions are schematic (no dimensions): piles are spread across the footing.
*/
export function groundSectionSvg(input: PierInput, model: PierModel): string {
const rows = input.strataRows ?? [];
const ground = input.groundEl;
const footTop = Math.max(...model.footings.map((f) => f.top));
const footBottom = Math.min(...model.footings.map((f) => f.bottom));
const pileTips = model.piles.map((p) => p.bottom);
const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : ground - 1;
const water = input.groundWaterOn && input.groundWaterEl !== undefined && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined;
const yMin = Math.min(footBottom, lowestRow, ...pileTips, water ?? ground) - 1;
const yMax = Math.max(ground, footTop, input.copingTopEl) + 0.5;
const scale = PLOT_H / (yMax - yMin);
const y = (el: number) => TOP_PAD + (yMax - el) * scale;
const H = TOP_PAD + PLOT_H + 16;
const parts: string[] = [];
// Layers: each band runs from the upper boundary (ground for the first) down to its 하단 EL.
let upper = ground;
rows.forEach((row, i) => {
const top = y(upper);
const bottom = y(row.bottomEl);
parts.push(`<rect x="0" y="${top}" width="${WIDTH}" height="${bottom - top}" fill="${LAYER_FILLS[i % LAYER_FILLS.length]}"></rect>`);
parts.push(`<text x="8" y="${(top + bottom) / 2 + 4}" font-size="12" fill="#18212B">${escapeText(row.name)}</text>`);
if (row.name === SUPPORT_STRATUM) {
parts.push(`<line x1="0" y1="${top}" x2="${WIDTH}" y2="${top}" stroke="#5B4A2E" stroke-width="2"></line>`);
parts.push(`<text x="8" y="${top - 4}" font-size="12" fill="#18212B">연암층 상면 EL ${upper.toFixed(3)}</text>`);
}
upper = row.bottomEl;
});
if (rows.length === 0 || upper > yMin) {
parts.push(`<rect x="0" y="${y(upper)}" width="${WIDTH}" height="${y(yMin) - y(upper)}" fill="${LAYER_FILLS[rows.length % LAYER_FILLS.length]}"></rect>`);
}
// Ground line and label.
parts.push(`<line x1="0" y1="${y(ground)}" x2="${WIDTH}" y2="${y(ground)}" stroke="#8A6D3B" stroke-width="1.5"></line>`);
parts.push(`<text x="8" y="${y(ground) - 6}" font-size="12" fill="#18212B">지반고 ${ground.toFixed(3)}</text>`);
// Groundwater (only when the input is on and has a value).
if (water !== undefined) {
parts.push(`<line x1="0" y1="${y(water)}" x2="${WIDTH}" y2="${y(water)}" stroke="#2F6DB5" stroke-dasharray="6 4"></line>`);
parts.push(`<text x="8" y="${y(water) - 4}" font-size="12" fill="#123E70">지하수위 ${water.toFixed(3)}</text>`);
}
// Footing, column stub, and piles.
const cx = WIDTH / 2;
const colW = 30;
parts.push(`<rect x="${cx - colW / 2}" y="${y(yMax)}" width="${colW}" height="${y(footTop) - y(yMax)}" fill="#D8C7AA" stroke="#9C8763"></rect>`);
const footW = 140;
parts.push(`<rect x="${cx - footW / 2}" y="${y(footTop)}" width="${footW}" height="${y(footBottom) - y(footTop)}" fill="#B7C7D3" stroke="#6E8496"></rect>`);
const sValues = [...new Set(model.piles.map((p) => p.s))].sort((a, b) => a - b);
const spread = sValues.length > 1 ? (footW - 20) / (sValues.length - 1) : 0;
sValues.forEach((s) => {
const tips = model.piles.filter((p) => p.s === s);
const x = sValues.length > 1 ? cx - footW / 2 + 10 + sValues.indexOf(s) * spread : cx;
const tip = Math.min(...tips.map((p) => p.bottom));
parts.push(`<rect x="${x - 3}" y="${y(footBottom)}" width="6" height="${y(tip) - y(footBottom)}" fill="#7D8A97"></rect>`);
});
return `<svg viewBox="0 0 ${WIDTH} ${H}" width="100%" style="max-width: ${WIDTH}px; height: auto" role="img" aria-label="지반·지층 단면: 지반면, 지층, 지하수위, 기초와 말뚝">${parts.join('')}</svg>`;
}
function escapeText(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}