diff --git a/index.html b/index.html
index 68b98c1..8fc9044 100644
--- a/index.html
+++ b/index.html
@@ -89,6 +89,12 @@
diff --git a/src/core/changes.ts b/src/core/changes.ts
new file mode 100644
index 0000000..e0a260b
--- /dev/null
+++ b/src/core/changes.ts
@@ -0,0 +1,45 @@
+import type { PierInput } from './types';
+
+/**
+ * Inputs the user can change on the 2단계 cards (spec §5.2 · plan C2). Each one maps to a field of PierInput.
+ * Extend this list as more cards are enabled; the tracking below works on any of these keys.
+ */
+export const EDITABLE_FIELDS = [
+ { key: 'copingWidthMm', label: '코핑 폭 B', unit: 'mm' },
+ { key: 'copingLengthMm', label: '코핑 길이 L', unit: 'mm' },
+ { key: 'pileLengthMm', label: '말뚝 길이', unit: 'mm' },
+] as const;
+
+export type EditableKey = (typeof EDITABLE_FIELDS)[number]['key'];
+
+/** Keys whose values differ between two inputs. */
+export function changedKeys(a: PierInput, b: PierInput): EditableKey[] {
+ return EDITABLE_FIELDS.map((f) => f.key).filter((key) => a[key] !== b[key]);
+}
+
+/**
+ * The three values a pier carries while it is edited (spec §5.5, plan B1):
+ * - defaults: the values as imported from the sheet (기본값, shown as "기본 3100").
+ * - input: the values the 3D model was generated from (생성본).
+ * - draft: the values the user currently has in the cards (현재 입력).
+ */
+export interface PierValues {
+ defaults: PierInput;
+ input: PierInput;
+ draft: PierInput;
+}
+
+/** Overrides: draft differs from the imported default (주황 표시, "변경 n건" in the card). */
+export function overrideKeys(p: PierValues): EditableKey[] {
+ return changedKeys(p.defaults, p.draft);
+}
+
+/** Pending: draft differs from the generated model, so the 3D view is out of date (미반영 변경). */
+export function pendingKeys(p: PierValues): EditableKey[] {
+ return changedKeys(p.input, p.draft);
+}
+
+/** Puts one field back to its imported default in the draft. */
+export function revertDraft(p: PierValues, key: EditableKey): PierInput {
+ return { ...p.draft, [key]: p.defaults[key] };
+}
diff --git a/src/core/project.ts b/src/core/project.ts
index 1fdbe91..b119ff9 100644
--- a/src/core/project.ts
+++ b/src/core/project.ts
@@ -14,8 +14,12 @@ export interface ProjectInfo {
export interface ProjectPier {
id: string;
- /** Input as read from the spreadsheet. The 3D model is not stored; it is regenerated on open (spec §8). */
+ /** Generated input: the values the 3D model was built from. The model is not stored; it is regenerated on open (spec §8). */
input: PierInput;
+ /** Values as imported from the sheet (기본값). Missing in files saved before change tracking: input is used. */
+ defaults?: PierInput;
+ /** Values currently in the cards (현재 입력). Missing in older files: input is used. */
+ draft?: PierInput;
}
export interface ProjectFile {
@@ -54,6 +58,11 @@ export function parseProject(text: string): ProjectFile {
if (typeof input.name !== 'string' || !Array.isArray(input.ips) || !Array.isArray(input.bearings)) {
throw new Error(`교각 ${i + 1}(${String(input.name)}): 입력 형식이 맞지 않습니다.`);
}
+ for (const extra of [p.defaults, p.draft]) {
+ if (extra !== undefined && (typeof extra !== 'object' || extra === null || typeof extra.name !== 'string')) {
+ throw new Error(`교각 ${i + 1}(${input.name}): defaults 또는 draft 형식이 맞지 않습니다.`);
+ }
+ }
});
return obj as ProjectFile;
}
diff --git a/src/main.ts b/src/main.ts
index 5d97c0a..f2c41b0 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,14 +6,14 @@ 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 { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile } from './core/project';
import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup';
import type { Issue, PierInput, PierType } from './core/types';
import { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
-interface Pier {
- input: PierInput;
+interface Pier extends PierValues {
model: PierModel | null;
issues: Issue[];
checked: boolean;
@@ -123,7 +123,12 @@ function buildEntry(input: PierInput, checked: boolean): Pier {
issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message });
}
}
- return { input, model, issues, checked };
+ return { input, model, issues, checked, defaults: input, draft: input };
+}
+
+/** Builds the model from the draft (the cards' values) and keeps the imported defaults. */
+function regenerateEntry(p: Pier): Pier {
+ return { ...buildEntry(p.draft, p.checked), defaults: p.defaults, draft: p.draft };
}
function sheetOk(sheets: SheetReport[], name: string): boolean {
@@ -250,7 +255,10 @@ function renderHeader(): void {
$('project-line').textContent = parts.join(' · ');
$('step1-status').textContent = pier?.model ? '생성됨' : '미생성';
renderSaveStatus();
- $('regen').disabled = !state.piers.some((p) => p.checked);
+ const total = pendingTotal();
+ const regen = $('regen');
+ regen.disabled = !state.piers.some((p) => p.checked);
+ regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성';
}
// --- results panel (시안 ① 입력값 · 계산 결과) ----------------------------------------
@@ -350,13 +358,100 @@ function renderAll(): void {
renderHeader();
renderPierList();
renderPanels();
+ renderEditCard();
scheduleBackup();
}
+// --- input cards and change tracking (plan B1 · C2) -------------------------------------
+function pendingTotal(): number {
+ return state.piers.filter((p) => p.checked).reduce((n, p) => n + pendingKeys(p).length, 0);
+}
+
+function fieldLabel(key: EditableKey): string {
+ return EDITABLE_FIELDS.find((f) => f.key === key)!.label;
+}
+
+function editField(name: string, key: EditableKey, raw: string): void {
+ const p = state.piers.find((q) => q.input.name === name);
+ if (!p) return;
+ const value = Number(raw);
+ if (!Number.isFinite(value) || value <= 0) {
+ setStatus(`${fieldLabel(key)}은(는) 0보다 큰 숫자여야 합니다. 값을 되돌렸습니다.`);
+ renderAll();
+ return;
+ }
+ p.draft = { ...p.draft, [key]: value };
+ renderAll();
+}
+
+function revertField(name: string, key: EditableKey): void {
+ const p = state.piers.find((q) => q.input.name === name);
+ if (!p) return;
+ p.draft = revertDraft(p, key);
+ renderAll();
+}
+
+function regenerateOne(name: string): void {
+ const i = state.piers.findIndex((q) => q.input.name === name);
+ if (i < 0) return;
+ state.piers[i] = regenerateEntry(state.piers[i]);
+ setupScene();
+ renderAll();
+ const msg = state.piers[i].model ? '형상을 다시 생성했습니다.' : '형상을 생성하지 못했습니다. 알림을 확인하세요.';
+ setStatus(msg);
+}
+
+function renderEditCard(): void {
+ const box = $('edit-fields');
+ box.replaceChildren();
+ const pier = activePier();
+ const btn = $('edit-regen');
+ if (!pier) {
+ $('edit-summary').textContent = '교각을 선택하면 입력을 고칠 수 있습니다.';
+ btn.textContent = '다시 생성 · 변경 0';
+ btn.disabled = true;
+ return;
+ }
+ const overrides = overrideKeys(pier);
+ const pending = pendingKeys(pier);
+ for (const f of EDITABLE_FIELDS) {
+ const changed = overrides.includes(f.key);
+ const row = el('div', undefined, changed ? 'edit-row changed' : 'edit-row');
+ const id = `edit-${f.key}`;
+ const label = el('label', `${f.label} (${f.unit})`);
+ label.htmlFor = id;
+ const input = el('input');
+ input.id = id;
+ input.type = 'number';
+ input.step = 'any';
+ input.value = String(pier.draft[f.key]);
+ input.addEventListener('change', () => editField(pier.input.name, f.key, input.value));
+ row.append(label, input, el('span', `기본 ${pier.defaults[f.key]}`, 'muted small'));
+ if (changed) {
+ const revert = el('button', '되돌리기', 'btn small');
+ revert.type = 'button';
+ revert.addEventListener('click', () => revertField(pier.input.name, f.key));
+ row.appendChild(revert);
+ }
+ box.appendChild(row);
+ }
+ $('edit-summary').textContent = pending.length
+ ? `미반영 변경 ${pending.length}건. 3D 그림은 생성본을 유지합니다.`
+ : overrides.length
+ ? `기본값과 ${overrides.length}건 다릅니다. 생성본이 최신입니다.`
+ : '기본값과 같습니다.';
+ btn.textContent = `다시 생성 · 변경 ${pending.length}`;
+ btn.disabled = pending.length === 0;
+}
+
// --- project file (plan B3·B4·B5) --------------------------------------------------------
/** Everything that a save writes except the history, so that a save itself does not look like a change. */
function snapshot(): string {
- return JSON.stringify({ project: state.project, source: state.source, piers: state.piers.map((p) => p.input) });
+ return JSON.stringify({
+ project: state.project,
+ source: state.source,
+ piers: state.piers.map((p) => ({ input: p.input, defaults: p.defaults, draft: p.draft })),
+ });
}
function isDirty(): boolean {
@@ -368,7 +463,7 @@ function currentProjectText(): string {
schemaVersion: SCHEMA_VERSION,
project: { name: state.project.name, bridge: state.project.bridge, client: '', author: '', crs: '', memo: '' },
source: state.source,
- piers: state.piers.map((p) => ({ id: p.input.name, input: p.input })),
+ piers: state.piers.map((p) => ({ id: p.input.name, input: p.input, defaults: p.defaults, draft: p.draft })),
history: state.history,
});
}
@@ -409,7 +504,11 @@ function openProjectText(text: string, saved: boolean): void {
state.project = { name: parsed.project.name, bridge: parsed.project.bridge };
state.source = parsed.source;
state.history = parsed.history;
- state.piers = parsed.piers.map((p) => buildEntry(p.input, false));
+ state.piers = parsed.piers.map((p) => ({
+ ...buildEntry(p.input, false),
+ defaults: p.defaults ?? p.input,
+ draft: p.draft ?? p.input,
+ }));
state.active = state.piers[0]?.input.name ?? null;
state.filter = 'all';
state.pending = null;
@@ -613,7 +712,7 @@ function regenerateChecked(): void {
state.piers = state.piers.map((p) => {
if (!p.checked) return p;
count++;
- const next = buildEntry(p.input, true);
+ const next = regenerateEntry(p);
if (!next.model) errors++;
return next;
});
@@ -628,6 +727,10 @@ $('dlg-close').addEventListener('click', () => $('dlg-cancel').addEventListener('click', () => $('import-dialog').close());
$('dlg-apply').addEventListener('click', applyImport);
$('regen').addEventListener('click', regenerateChecked);
+$('edit-regen').addEventListener('click', () => {
+ const pier = activePier();
+ if (pier) regenerateOne(pier.input.name);
+});
$('project-name').addEventListener('input', updateApply);
$('pick-file').addEventListener('click', () => $('file').click());
diff --git a/src/style.css b/src/style.css
index fde2c94..61902a3 100644
--- a/src/style.css
+++ b/src/style.css
@@ -587,3 +587,11 @@ table {
height: 60vh;
}
}
+
+/* Input cards (2단계 C2): changed values are outlined in orange, with the imported default beside them. */
+.edit-card { margin-top: 12px; border-top: 1px solid #d5dde6; padding-top: 10px; }
+.edit-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
+.edit-row label { min-width: 150px; }
+.edit-row input { width: 110px; padding: 4px 6px; border: 1px solid #b9c4d0; border-radius: 4px; }
+.edit-row.changed input { border: 2px solid #e8833a; background: #fff6ee; }
+.edit-row.changed label { color: #a4530e; }
diff --git a/tests/changes.test.ts b/tests/changes.test.ts
new file mode 100644
index 0000000..dd8593d
--- /dev/null
+++ b/tests/changes.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { buildPier } from '../src/core/geometry';
+import { overrideKeys, pendingKeys, revertDraft, type PierValues } from '../src/core/changes';
+import { loadP1 } from './helpers';
+
+function fresh(): PierValues {
+ const input = loadP1();
+ return { defaults: input, input, draft: input };
+}
+
+describe('input change tracking (plan B1 · C2)', () => {
+ it('a new pier has no overrides and nothing pending', () => {
+ const p = fresh();
+ expect(overrideKeys(p)).toEqual([]);
+ expect(pendingKeys(p)).toEqual([]);
+ });
+
+ it('coping width 3100 → 3300: one override and one pending change', () => {
+ const p = fresh();
+ const next = { ...p, draft: { ...p.draft, copingWidthMm: 3300 } };
+ expect(overrideKeys(next)).toEqual(['copingWidthMm']);
+ expect(pendingKeys(next)).toEqual(['copingWidthMm']);
+ });
+
+ it('regenerating clears the pending change but keeps the override', () => {
+ const p = fresh();
+ const edited = { ...p, draft: { ...p.draft, copingWidthMm: 3300 } };
+ const regenerated: PierValues = { ...edited, input: edited.draft };
+ expect(pendingKeys(regenerated)).toEqual([]);
+ expect(overrideKeys(regenerated)).toEqual(['copingWidthMm']);
+ expect(buildPier(regenerated.input).copingB).toBeCloseTo(3.3, 9);
+ });
+
+ it('reverting to the default removes the override', () => {
+ const p = fresh();
+ const edited = { ...p, draft: { ...p.draft, copingWidthMm: 3300 } };
+ const reverted = { ...edited, draft: revertDraft(edited, 'copingWidthMm') };
+ expect(overrideKeys(reverted)).toEqual([]);
+ // The draft is back to the generated value, so nothing is pending either.
+ expect(pendingKeys(reverted)).toEqual([]);
+ });
+});
diff --git a/tests/project.test.ts b/tests/project.test.ts
index 3715474..ea9ea83 100644
--- a/tests/project.test.ts
+++ b/tests/project.test.ts
@@ -46,4 +46,21 @@ describe('project file round trip (spec §8, plan B3)', () => {
expect(() => parseProject(JSON.stringify(broken))).toThrow('교각 2');
expect(() => parseProject('{ not json')).toThrow('JSON');
});
+
+ it('keeps defaults and an unregenerated draft through save and open', () => {
+ const original = sampleProject();
+ const p1 = original.piers[0];
+ original.piers[0] = { ...p1, defaults: p1.input, draft: { ...p1.input, copingWidthMm: 3300 } };
+ const reopened = parseProject(serializeProject(original));
+ expect(reopened.piers[0].draft?.copingWidthMm).toBe(3300);
+ expect(reopened.piers[0].input.copingWidthMm).toBe(3100);
+ });
+
+ it('a file saved before change tracking (no defaults or draft) still opens', () => {
+ const legacy = sampleProject();
+ const text = JSON.stringify({ ...legacy, piers: legacy.piers.map((p) => ({ id: p.id, input: p.input })) });
+ const reopened = parseProject(text);
+ expect(reopened.piers[0].defaults).toBeUndefined();
+ expect(reopened.piers[0].draft).toBeUndefined();
+ });
});