feat(abut): ② 날개벽 카드를 시안대로 작성 (좌우 같게 · 측별 치수 · 삽도 3개)

- 선택 줄 (타일): 좌우 같게(기본) / 따로 정하기, 편집할 측 (좌 · ① 쪽 / 우 · ⑤ 쪽), 형식 일반형(기본) / 단축형 / 없음
- 측별 치수 입력 (WingDims, 교대 기록 wingDims): 길이 6500, 두께 450, 끝 높이 1500(일반형), 헌치 300. 단축형 길이 기본 = 뒷굽 끝(1.807 m). 좌우 같게이면 양쪽에 함께 적용, 켤 때 좌측 값을 우측에 복사
- 연동: 두께 → 날개벽 3D · 접속슬래브 폭(geometry slabWidthMm) · 슬래브 평면, 헌치 → 연직 헌치 · 저판 상면 헌치 (0이면 없음)
- 자동 값: 경사 (A1 = −i), 등단면 · 변화단면 경계(뒷굽 끝), 끝 EL 상단 / 하단 (가정), 단축형 하단 = 확대기초 상면 (뒷굽 밖은 AI02 안내)
- 삽도 3개 (src/view/abut-wing-svg.ts): 평면(좌우 날개벽 · 접속슬래브, abutPlanOutline 공유), 측면(수평 단 · 뒷굽 끝), 날개벽–흉벽 모서리 모식도(헌치 · 브라켓 · 슬래브 모따기 · 간격)
- "생성되는 날개벽" 줄과 시안 메모. 프로젝트 파일 검사에 wingDims · wingsLinked 추가
- 테스트 7건 추가 · 갱신. 전체 669건 통과, 타입 검사 · 빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Opus 5.5 committed 2026-11-04 09:20:29 +09:00
1 parent a025a61209
commit 80e5302192
12 files changed
+610 -53

No files matched your search

+187 -5
View File
@@ -8,7 +8,22 @@ import { parseAbutments, toAbutInput, type AbutRecord } from './core/abut/parse'
import { buildAbut } from './core/abut/geometry';
import { structureOrder } from './core/abut/order';
import { ABUT_DETAIL_LABELS, ABUT_SEGMENT_HEADER, abutInputRows, abutSegmentRows, abutSummaryRows } from './core/abut/summary';
import { applyAbutEdit, applyBearingEdit, applyBearingRule, applyShapeEdit, applyShapeEdits, ABUT_EDIT_FIELDS, BEARING_RULE_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit';
import {
applyAbutEdit,
applyBearingEdit,
applyBearingRule,
applyShapeEdit,
applyShapeEdits,
applyWingDim,
ABUT_EDIT_FIELDS,
BEARING_RULE_FIELDS,
SHAPE_EDIT_FIELDS,
WING_DIM_FIELDS,
restoreAbutDefault,
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';
import { abutWallElevationSvg, abutWallSectionSvg, type AbutWallDrawing } from './view/abut-wall-svg';
@@ -27,8 +42,9 @@ import {
import { abutMemberRows, ABUT_MEMBER_EDITABLE, type AbutMemberId } from './core/abut/member-list';
import type { AbutModel, WingForm } from './core/abut/geometry';
import { buildMembers, type AbutMembers } from './core/abut/members';
import { buildWings } from './core/abut/wings';
import { abutSkewK, buildAbutGroup, cameraForBox } from './view/abut-scene';
import { buildWings, resolveWingDims, type WingSolid } from './core/abut/wings';
import { abutPlanOutline, abutSkewK, buildAbutGroup, cameraForBox } from './view/abut-scene';
import { abutWingCornerSvg, abutWingPlanSvg, abutWingSideSvg } from './view/abut-wing-svg';
import { fitCamera } from './view/fit';
import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
@@ -151,6 +167,8 @@ const state = {
abutMember: 'bearing' as AbutMemberId,
/** The bearing the ② 교량받침 삽도 draw (상세 보기). */
abutBearingNo: 1,
/** The side the ② 날개벽 card edits (편집할 측). */
abutWingSide: 'left' as 'left' | 'right',
/** The editable part of the 교대 panel is open (2026-11-03). */
abutEditOpen: false,
/** 교대 기본값 (가져온 그대로), for 기본값 복원. */
@@ -590,7 +608,7 @@ function setupScene(): void {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings), ref.pivot, rec.groundEl, groundModel?.terrainGrid);
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), ref.pivot, rec.groundEl, groundModel?.terrainGrid);
group.visible = abutShown(state.stage, state.abutActive, rec.name);
// The 부재 표시 toggles hold for a new abutment group too.
for (const [name, on] of Object.entries(state.abutVisible)) {
@@ -948,6 +966,168 @@ function fillAbutBearingCard(
box.append(el('p', `검사 · ${abutBearingChecks(model, members, rule, H).join(' · ')}`, 'muted small abut-checks'));
}
/** skew (deg) as 95°02′52″. */
function dms(deg: number): string {
const total = Math.round(Math.abs(deg) * 3600);
const d = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return `${d}°${String(m).padStart(2, '0')}′${String(s).padStart(2, '0')}″`;
}
/** ② 날개벽 카드 (시안, 2026-11-04): 좌우 · 편집할 측 · 형식 → 삽도 3개 → 측별 치수 → 자동 값 → 생성되는 날개벽 → 메모. */
function fillAbutWingCard(
box: HTMLDivElement,
rec: AbutRecord,
apply: (next: AbutRecord) => void,
fail: (err: unknown, input: HTMLInputElement, back: string) => void,
idOf: (suffix: string) => string,
numberInput: (id: string, value: string) => HTMLInputElement,
): 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('좌우', [
{ label: '좌우 같게', on: linked, isDefault: true, pick: () => apply(setWingsLinked(rec, true)) },
{ label: '따로 정하기', on: !linked, pick: () => apply(setWingsLinked(rec, false)) },
]),
optionTiles(
'편집할 측',
(['left', 'right'] as const).map((s) => ({
label: `${sideName(s)} · ${NAME[forms[s]]}`,
on: side === s,
pick: () => {
state.abutWingSide = s;
renderAll();
},
})),
linked ? '좌우 같게: 형식과 치수가 양쪽에 함께 적용됩니다' : undefined,
),
optionTiles(
`${side === 'left' ? '좌' : '우'} 날개벽 (${sideName(side).split(' · ')[1]}) 형식`,
(['general', 'short', 'none'] as const).map((f) => ({
label: NAME[f],
on: forms[side] === f,
isDefault: f === 'general',
pick: () => apply(setWingForm(rec, side, f)),
})),
),
);
let model: AbutModel;
let members: AbutMembers;
let wings: WingSolid[];
try {
model = buildAbut(toAbutInput(rec));
members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
wings = buildWings(model, members, forms, rec.wingDims);
} catch {
box.append(el('p', '교대 모델을 만들 수 없어 날개벽을 그리지 않습니다.', 'muted small'));
return;
}
const shapeAll = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) };
const wing = wings.find((w) => w.side === side);
const leftWing = wings.find((w) => w.side === 'left');
// 뒷굽 끝까지의 날개벽 거리 (단축형 길이의 기본값); with no wing drawn, the 뒷굽 width is a fair stand-in.
const heelM = wing?.heelDistanceM ?? leftWing?.heelDistanceM ?? Math.abs(members.shape.footingBackM);
// 삽도 3개.
const figs = el('div', undefined, 'abut-wall-figs abut-wing-figs');
const addFig = (svg: string, caption: string) => {
const fig = el('figure', undefined, 'abut-fig');
fig.innerHTML = svg;
fig.append(el('figcaption', caption, 'muted small'));
figs.append(fig);
};
addFig(
abutWingPlanSvg({
lengthM: model.length,
outline: abutPlanOutline(model, members, wings),
leftLabel: `좌 · ${NAME[forms.left]}`,
rightLabel: `우 · ${NAME[forms.right]}`,
leftSEnd: leftWing?.sEnd ?? (model.bearings[0].s < model.length / 2 ? 0 : model.length),
slabWidthM: (model.wing?.slabWidthMm ?? 0) / 1000,
}),
'평면 · 좌우 날개벽과 접속슬래브 · 축척 같음',
);
if (wing) {
addFig(
abutWingSideSvg({
name: `${side === 'left' ? '좌' : '우'} 날개벽`,
form: wing.form,
lengthM: wing.lengthM,
endHeightM: wing.endHeightM,
heelDistanceM: wing.heelDistanceM,
topLine: wing.topLine,
bottomLine: wing.bottomLine,
bands: wing.bands,
seatEl: rec.seatEl,
footingTopEl: members.footingTopEl,
}),
`${side === 'left' ? '좌' : '우'} 날개벽 측면 · ${NAME[wing.form]} (날개벽 축 방향으로 잘라 본 모습) · 축척 같음`,
);
}
addFig(
abutWingCornerSvg({
parapetMm: shapeAll.parapetThicknessMm,
bracketMm: shapeAll.bracketWidthMm,
wingThicknessMm: Math.round((wing?.thicknessM ?? 0.45) * 1000),
haunchMm: Math.round((wing?.haunchM ?? 0.3) * 1000),
slabGapWingMm: 20,
slabGapParapetMm: 5,
chamferMm: 300,
}),
'날개벽–흉벽 모서리 평면 · 모식도 · 접속슬래브 모따기',
);
box.append(figs);
// 측별 치수 (선택한 측의 형식에 맞는 칸).
const form = forms[side];
box.append(el('p', `${side === 'left' ? '좌' : '우'} 날개벽 (${sideName(side).split(' · ')[1]}) · ${NAME[form]}`, 'lbl'));
if (form === 'none' || !wing) {
box.append(el('p', '입력 없음 · 접속슬래브 측면은 교좌면 끝선에 맞춥니다.', 'muted small'));
} else {
const dims = resolveWingDims(form, rec.wingDims?.[side], heelM);
const defaults = resolveWingDims(form, undefined, heelM);
for (const f of WING_DIM_FIELDS.filter((x) => (form === 'general' ? x.general : x.short))) {
const id = idOf(`wing-${side}-${f.key}`);
const lab = el('label', form === 'short' && f.key === 'lengthMm' ? '길이 (mm) · 기본 = 뒷굽 끝' : f.label);
lab.htmlFor = id;
const input = numberInput(id, String(dims[f.key]));
if (dims[f.key] !== defaults[f.key]) input.classList.add('changed-input');
input.addEventListener('change', () => {
try {
apply(applyWingDim(rec, side, f.key, Number(input.value)));
} catch (err) {
fail(err, input, String(dims[f.key]));
}
});
box.append(lab, input, el('span', `기본 ${defaults[f.key]}${linked ? ' · 양쪽' : ''}`, '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)} %`, '종단경사에서');
if (form === 'general') {
auto('등단면 · 변화단면 경계 (자동)', `${wing.heelDistanceM.toFixed(3)} m`, '뒷굽 끝');
auto('끝 EL 상단 / 하단', `${wing.topEndEl.toFixed(3)} / ${wing.bottomEndEl.toFixed(3)}`, '가정');
} else {
auto('하단 (자동)', `확대기초 상면 ${members.footingTopEl.toFixed(3)}`, wing.lengthM > wing.heelDistanceM + 1e-6 ? '뒷굽 밖은 하단이 떠 있음 (정보 AI02)' : '');
auto('끝 EL 상단', wing.topEndEl.toFixed(3), '가정');
}
}
const made = (s: 'left' | 'right') => (forms[s] === 'none' ? `${s === 'left' ? '좌' : '우'} 없음` : `${s === 'left' ? '좌' : '우'} ${NAME[forms[s]]}`);
box.append(el('p', `생성되는 날개벽: ${made('left')} · ${made('right')} · 각도는 좌우 모두 skew ${dms(rec.skewDeg)}`, 'muted small'));
box.append(
el(
'p',
'시안 메모 · 형식은 ②에서만 정합니다(엑셀 입력 없음). "좌우 같게"를 끄면 우측을 따로 정할 수 있습니다. 단축형은 하단이 모두 확대기초 상면이고 길이 기본값은 뒷굽 끝이며, 늘리면 뒷굽 밖 구간의 하단이 떠 있게 됩니다(정보 AI02). 두께는 접속슬래브 폭에, 헌치는 연직 헌치와 저판 상면 헌치에 함께 반영됩니다. 모서리 상세의 슬래브 모따기는 3D에는 아직 그리지 않습니다.',
'muted small abut-memo',
),
);
}
/** 확대기초 · 말뚝 카드의 자동 값, 열 위치, 열별 본수 표(− / +), 메모 (시안, 2026-11-04). 말뚝 길이는 ①에서 고친다. */
function fillAbutFootingExtras(
box: HTMLDivElement,
@@ -1121,7 +1301,9 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
});
basic.append(lab, input);
}
for (const side of ['left', 'right'] as const) {
// ② draws the 날개벽 card (시안); ① keeps the two form selects.
if (planCard) fillAbutWingCard(wingBox, rec, apply, fail, idOf, numberInput);
for (const side of planCard ? [] : (['left', 'right'] as const)) {
const id = idOf(`wing-${side}`);
const lab = el('label', side === 'left' ? '좌 날개벽 (① 쪽)' : '우 날개벽 (n 쪽)');
lab.htmlFor = id;