feat(plan): 날개벽 형식을 좌 · 우 1행으로 요약하고 "반대쪽에도 적용" 추가

- 선택 줄을 "날개벽 형식  좌 [일반형 · 단축형 · 없음] │ 우 [일반형 · 단축형 · 없음]" 한 줄로 합침 (작은 그림 타일). "좌우" · "편집할 측" 줄과 ① 쪽 / ⑤ 쪽 표시는 없앰 (위치는 모델창에서 확인)
- 타일이나 "좌" / "우" 글자를 누르면 그 측이 편집 대상이 되고, 편집 중인 묶음은 진한 테두리로 표시. 아래 치수 입력과 측면 삽도가 그 측을 따름
- "좌우 같게" 삭제: 형식과 치수는 측별로만 저장. 예전 파일의 wingsLinked 값은 읽기만 하고 쓰지 않음
- 반대쪽에도 적용 (copyWingDims): 두께 · 끝 높이 · 헌치를 한 번 복사하고, 길이는 두 측 형식이 같을 때만 복사. 반대쪽이 없음이면 비활성
- 테스트 갱신. 전체 670건 통과, 타입 검사 · 빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Opus 5.5 committed 2026-11-04 09:36:53 +09:00
1 parent 4ef3650454
commit 886347471e
5 files changed
+101 -75

No files matched your search

+24 -19
View File
@@ -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<WingDims> = { ...(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 = 자동 — 블럭 높이 규칙을 따른다). */
+1 -1
View File
@@ -59,7 +59,7 @@ export interface AbutRecord {
bearingRule?: Partial<AbutBearingRule>;
/** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */
wingDims?: { left?: Partial<WingDims>; right?: Partial<WingDims> };
/** 좌우 같게 (기본 true): 형식과 치수를 양쪽에 함께 적용한다. */
/** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */
wingsLinked?: boolean;
}
+52 -43
View File
@@ -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<WingForm, string> = { 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<HTMLElement>('.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) {
$<HTMLElement>('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',
),
);
+8
View File
@@ -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; }