feat(abut-rebar): ③ 교대 접속슬래브 카드

- 보기 탭 평면 · 단면, 입력: 기본간격 교대축 · 교대축 직각, 피복 "80 / 100", 첫 철근 최소 여유, R8 배수 (편집, 사용자 결정 2026-11-05)
- 자동 값: 상면 2s · 하면 s · R8 k·s, 하면 격자, 모따기 판정 · 짧아진 철근 수, R7 l_d. 실간격 묶음 수정은 하면 행 R3 · R4 (상면은 2배)
- 노출형 안내 (매립형 규칙 적용), 공통 카드의 두 숫자 입력이 "/"도 구분자로 받음

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-05 09:27:51 +09:00
1 parent e9d79957c8
commit a282618d90
1 file changed
+106 -6
+106 -6
View File
@@ -30,9 +30,12 @@ import {
applyWallRebar,
applyParapetRebar,
applyWingRebar,
applySlabRebar,
} from './core/abut/edit';
import { ABUT_FOOTING_REBAR_DEFAULT, abutFootingGeom, abutFootingRebar, ABUT_FOOTING_SIZES, type AbutFootingGeom, type AbutFootingRebar, type AbutFootingRebarSettings } from './core/abut/rebar/footing';
import { lenText, skewAngle } from './core/abut/rebar/skew-layout';
import { ABUT_SLAB_REBAR_DEFAULT, ABUT_SLAB_SIZES, abutSlabGeom, abutSlabRebar, type AbutSlabGeom, type AbutSlabRebar, type AbutSlabRebarSettings } from './core/abut/rebar/slab';
import { ABUT_SLAB_COLORS, abutSlabPlanSvg as abutSlabRebarPlanSvg, abutSlabSectionSvg as abutSlabRebarSectionSvg } from './view/abut-slab-rebar-svg';
import { ABUT_WING_REBAR_DEFAULT, ABUT_WING_SIZES, abutWingGeom, abutWingRebar, type AbutWingGeom, type AbutWingRebar, type AbutWingRebarSettings } from './core/abut/rebar/wing';
import { ABUT_WING_COLORS, abutWingCornerSvg as abutWingRebarCornerSvg, abutWingFaceSvg } from './view/abut-wing-rebar-svg';
import { ABUT_PARAPET_REBAR_DEFAULT, ABUT_PARAPET_SIZES, abutParapetGeom, abutParapetRebar, slabSpanAt, type AbutParapetGeom, type AbutParapetRebar, type AbutParapetRebarSettings } from './core/abut/rebar/parapet';
@@ -229,6 +232,8 @@ const state = {
abutParapetView: 'sec' as 'sec' | 'elev' | 'dw',
/** ③ 교대 날개벽 보기 탭 (전면도 / 배면도 / 헌치 · 모서리 평면). 측은 ② 날개벽 카드와 같은 abutWingSide. */
abutWingView: 'front' as 'front' | 'back' | 'corner',
/** ③ 교대 접속슬래브 보기 탭 (평면 / 단면). */
abutSlabView: 'plan' as 'plan' | 'sec',
abutRebarNotes: false,
/** Member card selected on ② (시안 ② 부재 목록). */
member: 'coping' as MemberId,
@@ -3102,7 +3107,7 @@ function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void {
box.appendChild(wrap);
}
/** ③ 교대 부재 (시안 순서, plan_abut AD9). 확대기초 (AD3) · 벽체 (AD4) · 흉벽 (AD5) · 날개벽 (AD7) have rules; the rest come next. */
/** ③ 교대 부재 (시안 순서, plan_abut AD9). 확대기초 (AD3) · 벽체 (AD4) · 흉벽 (AD5) · 날개벽 (AD7) · 접속슬래브 (AD8) have rules; 받침블럭 (AD6) comes next. */
type AbutRebarMemberId = 'blk' | 'wall' | 'para' | 'foot' | 'wing' | 'slab';
const ABUT_REBAR_MEMBERS: { id: AbutRebarMemberId; label: string; marks: string; ready: boolean }[] = [
{ id: 'blk', label: '받침블럭', marks: 'S1·S2', ready: false },
@@ -3110,7 +3115,7 @@ const ABUT_REBAR_MEMBERS: { id: AbutRebarMemberId; label: string; marks: string;
{ id: 'para', label: '흉벽·브라켓', marks: 'P1–P11', ready: true },
{ id: 'foot', label: '확대기초', marks: 'B1–B10', ready: true },
{ id: 'wing', label: '날개벽', marks: 'WR1–WR20', ready: true },
{ id: 'slab', label: '접속슬래브', marks: 'R1–R8', ready: false },
{ id: 'slab', label: '접속슬래브', marks: 'R1–R8', ready: true },
];
/** The 저판 rebar of an abutment (null when the abutment does not build). */
@@ -3175,14 +3180,49 @@ function abutWingsBuilt(rec: AbutRecord, footing: AbutFootingRebar): { geom: Abu
}
}
/** ③ with an abutment selected (2026-11-04): 부재 · 교대 list, the member card, and the RB-AF / RB-AB / RB-AP / RB-AW alerts. */
/** The 접속슬래브 rebar of an abutment, on the ② slab plan and section (null when it does not build). */
function abutSlabBuilt(rec: AbutRecord): { geom: AbutSlabGeom; result: AbutSlabRebar } | null {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const wings = buildWings(model, members, rec.wings, rec.wingDims);
const slab = resolveSlab(rec.slab);
const grade = slabGradePct(slab, model.wing?.slopePct ?? 0);
const chamfer = (sEnd: number) => (wings.find((w) => Math.abs(w.sEnd - sEnd) < 1e-9)?.haunchM ?? 0) * 1000;
const geom = abutSlabGeom(
slab.form,
abutPlanOutline(model, members, wings, slab).slab,
slabSection(slab, grade, members.shape),
{ startEl: members.parapetTopStartEl, endEl: members.parapetTopEndEl, L: model.length, bottomEl: members.footingBottomEl ?? 0 },
[chamfer(0), chamfer(model.length)],
slab.lengthMm,
grade,
);
return { geom, result: abutSlabRebar(geom, rec.rebar?.slab, materialFor(state.material, state.fckByMember, 'abutBody'), rec.name) };
} catch {
return null;
}
}
/** ③ with an abutment selected (2026-11-04): 부재 · 교대 list, the member card, and the RB-AF / RB-AB / RB-AP / RB-AW / RB-AS alerts. */
function renderAbutRebar(rec: AbutRecord): void {
const built = abutFootingBuilt(rec);
const wall = built ? abutWallBuilt(rec, built.result) : null;
const para = wall ? abutParapetBuilt(rec, wall) : null;
const wings = built ? abutWingsBuilt(rec, built.result) : [];
const slabR = abutSlabBuilt(rec);
const issuesOf = (id: AbutRebarMemberId) =>
(id === 'foot' ? built?.result.issues : id === 'wall' ? wall?.result.issues : id === 'para' ? para?.result.issues : id === 'wing' ? wings.flatMap((w) => w.result.issues) : undefined) ?? [];
(id === 'foot'
? built?.result.issues
: id === 'wall'
? wall?.result.issues
: id === 'para'
? para?.result.issues
: id === 'wing'
? wings.flatMap((w) => w.result.issues)
: id === 'slab'
? slabR?.result.issues
: undefined) ?? [];
const warnOf = (id: AbutRebarMemberId) => issuesOf(id).filter((i) => i.level !== 'I').length;
const tabs = $<HTMLUListElement>('rebar-tabs');
tabs.replaceChildren();
@@ -3211,7 +3251,7 @@ function renderAbutRebar(rec: AbutRecord): void {
if (!member.ready) {
$('rebar-note').textContent = `${rec.name} · ${member.label} · Type-1 · ${member.marks} · 다음 단계`;
box.append(el('p', `${member.label} 배근은 다음 단계에서 추가합니다 (철근 기획서 확정, plan_abut AD4–AD8).`, 'muted'));
} else if (!built || (member.id === 'wall' && !wall) || (member.id === 'para' && !para)) {
} else if (!built || (member.id === 'wall' && !wall) || (member.id === 'para' && !para) || (member.id === 'slab' && !slabR)) {
$('rebar-note').textContent = `${rec.name} · 교대 모델을 만들 수 없어 배근하지 않습니다.`;
} else if (member.id === 'wall' && wall) {
const g = wall.geom;
@@ -3219,6 +3259,10 @@ function renderAbutRebar(rec: AbutRecord): void {
const high = g.segments.find((sg) => sg.no === g.segments.length)!;
$('rebar-note').textContent = `${rec.name} · Type-1 · 두께 ${Math.round(g.xf - g.xb)} · 높이 ${Math.round(low.H)} (구간 1) ~ ${Math.round(high.H)} (구간 ${high.no}) · 교좌면 계단 ${g.segments.length}구간 · 피복 ${wall.result.settings.c}`;
fillAbutWallRebarCard(box, rec, wall);
} else if (member.id === 'slab' && slabR) {
const g = slabR.geom;
$('rebar-note').textContent = `${rec.name} · Type-1 ${g.form === 'buried' ? '매립형' : '노출형'} · 폭 ${Math.round(g.B)} × 길이 ${Math.round(g.Ls)} · 경사 ${g.gradePct.toFixed(1)} %${g.form === 'exposed' ? ' · 매립형 규칙 적용 (사용자 결정 2026-11-05)' : ''}`;
fillAbutSlabRebarCard(box, rec, slabR);
} else if (member.id === 'wing') {
const forms = rec.wings ?? { left: 'general', right: 'general' };
$('rebar-note').textContent = `${rec.name} · Type-1 · 좌 ${WING_FORM_TEXT[forms.left]} · 우 ${WING_FORM_TEXT[forms.right]}`;
@@ -3395,7 +3439,7 @@ function abutRebarCard<S>(box: HTMLDivElement, o: AbutCardSpec<S>): void {
input.value = `${p.value[0]} ${p.sep} ${p.value[1]}`;
if (p.value[0] !== p.def[0] || p.value[1] !== p.def[1]) input.classList.add('changed-input');
input.addEventListener('change', () => {
const n = input.value.split(/[×x*·,\s]+/).filter(Boolean).map(Number);
const n = input.value.split(/[×x*·,/\s]+/).filter(Boolean).map(Number);
if (n.length !== 2 || n.some((v) => !Number.isFinite(v))) {
setError(p.error);
renderAll();
@@ -3542,6 +3586,62 @@ function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { ge
});
}
/** ③ 교대 접속슬래브 카드 (시안, 교대접속슬래브철근.md §9). */
function fillAbutSlabRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutSlabGeom; result: AbutSlabRebar }): void {
const { geom: g, result: r } = built;
const s = r.settings;
const D = ABUT_SLAB_REBAR_DEFAULT;
const C = ABUT_SLAB_COLORS;
const SZ = ABUT_SLAB_SIZES;
const fck = materialFor(state.material, state.fckByMember, 'abutBody').fck;
const sA = s.sMain ?? s.sA;
const sL = s.sDist ?? s.sL;
const view = state.abutSlabView;
abutRebarCard<AbutSlabRebarSettings>(box, {
idPrefix: `asr-${rec.name}`,
views: [['plan', '평면'], ['sec', '단면']],
view,
setView: (id) => (state.abutSlabView = id as 'plan' | 'sec'),
svg: (pick) => (view === 'plan' ? abutSlabRebarPlanSvg(g, r, pick) : abutSlabRebarSectionSvg(g, r, pick)),
caption: view === 'plan' ? '평면 · 왼쪽 절반 하면 (R3 · R4 · R8) · 오른쪽 절반 상면 (R1 · R2) · 위 = 흉벽 쪽 끝 · 모따기 모서리 R7' : '단면 (길이 방향, 폭 가운데) · 왼쪽 흉벽 쪽 끝 · 상면 R1 → R2, 하면 R3 → R4, R8은 R1 · R3을 감쌈',
memo: g.form === 'exposed' ? '노출형도 매립형 규칙을 적용합니다 (사용자 결정 2026-11-05): 상면 철근은 상면, 하면 철근은 하면을 따라갑니다. P10 다웰과의 간섭은 보지 않습니다.' : 'P10 다웰과의 간섭은 보지 않습니다 (사용자 결정 2026-11-05).',
legendHtml: `<span style="color:${C.main}">━ 길이 방향 주철근 (R1 · R3)</span> · <span style="color:${C.dist}">━ 교대축 방향 (R2 · R4)</span> · <span style="color:${C.corner}">━ 모서리 R7</span> · <span style="color:${C.shear}">● 전단 R8</span>`,
baseTitle: '기본간격 · 접속슬래브 (부재별 입력)',
base: [
['교대축 (mm)', s.sA, D.sA, (v) => ({ sA: v })],
['교대축 직각 (mm)', s.sL, D.sL, (v) => ({ sL: v })],
],
// 사용자 결정 2026-11-05: R8 배수 편집.
inputs: [
['첫 철근 최소 여유 (mm)', s.eMin, D.eMin, (v) => ({ eMin: v })],
['R8 배수', s.k8, D.k8, (v) => ({ k8: v })],
],
pair: { label: '피복 상·하면 / 측면 (mm)', value: [s.cv, s.c], def: [D.cv, D.c], sep: '/', error: '피복은 "80 / 100"처럼 두 숫자로 입력하세요.', patch: (a, b) => ({ cv: a, c: b }) },
autos: [
['상면 2s · 하면 s · R8 k·s', `${lenText(2 * sA)} · ${lenText(sA)} · ${lenText(s.k8 * sA)}`],
['하면 격자', '상면 구간을 절반으로'],
['모따기 자름 판정', `끝점 ~ 모따기선 < c · 짧아진 철근 ${r.trimmed}개`],
[`R7 l_d (fck ${fck})`, `H13 ${r.ld}`],
],
bars: r.bars,
roleColor: { 주철근: C.main, 배력철근: C.dist, 모서리: C.corner, 전단: C.shear },
// 묶음별 간격 수정 (사용자 결정 2026-11-05): R1 · R3 (교대축), R2 · R4 (길이). 하면 행에서 하면 간격으로 입력 (상면은 2배).
overrides: {
R3: { key: 'sMain', base: s.sA, label: 'R1 · R3 교대축 간격 (하면)' },
R4: { key: 'sDist', base: s.sL, label: 'R2 · R4 길이 간격 (하면)' },
},
basisNote: `근거 · 교대접속슬래브철근.md · spec_abut.md §4.8 · §6.7 · 규격 R1 · R3 ${SZ.R1} / R2 · R4 · R7 · R8 ${SZ.R2} · 교대축 ${r.across.text} · 길이 ${r.along.text} · R5 · R6 결번`,
set: (patch) => {
try {
applyAbutRecord(applySlabRebar(rec, patch));
} catch (err) {
setError((err as Error).message);
renderAll();
}
},
});
}
const WING_FORM_TEXT: Record<string, string> = { general: '일반형', short: '단축형', none: '없음' };
/** ③ 교대 날개벽 카드 (시안, 교대날개벽철근.md §10): 측 선택 + 공통 카드. 입력은 좌우 공통 (사용자 결정 2026-11-04). */