diff --git a/index.html b/index.html index 46989d3..4772efc 100644 --- a/index.html +++ b/index.html @@ -74,6 +74,7 @@

+ diff --git a/src/core/active-piers.ts b/src/core/active-piers.ts new file mode 100644 index 0000000..2fca62e --- /dev/null +++ b/src/core/active-piers.ts @@ -0,0 +1,29 @@ +import { overrideKeys, type EditableKey, type PierValues } from './changes'; +import type { PierInput } from './types'; + +/** One list row as the selection rules see it. */ +export interface PierSelection { + name: string; + checked: boolean; + hasModel: boolean; +} + +/** + * Piers shown in the 3D view: every checked pier that has a model, plus the 기준 pier (the one whose card is open) + * even when it is unchecked. Order follows the list (승인 2026-10-27, 체크 = 활성). + */ +export function shownNames(list: PierSelection[], active: string | null): string[] { + return list.filter((p) => p.hasModel && (p.checked || p.name === active)).map((p) => p.name); +} + +/** Piers that receive the 기준 pier's overrides: the checked piers with a model, other than the 기준 pier. */ +export function overrideTargets(list: PierSelection[], active: string | null): string[] { + return list.filter((p) => p.checked && p.hasModel && p.name !== active).map((p) => p.name); +} + +/** The target input with the source's overridden values copied in; every other value of the target is kept. */ +export function applyOverrides(source: PierValues, target: PierInput): PierInput { + const next: PierInput = { ...target }; + for (const key of overrideKeys(source) as EditableKey[]) next[key] = source.draft[key] as never; + return next; +} diff --git a/src/main.ts b/src/main.ts index fb45f3b..89c2bbf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { buildPier, type PierModel } from './core/geometry'; import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate'; import { buildTemplateWorkbook } from './core/template'; import { EDITABLE_FIELDS, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes'; +import { applyOverrides, overrideTargets, shownNames, type PierSelection } from './core/active-piers'; 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'; @@ -350,13 +351,26 @@ function setView(kind: ViewKind): void { } /** 전체 보기 applies on ① 형상 생성 only; ③ and the others keep the single-pier scene. */ -function allViewOn(): boolean { - return state.allView && state.stage === 'shape'; +function selectionOf(p: Pier): PierSelection { + return { name: p.input.name, checked: p.checked, hasModel: p.model !== null }; } -/** Builds 전체 보기 from every pier that has a model. Returns false when none does. */ +/** The piers the 3D view shows together: the checked ones plus the 기준 pier (체크 = 활성, 승인 2026-10-27). */ +function shownPiers(): Pier[] { + const names = new Set(shownNames(state.piers.map(selectionOf), state.active)); + return state.piers.filter((p) => names.has(p.input.name)); +} + +/** Several piers in one frame: ① 전체 보기 (button), or ② when two or more piers are shown. */ +function multiSceneOn(): boolean { + if (state.stage === 'shape') return state.allView; + if (state.stage === 'plan') return shownPiers().length >= 2; + return false; +} + +/** Builds the shown piers in one frame. Returns false when none has a model. */ function buildAllScene(): boolean { - const entries = state.piers.flatMap((p) => (p.model ? [{ name: p.input.name, model: p.model }] : [])); + const entries = shownPiers().flatMap((p) => (p.model ? [{ name: p.input.name, model: p.model }] : [])); if (entries.length === 0) return false; allPiers = buildAllPiers(entries, groundModel?.terrainGrid); world.add(allPiers.piers); @@ -375,7 +389,7 @@ function setupScene(): void { } if (rebar3d) world.remove(rebar3d.group); rebar3d = null; - if (allViewOn() && buildAllScene()) { + if (multiSceneOn() && buildAllScene()) { setView(state.view); return; } @@ -430,6 +444,7 @@ function renderPierRows(list: HTMLUListElement, mode: Stage): void { check.setAttribute('aria-label', `${pier.input.name} 선택`); check.addEventListener('change', () => { pier.checked = check.checked; + setupScene(); renderAll(); }); // The pier name and type are shown here, since the 3D title band is gone on wide screens (승인 2026-10-23). @@ -485,7 +500,8 @@ function renderInputTable(): void { const inp = p.input; const front = inp.bearings.filter((b) => b.row === '전열').length; const back = inp.bearings.filter((b) => b.row === '후열').length; - const tr = el('tr', undefined, p.input.name === state.active ? 'active' : undefined); + const classes = [p.checked ? 'shown' : '', p.input.name === state.active ? 'active' : ''].filter(Boolean).join(' '); + const tr = el('tr', undefined, classes || undefined); tr.tabIndex = 0; tr.addEventListener('click', () => selectPier(p.input.name)); tr.addEventListener('keydown', (e) => { @@ -533,7 +549,7 @@ function renderPanels(): void { renderInputTable(); $('inputs-active').textContent = pier ? `활성 교각 ${pier.input.name} 상세 (표의 행을 누르면 바뀜)` : ''; - $('edit-title').textContent = pier ? `코핑·말뚝 입력 수정 · ${pier.input.name}` : '코핑·말뚝 입력 수정'; + $('edit-title').textContent = pier ? `코핑·말뚝 입력 수정 · 기준 ${pier.input.name}` : '코핑·말뚝 입력 수정'; if (!pier) { fillKv($('inputs'), []); @@ -796,6 +812,12 @@ function renderEditCard(): void { : '기본값과 같습니다.'; btn.textContent = `다시 생성 · 변경 ${status.pending.length}`; btn.disabled = status.pending.length === 0; + // The 기준 pier's overrides can go to the other checked piers; they take effect on 선택 교각 다시 생성. + const targets = overrideTargets(state.piers.map(selectionOf), state.active); + const applyBtn = $('edit-apply-active'); + applyBtn.hidden = targets.length === 0; + applyBtn.textContent = `활성 교각 ${targets.length}기에 적용`; + applyBtn.disabled = overrideKeys(pier).length === 0; } /** One row per input key: value in the draft, the imported default beside it, and a revert button when overridden. */ @@ -1062,7 +1084,7 @@ function setStage(stage: Stage): void { if (stage === 'shape' && state.stage !== 'shape') state.allView = true; state.stage = stage; // Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout. - if ((allPiers !== null) !== allViewOn()) setupScene(); + if ((allPiers !== null) !== multiSceneOn()) setupScene(); if (state.scene) setRebarView(state.scene, stage === 'rebar'); $('view-shape').hidden = stage !== 'shape'; $('view-plan').hidden = stage !== 'plan'; @@ -3095,6 +3117,22 @@ $('edit-regen').addEventListener('click', () => { const pier = activePier(); if (pier) regenerateOne(pier.input.name); }); + +$('edit-apply-active').addEventListener('click', () => { + const base = activePier(); + if (!base) return; + const targets = overrideTargets(state.piers.map(selectionOf), state.active); + const keys = overrideKeys(base); + if (targets.length === 0 || keys.length === 0) return; + const labels = keys.map((k) => FIELD_LABELS[k]).join(', '); + const ok = window.confirm( + `${targets.join(', ')}에 기준 ${base.input.name}의 ${keys.length}개 항목(${labels})을 적용합니다.\n적용 후 '선택 교각 다시 생성'을 눌러야 3D에 반영됩니다.`, + ); + if (!ok) return; + for (const p of state.piers) if (targets.includes(p.input.name)) p.draft = applyOverrides(base, p.draft); + renderAll(); + setStatus(`${targets.length}기에 ${keys.length}개 항목을 적용했습니다.`); +}); $('project-name').addEventListener('input', updateApply); $('pick-file').addEventListener('click', () => $('file').click()); diff --git a/src/style.css b/src/style.css index 73fc116..4cd668c 100644 --- a/src/style.css +++ b/src/style.css @@ -816,6 +816,7 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; #inputs-all { font-size: 12px; width: 100%; } #inputs-all th, #inputs-all td { white-space: nowrap; padding: 4px 6px; } #inputs-all tbody tr { cursor: pointer; } +#inputs-all tbody tr.shown td { background: #f4f8fc; } #inputs-all tbody tr.active td { background: #e3edf8; font-weight: 700; } /* Import preview: all piers' 받침·IP rows scroll inside the dialog. */ diff --git a/tests/active-piers.test.ts b/tests/active-piers.test.ts new file mode 100644 index 0000000..baa7767 --- /dev/null +++ b/tests/active-piers.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { applyOverrides, overrideTargets, shownNames, type PierSelection } from '../src/core/active-piers'; +import { loadP1 } from './helpers'; + +const list: PierSelection[] = [ + { name: 'P1', checked: true, hasModel: true }, + { name: 'P2', checked: true, hasModel: true }, + { name: 'P3', checked: false, hasModel: true }, + { name: 'P4', checked: true, hasModel: false }, +]; + +describe('shownNames (체크 = 활성, 기준 교각은 항상 표시)', () => { + it('shows every checked pier that has a model, in list order', () => { + expect(shownNames(list, 'P1')).toEqual(['P1', 'P2']); + }); + + it('keeps the 기준 pier even when it is unchecked', () => { + expect(shownNames(list, 'P3')).toEqual(['P1', 'P2', 'P3']); + }); + + it('never shows a pier without a model', () => { + expect(shownNames(list, 'P4')).toEqual(['P1', 'P2']); + }); + + it('shows only the 기준 pier when nothing else is checked', () => { + const none = list.map((p) => ({ ...p, checked: false })); + expect(shownNames(none, 'P2')).toEqual(['P2']); + }); +}); + +describe('overrideTargets (기준 교각의 수정 값을 받을 교각)', () => { + it('is the checked piers other than the 기준 pier', () => { + expect(overrideTargets(list, 'P1')).toEqual(['P2']); + expect(overrideTargets(list, 'P3')).toEqual(['P1', 'P2']); + }); +}); + +describe('applyOverrides (기준 교각의 변경 항목만 옮김)', () => { + const base = loadP1(); + const defaults = base; + const edited = { ...base, copingWidthMm: base.copingWidthMm + 100 }; + const target = { ...base, copingLengthMm: base.copingLengthMm + 500 }; + + it('copies only the keys the 기준 pier overrides, keeping the target own values', () => { + const next = applyOverrides({ defaults, input: defaults, draft: edited }, target); + expect(next.copingWidthMm).toBe(edited.copingWidthMm); + expect(next.copingLengthMm).toBe(target.copingLengthMm); + }); + + it('returns the target unchanged when the 기준 pier has no overrides', () => { + const next = applyOverrides({ defaults, input: defaults, draft: defaults }, target); + expect(next).toEqual(target); + }); +});