feat(core,view): per-row pile counts 열별 본수 (plan C4)

- A 열 is one row across the footing width; each has its own count along the
  length. Default floor((L − 1400) / 2250) + 1 per 열, as in spec §4.6.
- Counts are validated: one per 열, whole numbers 2–30. Spacing below the
  2250 mm minimum is shown as a warning, not refused (the plan's 6본 → 5@1800
  example).
- PierInput gains an optional pileRows; absent means the defaults, so existing
  files and the P1 results are unchanged.
- Change tracking compares arrays by value; 열별 본수 can be reverted and
  copied (copy item off by default).
- The footing card has −/+ per 열 with spacing, total, and the display string.
- Tests: defaults 4열 × 5본 = 20 and the 1단계 text; 6본 → 5@1800; limits;
  model-level 21 piles.
- Not verified in a browser.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-22 10:34:39 +09:00
1 parent 5b162c2441
commit 17b5b24682
9 files changed
+256 -39

No files matched your search

+54 -5
View File
@@ -6,8 +6,10 @@ import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
import { buildTemplateWorkbook } from './core/template';
import { EDITABLE_FIELDS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes';
import { EDITABLE_FIELDS, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes';
import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from './core/piles';
import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
import { pileGeometry } from './core/geometry';
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';
@@ -445,7 +447,7 @@ function renderCopyPreview(): void {
for (const t of selected) {
if (!src) break;
const plan = planCopy(src.draft, t.draft, items);
const labels = plan.changes.map((c) => `${EDITABLE_FIELDS.find((f) => f.key === c.key)!.label} ${c.from} → ${c.to}`);
const labels = plan.changes.map((c) => `${FIELD_LABELS[c.key]} ${c.from} → ${c.to}`);
const skipped = plan.skipped.map((k) => k.reason);
skippedTotal += skipped.length;
if (plan.changes.length) applied++;
@@ -508,7 +510,7 @@ function pendingTotal(): number {
}
function fieldLabel(key: EditableKey): string {
return EDITABLE_FIELDS.find((f) => f.key === key)!.label;
return FIELD_LABELS[key];
}
function editField(name: string, key: EditableKey, raw: string): void {
@@ -590,6 +592,53 @@ function fieldRows(box: HTMLDivElement, pier: Pier, keys: EditableKey[]): { over
return { overrides, pending };
}
/** Footing length of the generated model, which the 열별 본수 limits depend on. */
function footingLengthOf(p: Pier): number | undefined {
return p.model?.footings[0]?.length;
}
/** 열별 본수 −/+ per 열 (spec §5.4). Shows the spacing, the total, and a warning below the minimum spacing. */
function renderPileRows(box: HTMLDivElement, pier: Pier): void {
const footingLength = footingLengthOf(pier);
if (pier.input.foundationType !== '말뚝기초' || footingLength === undefined) return;
const g = pileGeometry(footingLength);
let counts: number[];
try {
counts = resolveRowCounts(pier.draft.pileRows, g);
} catch (err) {
box.appendChild(el('p', (err as Error).message, 'warn-text'));
return;
}
const layout = rowLayout(counts, g);
const wrap = el('div', undefined, 'pile-rows');
wrap.appendChild(el('p', `열별 본수 · 합계 ${totalPiles(counts)}본`, 'lbl'));
counts.forEach((count, j) => {
const row = el('div', undefined, layout[j].belowMinimum ? 'pile-row warn' : 'pile-row');
const minus = el('button', '−', 'btn small');
minus.type = 'button';
minus.setAttribute('aria-label', `열 ${j + 1} 본수 줄이기`);
minus.addEventListener('click', () => setPileRow(pier.input.name, j, -1));
const plus = el('button', '+', 'btn small');
plus.type = 'button';
plus.setAttribute('aria-label', `열 ${j + 1} 본수 늘리기`);
plus.addEventListener('click', () => setPileRow(pier.input.name, j, 1));
row.append(el('span', `열 ${j + 1}`), minus, el('span', `${count}본`, 'num'), plus, el('span', `간격 ${Math.round(layout[j].spacing * 1000)} mm`, 'num'));
if (layout[j].belowMinimum) row.append(el('span', `최소 ${Math.round(g.minSpacing * 1000)} mm보다 좁습니다`, 'warn-text'));
wrap.appendChild(row);
});
wrap.appendChild(el('p', layoutText(counts, g), 'num'));
box.appendChild(wrap);
}
function setPileRow(name: string, row: number, delta: number): void {
const p = state.piers.find((q) => q.input.name === name);
const length = p && footingLengthOf(p);
if (!p || length === undefined) return;
const counts = resolveRowCounts(p.draft.pileRows, pileGeometry(length));
p.draft = { ...p.draft, pileRows: adjustRow(counts, row, delta) };
renderAll();
}
/** 형상 선택칩 (plan.md C5): the default is pressed; every other option is shown and disabled with the reason. */
function renderShapeOptions(member: MemberId): void {
const box = $<HTMLDivElement>('member-options');
@@ -659,6 +708,7 @@ function renderPlan(): void {
regen.disabled = true;
} else {
fieldRows(fields, pier, member.keys);
if (member.id === 'footing') renderPileRows(fields, pier);
regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`;
regen.disabled = pendingKeys(pier).length === 0;
}
@@ -667,8 +717,7 @@ function renderPlan(): void {
changes.replaceChildren();
const pending = pier ? pendingKeys(pier) : [];
for (const key of pier ? overrideKeys(pier) : []) {
const f = EDITABLE_FIELDS.find((x) => x.key === key)!;
const li = el('li', `${f.label}: 기본 ${pier!.defaults[key]} → 현재 ${pier!.draft[key]}${pending.includes(key) ? ' (미반영)' : ''}`);
const li = el('li', `${FIELD_LABELS[key]}: 기본 ${pier!.defaults[key] ?? '없음'} → 현재 ${pier!.draft[key] ?? '없음'}${pending.includes(key) ? ' (미반영)' : ''}`);
const revert = el('button', '되돌리기', 'btn small');
revert.type = 'button';
revert.addEventListener('click', () => revertField(pier!.input.name, key));