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

+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));