feat(plan): 날개벽 형식을 좌 · 우 1행으로 요약하고 "반대쪽에도 적용" 추가
- 선택 줄을 "날개벽 형식 좌 [일반형 · 단축형 · 없음] │ 우 [일반형 · 단축형 · 없음]" 한 줄로 합침 (작은 그림 타일). "좌우" · "편집할 측" 줄과 ① 쪽 / ⑤ 쪽 표시는 없앰 (위치는 모델창에서 확인) - 타일이나 "좌" / "우" 글자를 누르면 그 측이 편집 대상이 되고, 편집 중인 묶음은 진한 테두리로 표시. 아래 치수 입력과 측면 삽도가 그 측을 따름 - "좌우 같게" 삭제: 형식과 치수는 측별로만 저장. 예전 파일의 wingsLinked 값은 읽기만 하고 쓰지 않음 - 반대쪽에도 적용 (copyWingDims): 두께 · 끝 높이 · 헌치를 한 번 복사하고, 길이는 두 측 형식이 같을 때만 복사. 반대쪽이 없음이면 비활성 - 테스트 갱신. 전체 670건 통과, 타입 검사 · 빌드 통과. 브라우저 시각 확인은 미실시 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ef3650454
commit
886347471e
5 files changed
+101
-75
No files matched your search
+24
-19
@@ -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 = 자동 — 블럭 높이 규칙을 따른다). */
|
||||
|
||||
@@ -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
@@ -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',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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; }
|
||||
+16
-12
@@ -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('교대 기본값 복원과 받침 편집', () => {
|
||||
|
||||
Reference in new issue
Block a user