feat(abut): ② 확대기초 · 말뚝 카드를 시안대로 작성 (평면 삽도, 버림, 열별 본수)

- 선택: 평면 skew 평행사변형(기본) / 직사각(비활성, 다음 단계), 말뚝 원기둥 D600 / 강관 · PHC(비활성, 규칙 미정)
- 평면 삽도 (src/view/abut-footing-svg.ts): 저판, 벽체 윤곽, 교대축, 말뚝 3열(skew만큼 밀림), 열 위치 · 본수, 폭 치수
- 입력: 폭 4600, 두께 1100, 앞굽 1200 (뒷굽 자동 1000), 버림 여유 · 두께 100 / 100, 말뚝 지름 600 (QA-5), 연단 650, 본수 규칙 간격 2000
- 열 위치 "650 · 2000 · 1300 · 650" 입력 (전면 연단 자동, 합 = 폭 검사), 열별 말뚝 본수 표 − / + (0 = 본수 규칙)
- 자동 값: 뒷굽, 길이(교좌면 길이), 말뚝 길이(①에서 수정), 선단 EL, 본수 규칙 문구. 시안 메모
- 3D: 교대 버림을 저판 아래에 그림 (부재 표시 "버림" 토글 적용), 말뚝 지름 입력 반영, 말뚝 열을 skew만큼 s 방향으로 밀어 배치
- 예전 저장 파일의 앞굽 끝 · 뒷굽 끝은 폭 · 앞굽으로 바꿔 읽음
- 테스트 추가 · 갱신. 전체 661건 통과, 타입 검사 · 빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Opus 5.5 committed 2026-11-04 08:46:58 +09:00
1 parent 55b679932e
commit 372d759eb7
10 files changed
+425 -46

No files matched your search

+136 -4
View File
@@ -8,8 +8,9 @@ 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, ABUT_EDIT_FIELDS, BEARING_RULE_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit';
import { ABUT_SHAPE_DEFAULT, normalizeShape, resolveShape, wallThicknessMm } from './core/abut/shape';
import { applyAbutEdit, applyBearingEdit, applyBearingRule, applyShapeEdit, applyShapeEdits, ABUT_EDIT_FIELDS, BEARING_RULE_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } 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';
import { abutShown, piersShown } from './core/abut/visibility';
import { abutBearingPlanSvg, abutBearingSectionSvg, abutSolplateSvg, type AbutBearingDetail } from './view/abut-bearing-svg';
@@ -27,7 +28,7 @@ import { abutMemberRows, ABUT_MEMBER_EDITABLE, type AbutMemberId } from './core/
import type { AbutModel, WingForm } from './core/abut/geometry';
import { buildMembers, type AbutMembers } from './core/abut/members';
import { buildWings } from './core/abut/wings';
import { buildAbutGroup, cameraForBox } from './view/abut-scene';
import { abutSkewK, buildAbutGroup, cameraForBox } from './view/abut-scene';
import { fitCamera } from './view/fit';
import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
@@ -104,7 +105,7 @@ const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [
{ label: '기둥', pier: ['columns'], abut: [] },
{ label: '확대기초', pier: ['footings'], abut: ['footing'] },
{ label: '말뚝', pier: ['piles'], abut: ['piles'] },
{ label: '버림', pier: ['blinding'], abut: [] },
{ label: '버림', pier: ['blinding'], abut: ['blinding'] },
{ label: '지반면', pier: ['ground'], abut: ['ground'] },
{ label: '벽체·흉벽', pier: [], abut: ['wall', 'parapet'] },
// 헌치 sits at the wing ends (wings.ts builds it per wing), so it follows the 날개벽 toggle (2026-11-03).
@@ -892,6 +893,94 @@ function fillAbutBearingCard(
box.append(el('p', `검사 · ${abutBearingChecks(model, members, rule, H).join(' · ')}`, 'muted small abut-checks'));
}
/** 확대기초 · 말뚝 카드의 자동 값, 열 위치, 열별 본수 표(− / +), 메모 (시안, 2026-11-04). 말뚝 길이는 ①에서 고친다. */
function fillAbutFootingExtras(
box: HTMLDivElement,
rec: AbutRecord,
v: AbutShape,
built: { model: AbutModel; members: AbutMembers },
apply: (next: AbutRecord) => void,
fail: (err: unknown, input: HTMLInputElement, back: string) => void,
idOf: (suffix: string) => string,
): void {
const ends = footingEnds(v);
const rows = pileRowsMm(v);
const L = built.model.length;
const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small'));
auto('뒷굽 (자동)', `${ends.heelMm} mm`, '= 폭 − 앞굽 − 벽체 두께');
auto('길이 (자동)', `${L.toFixed(3)} m`, '교좌면 길이 (skew)');
auto('말뚝 길이', `${Math.round(rec.pileLengthMm)} mm`, '① 기본 정보에서 수정');
auto('선단 EL (자동)', built.members.pileBottomEl === undefined ? '—' : `${built.members.pileBottomEl.toFixed(3)} m`, '= 기초 하단 − 말뚝 길이');
// 열 위치 (배면부터): "650 · 2000 · 1300 · 650". The last one (전면 연단) follows from the 폭.
const id = idOf('pile-rows');
const lab = el('label', '열 위치 · 배면부터 (mm)');
lab.htmlFor = id;
const text = `${v.pileRowBackEdgeMm} · ${v.pileRowGap1Mm} · ${v.pileRowGap2Mm} · ${rows.frontEdgeMm}`;
const input = el('input');
input.id = id;
input.type = 'text';
input.value = text;
input.addEventListener('change', () => {
const n = input.value.split(/[·,\s]+/).filter(Boolean).map(Number);
try {
if (n.length < 3 || n.some((x) => !Number.isFinite(x))) throw new Error('열 위치는 "650 · 2000 · 1300 · 650"처럼 숫자 3~4개로 입력하세요.');
if (n.length === 4 && Math.abs(n.reduce((a, b) => a + b, 0) - v.footingWidthMm) > 0.5) throw new Error(`열 위치의 합이 폭 ${v.footingWidthMm}과 같아야 합니다.`);
apply(applyShapeEdits(rec, { pileRowBackEdgeMm: n[0], pileRowGap1Mm: n[1], pileRowGap2Mm: n[2] }));
} catch (err) {
fail(err, input, text);
}
});
box.append(lab, input, el('span', `전면 연단 ${rows.frontEdgeMm} (자동)`, 'muted small'));
auto('본수 규칙', `int((L − ${2 * v.pileEdgeMm})/${v.pileSpacingMm}) + 1`, `= ${Math.floor((L * 1000 - 2 * v.pileEdgeMm) / v.pileSpacingMm) + 1}본`);
// 열별 말뚝 본수 (− / +): 0 = 본수 규칙.
const piles = built.members.piles;
const total = piles.reduce((a, r) => a + r.s.length, 0);
box.append(el('p', `열별 말뚝 본수 · 합계 ${total}본`, 'lbl'));
const table = document.createElement('table');
table.className = 'abut-seg';
const head = table.createTHead().insertRow();
for (const h of ['열', '위치 (교대축 기준)', '본수', '본 간격']) head.append(el('th', h));
const body = table.createTBody();
const order: { key: 'pileCountRow1' | 'pileCountRow2' | 'pileCountRow3'; name: string; row: number }[] = [
{ key: 'pileCountRow1', name: '1열 · 전면', row: 2 },
{ key: 'pileCountRow2', name: '2열', row: 1 },
{ key: 'pileCountRow3', name: '3열 · 배면', row: 0 },
];
for (const o of order) {
const r = piles[o.row];
if (!r) continue;
const n = r.s.length;
const gap = n > 1 ? Math.round(((L - 2 * v.pileEdgeMm / 1000) / (n - 1)) * 1000) : 0;
const tr = body.insertRow();
tr.append(el('td', o.name), el('td', `${r.t >= 0 ? '+' : '−'}${Math.round(Math.abs(r.t) * 1000)}`));
const cell = el('td');
const step = (delta: number) => {
const b = el('button', delta < 0 ? '−' : '+', 'btn small');
b.type = 'button';
b.disabled = delta < 0 && n <= 2;
b.addEventListener('click', () => {
try {
apply(applyShapeEdit(rec, o.key, n + delta));
} catch (err) {
$<HTMLElement>('abut-panel-plan-msg').textContent = (err as Error).message;
}
});
return b;
};
cell.append(step(-1), el('span', ` ${n} `, 'num'), step(1));
if (v[o.key] > 0) cell.append(el('span', ' 수정됨', 'muted small abut-note-warn'));
tr.append(cell, el('td', n > 1 ? `${v.pileEdgeMm} · ${n - 1}@${gap} · ${v.pileEdgeMm}` : `${v.pileEdgeMm}`));
}
box.append(table);
box.append(
el(
'p',
'시안 메모 · 확대기초는 교대 저판입니다(엑셀 "저판"). skew 평면에서는 말뚝 열이 skew만큼 밀리고, 연단 650은 그 열의 끝에서 잽니다. 직사각 평면은 다음 단계입니다. 말뚝 지름(D600 / 609.6)과 전면 열 본수는 확인 필요(QA-5).',
'muted small abut-memo',
),
);
}
/** Shows only the card of the open row in the ② 교대 editor; the basic items stay visible. */
function applyAbutMemberCard(rec: AbutRecord): void {
const member = state.abutMember;
@@ -1085,6 +1174,48 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
shape.append(figs);
}
}
// ② 확대기초 · 말뚝 카드 (시안, 2026-11-04): 평면 · 말뚝 선택 → 평면 삽도 → 입력 격자 → 자동 값 → 열 위치 → 열별 본수 → 메모.
const shapeAll = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) };
if (planCard) {
const pick = (title: string, options: [string, boolean, string?][]) => {
const row = el('div', undefined, 'abut-bearing-pick');
row.append(el('span', title, 'muted small'));
for (const [name, on, why] of options) {
const b = el('button', name, 'btn small');
b.type = 'button';
b.setAttribute('aria-pressed', String(on));
if (why) {
b.disabled = true;
b.title = why;
}
row.append(b);
}
return row;
};
footingBox.append(
pick('평면', [['skew 평행사변형 (기본)', true], ['직사각', false, '다음 단계 (길이 = 교좌면 길이 + 폭 × |cot skew|, 100 올림)']]),
pick('말뚝', [[`원기둥 D${shapeAll.pileDiameterMm}`, true], ['강관 · PHC', false, '규칙 미정']]),
);
if (built) {
const rows = pileRowsMm(shapeAll);
const names = ['3열 · 배면', '2열', '1열 · 전면'];
const fig = el('figure', undefined, 'abut-fig abut-footing-fig');
fig.innerHTML = abutFootingPlanSvg({
lengthM: built.model.length,
skewK: abutSkewK(built.model),
footingFrontM: built.members.shape.footingFrontM,
footingBackM: built.members.shape.footingBackM,
wallFrontM: built.members.shape.wallFrontM,
wallBackM: built.members.shape.wallBackM,
rows: built.members.piles.map((r, i) => ({ t: r.t, s: r.s, name: names[i] ?? `${i + 1}열` })),
pileDiameterM: built.members.shape.pileDiameterM,
rowDimsMm: [shapeAll.pileRowBackEdgeMm, shapeAll.pileRowGap1Mm, shapeAll.pileRowGap2Mm, rows.frontEdgeMm],
footingWidthMm: shapeAll.footingWidthMm,
});
fig.append(el('figcaption', '평면 · 축척 같음 · 실선 = 벽체, 굵은 선 = 확대기초 · 열마다 skew만큼 밀림', 'muted small'));
footingBox.append(fig);
}
}
for (const f of SHAPE_EDIT_FIELDS) {
const current = normalizeShape(rec.shape)[f.key] ?? ABUT_SHAPE_DEFAULT[f.key];
const id = idOf(`shape-${f.key}`);
@@ -1132,6 +1263,7 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
qa.append(el('p', '확인 필요 (spec_abut)', 'lbl'));
for (const line of ABUT_QA_NOTES) qa.append(el('p', line, 'muted small'));
shape.append(qa);
if (built) fillAbutFootingExtras(footingBox, rec, shapeAll, built, apply, fail, idOf);
shape.append(
el(
'p',