From a282618d908180c625cacf83978f4988424a4945 Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 5 Nov 2026 09:27:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut-rebar):=20=E2=91=A2=20=EA=B5=90?= =?UTF-8?q?=EB=8C=80=20=EC=A0=91=EC=86=8D=EC=8A=AC=EB=9E=98=EB=B8=8C=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보기 탭 평면 · 단면, 입력: 기본간격 교대축 · 교대축 직각, 피복 "80 / 100", 첫 철근 최소 여유, R8 배수 (편집, 사용자 결정 2026-11-05) - 자동 값: 상면 2s · 하면 s · R8 k·s, 하면 격자, 모따기 판정 · 짧아진 철근 수, R7 l_d. 실간격 묶음 수정은 하면 행 R3 · R4 (상면은 2배) - 노출형 안내 (매립형 규칙 적용), 공통 카드의 두 숫자 입력이 "/"도 구분자로 받음 Co-Authored-By: Claude Haiku 5.5 --- src/main.ts | 112 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 6 deletions(-) diff --git a/src/main.ts b/src/main.ts index e0da349..34ad43d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 = $('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(box: HTMLDivElement, o: AbutCardSpec): 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(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: `━ 길이 방향 주철근 (R1 · R3) · ━ 교대축 방향 (R2 · R4) · ━ 모서리 R7 · ● 전단 R8`, + 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 = { general: '일반형', short: '단축형', none: '없음' }; /** ③ 교대 날개벽 카드 (시안, 교대날개벽철근.md §10): 측 선택 + 공통 카드. 입력은 좌우 공통 (사용자 결정 2026-11-04). */