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:
1 parent
fc0370883f
commit
4b8b80ae97
14 files changed
+854
-60
No files matched your search
+177
-7
@@ -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 쪽)');
|
||||
|
||||
Reference in new issue
Block a user