feat(abut): 접속슬래브 카드 — 매립형 / 노출형 형식, 경사, 치수 입력, 삽도 3개, 모따기 3D 반영

- 노출형 (삽도 기준, QA-8 가정): 상면 = 흉벽 상단, 끝은 브라켓 위(끝 두께 = 브라켓 높이), 헌치 길이 기본 2000
- 형식을 고르면 경사가 기본값으로 바뀜 (매립형 하향 4 %, 노출형 종단경사), 경사 타일로 변경 가능
- 길이 · 두께 · 흉벽과 간격 · 날개벽과 간격 입력이 3D, 폭, 날개벽 평면에 반영
- 슬래브 모따기 = 날개벽 헌치 (3D와 평면), 날개벽 없는 측은 skew 교좌면 끝선
- 3D 슬래브를 단면 기준 입체로 다시 만듦, 프로젝트 파일 저장 · 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 10:11:39 +09:00
1 parent fc0370883f
commit 4b8b80ae97
14 files changed
+854 -60

No files matched your search

+177 -7
View File
@@ -22,7 +22,13 @@ import {
restoreAbutDefault,
copyWingDims,
setWingForm,
applySlabEdit,
setSlabForm,
setSlabSlope,
SLAB_EDIT_FIELDS,
} from './core/abut/edit';
import { ABUT_SLAB_DEFAULT, resolveSlab, SLAB_FORM_LABEL, slabDefaultSlope, slabGradePct, slabSection, slabSlopeLabel, type SlabForm, type SlabSlope } from './core/abut/slab';
import { abutSlabJointSvg, abutSlabPlanSvg, abutSlabSectionSvg } from './view/abut-slab-svg';
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';
@@ -607,7 +613,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, rec.wingDims), ref.pivot, rec.groundEl, groundModel?.terrainGrid);
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), ref.pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab);
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)) {
@@ -737,7 +743,7 @@ const ABUT_QA_NOTES = [
const ABUT_SLOTS = {
shape: { root: 'abut-panel', name: 'abut-panel-name', input: 'abut-panel-input', toggle: 'abut-panel-edit-toggle', editor: 'abut-panel-editor', summary: 'abut-panel-summary', basic: 'abut-panel-basic', bearings: 'abut-panel-bearings', shape: 'abut-panel-shape', msg: 'abut-panel-msg', prefix: 'abs' },
// ② has no 편집 toggle and no basic items: the card sits under the 3D view and shows the picked member (2026-11-03).
plan: { wing: 'abut-panel-plan-wing', footing: 'abut-panel-plan-footing', root: 'abut-panel-plan', name: 'abut-panel-plan-name', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' },
plan: { wing: 'abut-panel-plan-wing', slab: 'abut-panel-plan-slab', footing: 'abut-panel-plan-footing', root: 'abut-panel-plan', name: 'abut-panel-plan-name', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' },
} as const;
/** Shows the 교대 입력 in place of the pier info when an abutment is selected (2026-11-03). */
@@ -1073,7 +1079,7 @@ function fillAbutWingCard(
addFig(
abutWingPlanSvg({
lengthM: model.length,
outline: abutPlanOutline(model, members, wings),
outline: abutPlanOutline(model, members, wings, resolveSlab(rec.slab)),
leftLabel: `좌 · ${NAME[forms.left]}`,
rightLabel: `우 · ${NAME[forms.right]}`,
leftSEnd: leftWing?.sEnd ?? (model.bearings[0].s < model.length / 2 ? 0 : model.length),
@@ -1112,9 +1118,9 @@ function fillAbutWingCard(
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,
slabGapWingMm: resolveSlab(rec.slab).wingGapMm,
slabGapParapetMm: resolveSlab(rec.slab).parapetGapMm,
chamferMm: Math.round((wing?.haunchM ?? 0.3) * 1000),
}),
'날개벽–흉벽 모서리 평면 · 모식도 · 접속슬래브 모따기',
);
@@ -1176,7 +1182,165 @@ function fillAbutWingCard(
box.append(
el(
'p',
'시안 메모 · 형식은 ②에서만 정합니다(엑셀 입력 없음). 좌 · 우 형식과 치수는 따로 정하고, "반대쪽에도 적용"으로 치수를 한 번 복사할 수 있습니다(길이는 형식이 같을 때만). 단축형은 하단이 모두 확대기초 상면이고 길이 기본값은 뒷굽 끝이며, 늘리면 뒷굽 밖 구간의 하단이 떠 있게 됩니다(정보 AI02). 두께는 접속슬래브 폭에, 헌치는 연직 헌치와 저판 상면 헌치에 함께 반영됩니다. 모서리 상세의 슬래브 모따기는 3D에는 아직 그리지 않습니다.',
'시안 메모 · 형식은 ②에서만 정합니다(엑셀 입력 없음). 좌 · 우 형식과 치수는 따로 정하고, "반대쪽에도 적용"으로 치수를 한 번 복사할 수 있습니다(길이는 형식이 같을 때만). 단축형은 하단이 모두 확대기초 상면이고 길이 기본값은 뒷굽 끝이며, 늘리면 뒷굽 밖 구간의 하단이 떠 있게 됩니다(정보 AI02). 두께는 접속슬래브 폭에, 헌치는 연직 헌치와 저판 상면 헌치, 접속슬래브 모따기에 함께 반영됩니다.',
'muted small abut-memo',
),
);
}
/**
* 접속슬래브 타일 그림 (2026-11-04, 48 × 24, 사용자 삽도 기준): 오른쪽이 흉벽 · 브라켓 단차, 왼쪽으로 슬래브가 뻗는다.
* 매립형 = 흉벽 상단 아래 일정 두께, 노출형 = 상면이 흉벽 상단과 같고 끝에 헌치. 경사는 하향(왼쪽이 낮음) / 종단경사(높음).
*/
const SLAB_ICON = {
section(form: SlabForm, slope: SlabSlope): string {
const wall = '<path d="M31 22V12h3V2h4v20z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>';
const fall = slope === 'down4';
const slab =
form === 'buried'
? fall ? 'M4 11L30 8v4L4 15z' : 'M4 6L30 8v4L4 10z'
: fall ? 'M4 4.5L30 2v10L23 6.5L4 8.5z' : 'M4 0.8L30 2v10L23 5.5L4 4.8z';
return `${wall}<path d="${slab}" fill="currentColor" fill-opacity="0.18" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>`;
},
};
/** ② 접속슬래브 카드 (시안, 2026-11-04): 형식 · 경사 타일 → 삽도 3개 → 입력 → 자동 값 → 메모. */
function fillAbutSlabCard(
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 slab = resolveSlab(rec.slab);
const say = (err: unknown) => ($<HTMLElement>('abut-panel-plan-msg').textContent = (err as Error).message);
const tiles = el('div', undefined, 'abut-tile-row');
tiles.append(
optionTiles(
'형식',
(['buried', 'exposed'] as const).map((f) => ({
label: SLAB_FORM_LABEL[f],
icon: SLAB_ICON.section(f, slabDefaultSlope(f)),
on: slab.form === f,
isDefault: f === 'buried',
pick: () => {
if (slab.form === f) return;
try {
apply(setSlabForm(rec, f));
} catch (err) {
say(err);
}
},
})),
),
optionTiles(
'경사',
(['down4', 'grade'] as const).map((s) => ({
label: s === 'down4' ? '하향 4 %' : '종단경사 따라',
icon: SLAB_ICON.section(slab.form, s),
on: slab.slope === s,
isDefault: s === slabDefaultSlope(slab.form),
pick: () => {
if (slab.slope !== s) apply(setSlabSlope(rec, s));
},
})),
),
);
box.append(tiles);
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, rec.wings, rec.wingDims);
} catch {
box.append(el('p', '교대 모델을 만들 수 없어 접속슬래브를 그리지 않습니다.', 'muted small'));
return;
}
const wingSlope = model.wing?.slopePct ?? 0;
const gradePct = slabGradePct(slab, wingSlope);
const slopeText = slabSlopeLabel(slab, wingSlope);
const widthM = (model.wing?.slabWidthMm ?? Math.round(model.length * 1000)) / 1000;
const leftWing = wings.find((w) => w.side === 'left');
const rightWing = wings.find((w) => w.side === 'right');
const chamfer = { left: Math.round((leftWing?.haunchM ?? 0) * 1000), right: Math.round((rightWing?.haunchM ?? 0) * 1000) };
// 단면은 교대축 가운데의 흉벽 상단에서 잰다.
const parapetTopMid = (members.parapetTopStartEl + members.parapetTopEndEl) / 2;
const sh = members.shape;
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(
abutSlabPlanSvg({
outline: abutPlanOutline(model, members, wings, slab),
leftSEnd: leftWing?.sEnd ?? (model.bearings[0].s < model.length / 2 ? 0 : model.length),
widthM,
lengthMm: slab.lengthMm,
chamferMm: chamfer,
}),
'평면 · 날개벽 사이 (skew 평행사변형) · 모따기 = 날개벽 헌치 · 축척 같음',
);
addFig(
abutSlabSectionSvg({
form: slab.form,
slopeLabel: slopeText,
section: slabSection(slab, gradePct, sh),
parapetTopEl: parapetTopMid,
bracketHeightM: sh.bracketHeightM,
parapetBackM: sh.parapetBackM,
wallBackM: sh.wallBackM,
lengthMm: slab.lengthMm,
thicknessMm: slab.thicknessMm,
parapetGapMm: slab.parapetGapMm,
haunchLengthMm: slab.haunchLengthMm,
}),
`단면 · 교대축 직각 (교대축 가운데) · ${SLAB_FORM_LABEL[slab.form]} · 경사 ${slopeText} · 축척 같음`,
);
addFig(
abutSlabJointSvg({
form: slab.form,
gradePct,
parapetTopEl: parapetTopMid,
bracketHeightM: sh.bracketHeightM,
parapetGapMm: slab.parapetGapMm,
thicknessMm: slab.thicknessMm,
}),
'흉벽–접속슬래브 접합 상세 · 모식도 (부속재는 표시용)',
);
box.append(figs);
for (const f of SLAB_EDIT_FIELDS) {
const id = idOf(`slab-${f.key}`);
const lab = el('label', f.label);
lab.htmlFor = id;
const input = numberInput(id, String(slab[f.key]));
if (slab[f.key] !== ABUT_SLAB_DEFAULT[f.key]) input.classList.add('changed-input');
const off = f.exposedOnly && slab.form !== 'exposed';
input.disabled = Boolean(off);
input.addEventListener('change', () => {
try {
apply(applySlabEdit(rec, f.key, Number(input.value)));
} catch (err) {
fail(err, input, String(slab[f.key]));
}
});
box.append(lab, input, el('span', off ? '노출형만' : `기본 ${ABUT_SLAB_DEFAULT[f.key]}${f.note ? ` · ${f.note}` : ''}`, '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 side = (mm: number, w: WingSolid | undefined) => (w ? (mm > 0 ? `${mm} × ${mm}` : '없음') : '날개벽 없음');
auto('모따기 (자동)', `좌 ${side(chamfer.left, leftWing)} · 우 ${side(chamfer.right, rightWing)}`, '= 그 측 날개벽 헌치');
auto('폭 (자동)', `${widthM.toFixed(3)} m`, '도면 11.670 · QA-6');
auto('경사', slopeText, slab.slope === slabDefaultSlope(slab.form) ? `${SLAB_FORM_LABEL[slab.form]} 기본` : `변경 (${SLAB_FORM_LABEL[slab.form]} 기본 = ${slabDefaultSlope(slab.form) === 'down4' ? '하향 4 %' : '종단경사'})`);
if (slab.form === 'exposed') auto('끝 두께 (자동)', `${Math.round(sh.bracketHeightM * 1000)} mm`, '= 흉벽 상단 − 브라켓 상면');
box.append(
el(
'p',
'시안 메모 · 매립형은 브라켓 상면에 얹혀 두께가 일정하고, 벽체 배면 뒤에서 경사를 따릅니다. 노출형(2026-11-04 삽도 기준, QA-8 가정)은 상면이 흉벽 상단과 같고 끝은 브라켓 위에 얹히며, 벽체 배면부터 헌치 길이 동안 두께가 끝 두께에서 슬래브 두께로 줄어듭니다. 형식을 바꾸면 경사가 그 형식의 기본값으로 바뀝니다. 접합 부속재(고무판, 타르페이퍼, 다웰, 강관)는 표시용이고, 배근은 철근 단계에서 정합니다(노출형 배근 규칙 미정).',
'muted small abut-memo',
),
);
@@ -1277,6 +1441,7 @@ function applyAbutMemberCard(rec: AbutRecord): void {
$<HTMLElement>('abut-panel-plan-shape').hidden = member !== 'shape';
$<HTMLElement>('abut-panel-plan-footing').hidden = member !== 'footing';
$<HTMLElement>('abut-panel-plan-wing').hidden = member !== 'wing';
$<HTMLElement>('abut-panel-plan-slab').hidden = member !== 'slab';
// The card title is the picked row; its 요약 follows the name (시안 머리줄).
const slabWidth = abutSummaryRows(rec).find(([label]) => label === '접속슬래브 폭')?.[1] ?? '—';
const row = abutMemberRows(rec, slabWidth).find((r) => r.id === member);
@@ -1357,6 +1522,11 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
}
// ② draws the 날개벽 card (시안); ① keeps the two form selects.
if (planCard) fillAbutWingCard(wingBox, rec, apply, fail, idOf, numberInput);
if ('slab' in slot) {
const slabBox = $<HTMLDivElement>(slot.slab);
slabBox.replaceChildren();
fillAbutSlabCard(slabBox, 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 쪽)');