diff --git a/src/core/abut/edit.ts b/src/core/abut/edit.ts index 670720b..20c463e 100644 --- a/src/core/abut/edit.ts +++ b/src/core/abut/edit.ts @@ -33,23 +33,32 @@ export function applyAbutEdit(rec: AbutRecord, key: AbutEditKey, value: number): return next; } -/** 좌우 같게 (기본): the form and dims apply to both sides. Off: each side keeps its own. */ -export function wingsLinked(rec: AbutRecord): boolean { - return rec.wingsLinked !== false; -} - +/** 날개벽 형식은 측별로 정한다 (2026-11-04: "좌우 같게" 없음). */ export function setWingForm(rec: AbutRecord, side: 'left' | 'right', form: WingForm): AbutRecord { const current = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm }; - if (wingsLinked(rec)) return { ...rec, wings: { left: form, right: form } }; return { ...rec, wings: { ...current, [side]: form } }; } -/** Turns 좌우 같게 on or off. Turning it on copies the left side (form and dims) to the right. */ -export function setWingsLinked(rec: AbutRecord, linked: boolean): AbutRecord { - if (!linked) return { ...rec, wingsLinked: false }; - const left = rec.wings?.left ?? 'general'; - const dims = rec.wingDims?.left; - return { ...rec, wingsLinked: true, wings: { left, right: left }, wingDims: dims ? { left: { ...dims }, right: { ...dims } } : undefined }; +/** + * 반대쪽에도 적용 (2026-11-04): copies this side's wing dims to the other side once (no lasting link). + * 두께 · 끝 높이 · 헌치는 늘 복사하고, 길이는 두 측의 형식이 같을 때만 복사한다 (단축형 길이 기본 = 뒷굽 끝). + * The other side must have a wing. + */ +export function copyWingDims(rec: AbutRecord, side: 'left' | 'right'): AbutRecord { + const other = side === 'left' ? 'right' : 'left'; + const forms = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm }; + if (forms[other] === 'none') throw new Error('반대쪽 날개벽이 없습니다.'); + const from = rec.wingDims?.[side] ?? {}; + const to: Partial = { ...(rec.wingDims?.[other] ?? {}) }; + for (const key of ['thicknessMm', 'endHeightMm', 'haunchMm'] as const) { + if (from[key] === undefined) delete to[key]; + else to[key] = from[key]; + } + if (forms[side] === forms[other]) { + if (from.lengthMm === undefined) delete to.lengthMm; + else to.lengthMm = from.lengthMm; + } + return { ...rec, wingDims: { ...(rec.wingDims ?? {}), [other]: to } }; } /** 날개벽 치수 입력 칸 (② 날개벽 카드, 시안 순서). 단축형에는 끝 높이가 없다. */ @@ -60,16 +69,12 @@ export const WING_DIM_FIELDS: { key: keyof WingDims; label: string; general: boo { key: 'haunchMm', label: '헌치 (mm, 300 × 300의 한 변)', general: true, short: true }, ]; -/** Changes one wing dim of a side (both sides when 좌우 같게). Length, thickness and end height must be > 0, the haunch ≥ 0. */ +/** Changes one wing dim of one side. Length, thickness and end height must be > 0, the haunch ≥ 0. */ export function applyWingDim(rec: AbutRecord, side: 'left' | 'right', key: keyof WingDims, value: number): AbutRecord { if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.'); - const probe = { ...WING_DIMS_DEFAULT, [key]: value }; - const issues = wingDimsIssues(probe); + const issues = wingDimsIssues({ ...WING_DIMS_DEFAULT, [key]: value }); if (issues.length > 0) throw new Error(issues[0]); - const sides: ('left' | 'right')[] = wingsLinked(rec) ? ['left', 'right'] : [side]; - const next = { left: { ...(rec.wingDims?.left ?? {}) }, right: { ...(rec.wingDims?.right ?? {}) } }; - for (const s of sides) next[s] = { ...next[s], [key]: value }; - return { ...rec, wingDims: next }; + return { ...rec, wingDims: { ...(rec.wingDims ?? {}), [side]: { ...(rec.wingDims?.[side] ?? {}), [key]: value } } }; } /** 받침 값 편집 (2026-11-03): z (패드 상면, m) or 교좌면 override (m, undefined = 자동 — 블럭 높이 규칙을 따른다). */ diff --git a/src/core/abut/parse.ts b/src/core/abut/parse.ts index 094b4f2..4e1228f 100644 --- a/src/core/abut/parse.ts +++ b/src/core/abut/parse.ts @@ -59,7 +59,7 @@ export interface AbutRecord { bearingRule?: Partial; /** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */ wingDims?: { left?: Partial; right?: Partial }; - /** 좌우 같게 (기본 true): 형식과 치수를 양쪽에 함께 적용한다. */ + /** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */ wingsLinked?: boolean; } diff --git a/src/main.ts b/src/main.ts index 12cd3ce..0cc8a50 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,9 +20,8 @@ import { SHAPE_EDIT_FIELDS, WING_DIM_FIELDS, restoreAbutDefault, + copyWingDims, setWingForm, - setWingsLinked, - wingsLinked, } from './core/abut/edit'; import { ABUT_SHAPE_DEFAULT, footingEnds, normalizeShape, pileRowsMm, resolveShape, wallThicknessMm, type AbutShape } from './core/abut/shape'; import { abutFootingPlanSvg } from './view/abut-footing-svg'; @@ -1003,7 +1002,7 @@ function dms(deg: number): string { return `${d}°${String(m).padStart(2, '0')}′${String(s).padStart(2, '0')}″`; } -/** ② 날개벽 카드 (시안, 2026-11-04): 좌우 · 편집할 측 · 형식 → 삽도 3개 → 측별 치수 → 자동 값 → 생성되는 날개벽 → 메모. */ +/** ② 날개벽 카드 (시안, 2026-11-04): 형식 1행(좌 · 우) → 삽도 3개 → 편집 중인 측의 치수 · 반대쪽에도 적용 → 자동 값 → 생성되는 날개벽 → 메모. */ function fillAbutWingCard( box: HTMLDivElement, rec: AbutRecord, @@ -1014,47 +1013,39 @@ function fillAbutWingCard( ): void { const NAME: Record = { general: '일반형', short: '단축형', none: '없음' }; const forms = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm }; - const linked = wingsLinked(rec); const side = state.abutWingSide; - const lastNo = rec.bearings.length; - const circled = (n: number) => ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'][n - 1] ?? String(n); - const sideName = (s: 'left' | 'right') => (s === 'left' ? '좌 · ① 쪽' : `우 · ${circled(lastNo)} 쪽`); - box.append( - optionTiles('좌우', [ - // 같게: the left form on both sides with "="; 따로: the current forms, or long / short when they happen to match. - { label: '좌우 같게', icon: WING_ICON.plan(forms.left, forms.left, undefined, true), on: linked, isDefault: true, pick: () => apply(setWingsLinked(rec, true)) }, - { - label: '따로 정하기', - icon: forms.left === forms.right ? WING_ICON.plan('general', 'short') : WING_ICON.plan(forms.left, forms.right), - on: !linked, - pick: () => apply(setWingsLinked(rec, false)), - }, - ]), - optionTiles( - '편집할 측', - (['left', 'right'] as const).map((s) => ({ - // The picture shows both wings in their current form, with this side drawn bold. - label: sideName(s), - icon: WING_ICON.plan(forms.left, forms.right, s), - on: side === s, - pick: () => { - state.abutWingSide = s; - renderAll(); - }, - })), - linked ? '좌우 같게: 형식과 치수가 양쪽에 함께 적용됩니다' : undefined, - ), - optionTiles( - `${side === 'left' ? '좌' : '우'} 날개벽 (${sideName(side).split(' · ')[1]}) 형식`, + const sideKo = (s: 'left' | 'right') => (s === 'left' ? '좌' : '우'); + // 날개벽 형식 1행 (2026-11-04): 좌 · 우 묶음을 나란히. 타일을 누르면 그 측의 형식이 바뀌고 그 측이 편집 대상이 된다. + const formsRow = el('div', undefined, 'abut-wing-forms'); + formsRow.append(el('span', '날개벽 형식', 'abut-grp-title')); + for (const s of ['left', 'right'] as const) { + const group = optionTiles( + sideKo(s), (['general', 'short', 'none'] as const).map((f) => ({ label: NAME[f], icon: WING_ICON.side(f), - on: forms[side] === f, + on: forms[s] === f, isDefault: f === 'general', - pick: () => apply(setWingForm(rec, side, f)), + pick: () => { + state.abutWingSide = s; + if (forms[s] === f) renderAll(); + else apply(setWingForm(rec, s, f)); + }, })), - ), - ); + ); + group.classList.add('compact'); + if (s === side) group.classList.add('editing'); + const title = group.querySelector('.abut-grp-title'); + if (title) { + title.title = `${sideKo(s)} 날개벽 치수 편집`; + title.addEventListener('click', () => { + state.abutWingSide = s; + renderAll(); + }); + } + formsRow.append(group); + } + box.append(formsRow); let model: AbutModel; let members: AbutMembers; let wings: WingSolid[]; @@ -1093,7 +1084,7 @@ function fillAbutWingCard( if (wing) { addFig( abutWingSideSvg({ - name: `${side === 'left' ? '좌' : '우'} 날개벽`, + name: `${sideKo(side)} 날개벽`, form: wing.form, lengthM: wing.lengthM, endHeightM: wing.endHeightM, @@ -1104,7 +1095,7 @@ function fillAbutWingCard( seatEl: rec.seatEl, footingTopEl: members.footingTopEl, }), - `${side === 'left' ? '좌' : '우'} 날개벽 측면 · ${NAME[wing.form]} (날개벽 축 방향으로 잘라 본 모습) · 축척 같음`, + `${sideKo(side)} 날개벽 측면 · ${NAME[wing.form]} (날개벽 축 방향으로 잘라 본 모습) · 축척 같음`, ); } addFig( @@ -1122,7 +1113,7 @@ function fillAbutWingCard( box.append(figs); // 측별 치수 (선택한 측의 형식에 맞는 칸). const form = forms[side]; - box.append(el('p', `${side === 'left' ? '좌' : '우'} 날개벽 (${sideName(side).split(' · ')[1]}) · ${NAME[form]}`, 'lbl')); + box.append(el('p', `${sideKo(side)} 날개벽 · ${NAME[form]} (편집 중)`, 'lbl')); if (form === 'none' || !wing) { box.append(el('p', '입력 없음 · 접속슬래브 측면은 교좌면 끝선에 맞춥니다.', 'muted small')); } else { @@ -1141,8 +1132,26 @@ function fillAbutWingCard( fail(err, input, String(dims[f.key])); } }); - box.append(lab, input, el('span', `기본 ${defaults[f.key]}${linked ? ' · 양쪽' : ''}`, 'muted small')); + box.append(lab, input, el('span', `기본 ${defaults[f.key]}`, 'muted small')); } + // 반대쪽에도 적용: one copy now (두께 · 끝 높이 · 헌치, and the 길이 only when both forms match). + const other = side === 'left' ? 'right' : 'left'; + const copy = el('button', `반대쪽(${sideKo(other)})에도 적용`, 'btn small'); + copy.type = 'button'; + copy.disabled = forms[other] === 'none'; + copy.title = forms[other] === 'none' + ? '반대쪽 날개벽이 없습니다' + : forms[side] === forms[other] + ? '길이 · 두께 · 끝 높이 · 헌치를 한 번 복사합니다 (이후 연결되지 않음)' + : '형식이 달라 길이는 복사하지 않습니다. 두께 · 끝 높이 · 헌치만 한 번 복사합니다'; + copy.addEventListener('click', () => { + try { + apply(copyWingDims(rec, side)); + } catch (err) { + $('abut-panel-plan-msg').textContent = (err as Error).message; + } + }); + box.append(el('span', ''), copy, el('span', forms[side] === forms[other] ? '길이 포함' : '형식이 달라 길이 제외', 'muted small')); const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small')); const slope = model.wing?.slopePct ?? 0; auto(`경사 (자동, ${rec.type} = ${rec.type === 'A1' ? '−i' : '+i'})`, `${slope >= 0 ? '+' : ''}${slope.toFixed(2)} %`, '종단경사에서'); @@ -1159,7 +1168,7 @@ function fillAbutWingCard( box.append( el( 'p', - '시안 메모 · 형식은 ②에서만 정합니다(엑셀 입력 없음). "좌우 같게"를 끄면 우측을 따로 정할 수 있습니다. 단축형은 하단이 모두 확대기초 상면이고 길이 기본값은 뒷굽 끝이며, 늘리면 뒷굽 밖 구간의 하단이 떠 있게 됩니다(정보 AI02). 두께는 접속슬래브 폭에, 헌치는 연직 헌치와 저판 상면 헌치에 함께 반영됩니다. 모서리 상세의 슬래브 모따기는 3D에는 아직 그리지 않습니다.', + '시안 메모 · 형식은 ②에서만 정합니다(엑셀 입력 없음). 좌 · 우 형식과 치수는 따로 정하고, "반대쪽에도 적용"으로 치수를 한 번 복사할 수 있습니다(길이는 형식이 같을 때만). 단축형은 하단이 모두 확대기초 상면이고 길이 기본값은 뒷굽 끝이며, 늘리면 뒷굽 밖 구간의 하단이 떠 있게 됩니다(정보 AI02). 두께는 접속슬래브 폭에, 헌치는 연직 헌치와 저판 상면 헌치에 함께 반영됩니다. 모서리 상세의 슬래브 모따기는 3D에는 아직 그리지 않습니다.', 'muted small abut-memo', ), ); diff --git a/src/style.css b/src/style.css index 0f4f1d3..47b025c 100644 --- a/src/style.css +++ b/src/style.css @@ -940,3 +940,11 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; #abut-panel-plan-wing > p, #abut-panel-plan-wing > .abut-wall-figs { grid-column: 1 / -1; } #abut-panel-plan .abut-wing-figs { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1fr); } @media (max-width: 1100px) { #abut-panel-plan .abut-wing-figs { grid-template-columns: minmax(0, 1fr); } } + +/* ② 날개벽 형식 1행 (2026-11-04): 좌 · 우 묶음을 나란히, 작은 타일, 편집 중인 측은 진한 테두리. */ +#abut-panel-plan .abut-wing-forms { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px 18px; } +#abut-panel-plan .abut-wing-forms > .abut-grp-title { flex-basis: 100%; } +#abut-panel-plan .abut-wing-forms .abut-grp { margin: 0; padding: 6px 8px 8px; border: 1px solid transparent; border-radius: 8px; } +#abut-panel-plan .abut-wing-forms .abut-grp.editing { border-color: #1f5fa8; background: #f8fbfe; } +#abut-panel-plan .abut-wing-forms .abut-grp .abut-grp-title { cursor: pointer; font-weight: 600; color: #18212b; } +#abut-panel-plan .abut-grp.compact .abut-opt { width: 64px; min-height: 48px; padding: 4px 2px; } diff --git a/tests/abut-edit.test.ts b/tests/abut-edit.test.ts index af3a9ff..fc9ac08 100644 --- a/tests/abut-edit.test.ts +++ b/tests/abut-edit.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import * as XLSX from 'xlsx'; import { describe, expect, it } from 'vitest'; -import { applyAbutEdit, applyBearingEdit, restoreAbutDefault, setWingForm, setWingsLinked, applyWingDim } from '../src/core/abut/edit'; +import { applyAbutEdit, applyBearingEdit, restoreAbutDefault, setWingForm, applyWingDim, copyWingDims } from '../src/core/abut/edit'; import { parseAbutments } from '../src/core/abut/parse'; const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); @@ -25,23 +25,27 @@ describe('교대 입력 편집 (교대 편집 카드)', () => { expect(() => applyAbutEdit(rec, 'seatEl', rec.foundationBottomEl)).toThrow('기초 하단 EL보다 높아야'); }); - it('a wing form change sets both sides while 좌우 같게 (default), one side once it is off', () => { - expect(setWingForm(rec, 'left', 'short').wings).toEqual({ left: 'short', right: 'short' }); - const apart = setWingsLinked(rec, false); - expect(setWingForm(apart, 'left', 'short').wings).toEqual({ left: 'short', right: 'general' }); + it('a wing form change sets one side and keeps the other (좌 · 우 따로)', () => { + expect(setWingForm(rec, 'left', 'short').wings).toEqual({ left: 'short', right: 'general' }); expect(rec.wings).toBeUndefined(); }); - it('wing dims: 좌우 같게 applies to both sides, off applies to one; turning it on copies the left side', () => { - const both = applyWingDim(rec, 'left', 'lengthMm', 6000); - expect(both.wingDims).toEqual({ left: { lengthMm: 6000 }, right: { lengthMm: 6000 } }); - const apart = applyWingDim(setWingsLinked(rec, false), 'right', 'thicknessMm', 500); - expect(apart.wingDims).toEqual({ left: {}, right: { thicknessMm: 500 } }); - const relinked = setWingsLinked(applyWingDim(apart, 'left', 'lengthMm', 5000), true); - expect(relinked.wingDims).toEqual({ left: { lengthMm: 5000 }, right: { lengthMm: 5000 } }); + it('wing dims are per side; 0 thickness and a negative haunch are refused', () => { + expect(applyWingDim(rec, 'left', 'lengthMm', 6000).wingDims).toEqual({ left: { lengthMm: 6000 } }); expect(() => applyWingDim(rec, 'left', 'thicknessMm', 0)).toThrow('두께'); expect(() => applyWingDim(rec, 'left', 'haunchMm', -1)).toThrow('헌치'); }); + + it('반대쪽에도 적용: copies 두께 · 끝 높이 · 헌치 once, and the 길이 only when both forms match', () => { + let r = applyWingDim(applyWingDim(rec, 'right', 'lengthMm', 6000), 'right', 'thicknessMm', 500); + expect(copyWingDims(r, 'right').wingDims?.left).toEqual({ lengthMm: 6000, thicknessMm: 500 }); + r = setWingForm(r, 'left', 'short'); + expect(copyWingDims(r, 'right').wingDims?.left).toEqual({ thicknessMm: 500 }); // 형식이 달라 길이는 복사하지 않음 + expect(() => copyWingDims(setWingForm(r, 'left', 'none'), 'right')).toThrow('반대쪽'); + // one copy, no link: a later change on the right does not move the left + const copied = copyWingDims(applyWingDim(rec, 'right', 'haunchMm', 200), 'right'); + expect(applyWingDim(copied, 'right', 'haunchMm', 250).wingDims?.left).toEqual({ haunchMm: 200 }); + }); }); describe('교대 기본값 복원과 받침 편집', () => {