diff --git a/src/main.ts b/src/main.ts index 30c6b45..cfa7968 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,9 +28,12 @@ import { setAbutGround, applyFootingRebar as applyAbutFootingRebar, applyWallRebar, + applyParapetRebar, } 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_PARAPET_REBAR_DEFAULT, ABUT_PARAPET_SIZES, abutParapetGeom, abutParapetRebar, slabSpanAt, type AbutParapetGeom, type AbutParapetRebar, type AbutParapetRebarSettings } from './core/abut/rebar/parapet'; +import { ABUT_PARAPET_COLORS, abutParapetDowelSvg, abutParapetElevationSvg, abutParapetSectionSvg } from './view/abut-parapet-rebar-svg'; import { ABUT_WALL_REBAR_DEFAULT, ABUT_WALL_SIZES, abutWallGeom, abutWallRebar, type AbutWallGeom, type AbutWallRebar, type AbutWallRebarSettings } from './core/abut/rebar/wall'; import { ABUT_WALL_COLORS, abutWallElevationSvg as abutWallRebarElevationSvg, abutWallSectionSvg as abutWallRebarSectionSvg } from './view/abut-wall-rebar-svg'; import { lapLength } from './core/abut/rebar/splice'; @@ -219,6 +222,8 @@ const state = { abutRebarView: 'w' as 'w' | 'l' | 'plan', /** ③ 교대 벽체 보기 탭 (교대축 직각 단면 / 교대축 단면 계단). */ abutWallView: 'sec' as 'sec' | 'elev', + /** ③ 교대 흉벽 보기 탭 (단면 / 교대축 단면 / P10 다웰 배치). */ + abutParapetView: 'sec' as 'sec' | 'elev' | 'dw', abutRebarNotes: false, /** Member card selected on ② (시안 ② 부재 목록). */ member: 'coping' as MemberId, @@ -3090,12 +3095,12 @@ function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void { box.appendChild(wrap); } -/** ③ 교대 부재 (시안 순서, plan_abut AD9). 확대기초 (AD3) · 벽체 (AD4) have rules; the rest come next. */ +/** ③ 교대 부재 (시안 순서, plan_abut AD9). 확대기초 (AD3) · 벽체 (AD4) · 흉벽 (AD5) have rules; the rest come 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 }, { id: 'wall', label: '벽체', marks: 'W1–W11', ready: true }, - { id: 'para', label: '흉벽·브라켓', marks: 'P1–P11', ready: false }, + { id: 'para', label: '흉벽·브라켓', marks: 'P1–P11', ready: true }, { id: 'foot', label: '확대기초', marks: 'B1–B10', ready: true }, { id: 'wing', label: '날개벽', marks: 'WR1–WR20', ready: false }, { id: 'slab', label: '접속슬래브', marks: 'R1–R8', ready: false }, @@ -3126,11 +3131,31 @@ function abutWallBuilt(rec: AbutRecord, footing: AbutFootingRebar): { geom: Abut } } -/** ③ with an abutment selected (2026-11-04): 부재 · 교대 list, the member card, and the RB-AF / RB-AB alerts. */ +/** The 흉벽 · 브라켓 rebar of an abutment, on its 벽체 grid; P10 from the ② 접속슬래브 plan (null when it does not build). */ +function abutParapetBuilt(rec: AbutRecord, wall: { geom: AbutWallGeom; result: AbutWallRebar }): { geom: AbutParapetGeom; result: AbutParapetRebar } | 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 tM = (wall.geom.xb / 1000 + members.shape.parapetBackM) / 2; + const span = slabSpanAt(abutPlanOutline(model, members, wings, slab).slab, tM, abutSkewK(model)); + const chamfer = (sEnd: number) => (wings.find((w) => Math.abs(w.sEnd - sEnd) < 1e-9)?.haunchM ?? 0) * 1000; + const B = model.wing?.slabWidthMm; + const slabGeom = span && B !== undefined ? { B, a: span.a, b: span.b, nearT: members.shape.parapetBackM * 1000 - slab.parapetGapMm, chamferA: chamfer(0), chamferB: chamfer(model.length) } : null; + const geom = abutParapetGeom(wall.geom, members.shape, slabGeom); + return { geom, result: abutParapetRebar(geom, wall.result, rec.rebar?.parapet, 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 alerts. */ function renderAbutRebar(rec: AbutRecord): void { const built = abutFootingBuilt(rec); const wall = built ? abutWallBuilt(rec, built.result) : null; - const issuesOf = (id: AbutRebarMemberId) => (id === 'foot' ? built?.result.issues : id === 'wall' ? wall?.result.issues : undefined) ?? []; + const para = wall ? abutParapetBuilt(rec, wall) : null; + const issuesOf = (id: AbutRebarMemberId) => (id === 'foot' ? built?.result.issues : id === 'wall' ? wall?.result.issues : id === 'para' ? para?.result.issues : undefined) ?? []; const warnOf = (id: AbutRebarMemberId) => issuesOf(id).filter((i) => i.level !== 'I').length; const tabs = $('rebar-tabs'); tabs.replaceChildren(); @@ -3159,7 +3184,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)) { + } else if (!built || (member.id === 'wall' && !wall) || (member.id === 'para' && !para)) { $('rebar-note').textContent = `${rec.name} · 교대 모델을 만들 수 없어 배근하지 않습니다.`; } else if (member.id === 'wall' && wall) { const g = wall.geom; @@ -3167,6 +3192,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 === 'para' && para) { + const g = para.geom; + $('rebar-note').textContent = `${rec.name} · Type-1 · 흉벽 두께 ${Math.round(-g.parapetBack)} · 높이 ${Math.round(g.Hp)} (대표 단면) · 브라켓 ${Math.round(g.parapetBack - g.wall.xb)} × ${Math.round(g.bracketH)} · 피복 ${para.result.settings.c}`; + fillAbutParapetRebarCard(box, rec, para); } else { const g = built.geom; const toe = Math.round(g.W - g.xf); @@ -3206,20 +3235,52 @@ function abutSelectRow(tr: HTMLTableRowElement, mark: string, togglePick: (mark: return cell; } -/** ③ 교대 벽체 카드 (시안, 교대벽체철근.md §9): 보기 탭 · 삽도 → 구간 메모 · 범례 → 기본간격 · 입력 → 자동 값 → 철근표 → 근거. */ -function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutWallGeom; result: AbutWallRebar }): void { - const { geom, result: r } = built; - const s = r.settings; - const D = ABUT_WALL_REBAR_DEFAULT; +/** One 철근표 row of a ③ 교대 벽체 · 흉벽 card. */ +interface AbutCardBar { + mark: string; + role: string; + size: string; + spacing: string; + actual: string; + shape: string; + length: number; + lengthMin?: number; + lengthMax: number; + count: number; + position: string; + note: string; + basis: '규칙' | '가정'; + modified: boolean; +} + +/** What a ③ 교대 벽체 · 흉벽 card shows (시안 순서: 보기 탭 · 삽도 → 메모 · 범례 → 기본간격 · 입력 → 자동 값 → 철근표 → 근거). */ +interface AbutCardSpec { + idPrefix: string; + views: readonly (readonly [string, string])[]; + view: string; + setView: (id: string) => void; + svg: (pick: AbutRebarPick) => string; + caption: string; + memo?: string; + legendHtml: string; + baseTitle: string; + /** label, value, default, patch. */ + base: [string, number, number, (v: number) => Partial][]; + inputs: [string, number, number, (v: number) => Partial][]; + /** "8 × 4" or "400 · 600" text input of two numbers. */ + pair: { label: string; value: [number, number]; def: [number, number]; sep: string; error: string; patch: (a: number, b: number) => Partial }; + autos: [string, string][]; + bars: AbutCardBar[]; + roleColor: Record; + /** 묶음별 간격 수정: the row that edits a group, its key, the 기본간격-linked value, and the label. */ + overrides: Partial>; + basisNote: string; + set: (patch: Partial) => void; +} + +/** ③ 교대 벽체 · 흉벽 카드 공통 (2026-11-04). The selection is the ③ rebarSelection (표 · 삽도 · 3D 공통). */ +function abutRebarCard(box: HTMLDivElement, o: AbutCardSpec): void { const pick: AbutRebarPick = { marks: state.rebarSelection, only: state.rebarIsolate }; - const set = (patch: Partial) => { - try { - applyAbutRecord(applyWallRebar(rec, patch)); - } catch (err) { - setError((err as Error).message); - renderAll(); - } - }; const togglePick = (mark: string) => { const alone = state.rebarSelection.size === 1 && state.rebarSelection.has(mark); state.rebarSelection = alone ? new Set() : new Set([mark]); @@ -3227,18 +3288,18 @@ function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { ge }; // 보기 탭. const head = el('div', undefined, 'abut-rebar-head'); - for (const [id, label] of [['sec', '교대축 직각 단면'], ['elev', '교대축 단면 (계단)']] as const) { - const b = el('button', label, `abut-optt${state.abutWallView === id ? ' on' : ''}`); + for (const [id, label] of o.views) { + const b = el('button', label, `abut-optt${o.view === id ? ' on' : ''}`); b.type = 'button'; - b.setAttribute('aria-pressed', String(state.abutWallView === id)); + b.setAttribute('aria-pressed', String(o.view === id)); b.addEventListener('click', () => { - state.abutWallView = id; + o.setView(id); renderAll(); }); head.append(b); } box.append(head); - const picked = r.bars.filter((b) => state.rebarSelection.has(b.mark)); + const picked = o.bars.filter((b) => state.rebarSelection.has(b.mark)); box.append( el( 'p', @@ -3249,7 +3310,7 @@ function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { ge ), ); const fig = el('figure', undefined, 'ground-fig abut-rebar-fig'); - fig.innerHTML = state.abutWallView === 'sec' ? abutWallRebarSectionSvg(geom, r, pick) : abutWallRebarElevationSvg(geom, r, pick); + fig.innerHTML = o.svg(pick); fig.querySelectorAll('[data-pick]').forEach((g) => { const mark = g.getAttribute('data-pick')!; g.addEventListener('click', () => togglePick(mark)); @@ -3260,32 +3321,16 @@ function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { ge } }); }); - fig.append( - el( - 'figcaption', - state.abutWallView === 'sec' - ? '교대축 직각 단면 · 구간 1 (IP3 끝) · 왼쪽 배면 → 오른쪽 전면 · 선 = 연직 · W2 · W7, 점 = W5 · W6 · W9 · W10' - : '교대축 단면 · 전면 철근 · 왼쪽 IP3 = 구간 1 → 오른쪽 IP1 · W5는 계단에서 끊김, W9 · W10은 구간마다 ⊓ (양끝 경계 − 피복에서 다리 l_d)', - 'muted small', - ), - ); + fig.append(el('figcaption', o.caption, 'muted small')); box.append(fig); - // 구간 메모 · 범례 (시안). - box.append( - el( - 'p', - `벽체 높이 구간 ${[...geom.segments].sort((a, b) => a.no - b.no).map((sg) => `${sg.no} ${Math.round(sg.H)}`).join(' · ')} · 수평 W5는 높이가 들어가는 구간까지만 (위 계단 면 − ${lenText(s.c)}에서 끊음) · W9 · W10은 구간마다 ⊓, 양끝 다리 l_d ${r.ld.H16}`, - 'muted small', - ), - ); - const C = ABUT_WALL_COLORS; + if (o.memo) box.append(el('p', o.memo, 'muted small')); const legend = el('p', undefined, 'muted small abut-rebar-legend'); - legend.innerHTML = `━ 연직 (W1 · W3 · W4 · W11) · ━ 수평 · 교좌면 (W2 · W5 · W6 · W8) · ━ 계단 상면 (W9 실선 · W10 파선) · ┅ 간격재 W7`; + legend.innerHTML = o.legendHtml; box.append(legend); - // 입력: 기본간격 · 피복 · 첫 철근 최소 여유 · W7 배수 (사용자 결정 2026-11-04: W7 배수 편집, e_min 표시). - const num = (into: HTMLElement, label: string, value: number, apply: (v: number) => Partial, def: number) => { - const id = `awr-${rec.name}-${label}`; + // 입력. + const num = (into: HTMLElement, [label, value, def, patch]: [string, number, number, (v: number) => Partial]) => { + const id = `${o.idPrefix}-${label}`; const lab = el('label', label); lab.htmlFor = id; const input = el('input'); @@ -3294,59 +3339,50 @@ function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { ge input.step = 'any'; input.value = String(value); if (value !== def) input.classList.add('changed-input'); - input.addEventListener('change', () => set(apply(Number(input.value)))); + input.addEventListener('change', () => o.set(patch(Number(input.value)))); const cell = el('div'); cell.append(lab, input); into.append(cell); }; const grid = el('div', undefined, 'abut-rebar-inputs'); - grid.append(el('p', '기본간격 · 벽체 (부재별 입력)', 'lbl')); - num(grid, '교대축 (mm)', s.sL, (v) => ({ sL: v }), D.sL); - num(grid, '교대축 직각 (mm)', s.sW, (v) => ({ sW: v }), D.sW); - num(grid, '연직 (mm)', s.sV, (v) => ({ sV: v }), D.sV); + grid.append(el('p', o.baseTitle, 'lbl')); + for (const f of o.base) num(grid, f); box.append(grid); const grid2 = el('div', undefined, 'abut-rebar-inputs'); - num(grid2, '피복 (mm)', s.c, (v) => ({ c: v }), D.c); - num(grid2, '첫 철근 최소 여유 (mm)', s.eMin, (v) => ({ eMin: v }), D.eMin); + for (const f of o.inputs) num(grid2, f); { - const id = `awr-${rec.name}-k7`; - const lab = el('label', '간격재 W7 배수 (교대축 × 연직)'); + const p = o.pair; + const id = `${o.idPrefix}-pair`; + const lab = el('label', p.label); lab.htmlFor = id; const input = el('input'); input.id = id; input.type = 'text'; - input.value = `${s.k7L} × ${s.k7V}`; - if (s.k7L !== D.k7L || s.k7V !== D.k7V) input.classList.add('changed-input'); + 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); - if (n.length !== 2) { - setError('W7 배수는 "8 × 4"처럼 두 정수로 입력하세요.'); + 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(); return; } - set({ k7L: n[0], k7V: n[1] }); + o.set(p.patch(n[0], n[1])); }); const cell = el('div'); cell.append(lab, input); grid2.append(cell); } - // 자동 값. - const fck = materialFor(state.material, state.fckByMember, 'abutBody').fck; - const auto = (label: string, value: string) => { + for (const [label, value] of o.autos) { const cell = el('div', undefined, 'abut-rebar-auto'); cell.append(el('span', label, 'muted small'), el('span', value, 'num')); grid2.append(cell); - }; - const w7 = r.bars.find((b) => b.mark === 'W7'); - auto('연직철근 격자 (저판 B1 · B3 피함)', `저판 격자 − ${lenText(r.vertical.shift)}`); - auto('W7 간격', w7 ? `@${w7.spacing.replace(' × ', ' × @')}` : '—'); - auto('교대축 직각 철근 방향', '① skew 고정'); - auto(`l_d (fck ${fck})`, `H13 ${r.ld.H13} · H16 ${r.ld.H16} · H19 ${r.ld.H19}`); + } box.append(grid2); // 철근표. const bar = el('div', undefined, 'abut-rebar-tablehead'); - const assumed = r.bars.filter((b) => b.basis === '가정').map((b) => b.mark); + const assumed = o.bars.filter((b) => b.basis === '가정').map((b) => b.mark); bar.append(el('span', assumed.length ? `가정값 ${assumed.length}개 (${assumed.join(' · ')}) · 나머지는 규칙 계산` : '가정값 없음 · 모든 철근이 규칙 계산', 'muted small')); const notes = el('button', state.abutRebarNotes ? '비고 접기' : '비고 모두 펼치기', 'btn small'); notes.type = 'button'; @@ -3356,26 +3392,19 @@ function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { ge }); bar.append(notes); box.append(bar); - box.append(rebarSelectBar(r.bars.map((b) => b.mark))); + box.append(rebarSelectBar(o.bars.map((b) => b.mark))); const table = el('table', undefined, 'grid-table rebar-table'); const hr = table.createTHead().insertRow(); for (const h of ['선택', '구분', '번호', '규격', '간격', '실간격', '형상', '길이 (mm) · 근거', '개수', '위치']) hr.append(el('th', h)); const body = table.createTBody(); - const roleColor: Record = { 연직: C.vertical, 수평: C.horizontal, 간격재: C.tie, '계단 상면': C.step }; - // 묶음별 간격 수정 (사용자 결정 2026-11-04): W3 · W4 (교대축), W5 · W6 · W8 (연직), W9 · W10 (폭). W1 · W2는 연직 격자를 따름. - const overrideOf: Partial> = { - W3: { key: 'sVert', base: s.sL, label: 'W1–W4 · W2 교대축 실간격' }, - W5: { key: 'sHoriz', base: s.sV, label: 'W5 · W6 · W8 연직 간격' }, - W9: { key: 'sTop', base: 2 * s.sW, label: 'W9 · W10 폭 간격' }, - }; - for (const b of r.bars) { + for (const b of o.bars) { const on = state.rebarSelection.has(b.mark); const tr = body.insertRow(); const checkCell = abutSelectRow(tr, b.mark, togglePick); const role = el('td'); - role.innerHTML = ` ${b.role}`; + role.innerHTML = ` ${b.role}`; const actual = el('td'); - const ov = overrideOf[b.mark]; + const ov = o.overrides[b.mark]; if (ov) { const input = el('input'); input.type = 'number'; @@ -3384,7 +3413,7 @@ function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { ge input.style.width = '70px'; input.setAttribute('aria-label', ov.label); input.addEventListener('click', (ev) => ev.stopPropagation()); - input.addEventListener('change', () => set({ [ov.key]: Number(input.value) === ov.base ? undefined : Number(input.value) })); + input.addEventListener('change', () => o.set({ [ov.key]: Number(input.value) === ov.base ? undefined : Number(input.value) } as Partial)); actual.append(input); if (b.modified) { actual.append(el('span', ' 수정됨', 'muted small abut-note-warn')); @@ -3392,15 +3421,17 @@ function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { ge back.type = 'button'; back.addEventListener('click', (ev) => { ev.stopPropagation(); - set({ [ov.key]: undefined }); + o.set({ [ov.key]: undefined } as Partial); }); actual.append(back); } } else { - actual.textContent = b.actual + (b.modified && b.mark !== 'W7' ? ' · 수정됨' : ''); + actual.textContent = b.actual + (b.modified && b.actual !== '—' ? ' · 수정됨' : ''); } const len = el('td'); - len.append(`${b.length}${b.lengthMax !== b.length ? ` ~ ${b.lengthMax}` : ''} `, el('span', b.basis, b.basis === '가정' ? 'badge warn' : 'badge')); + // 벽체: 대표 ~ 최대 (구간마다). 흉벽: 대표 (최소 ~ 최대) (상단 경사). + const range = b.lengthMin === undefined ? (b.lengthMax !== b.length ? ` ~ ${b.lengthMax}` : '') : b.lengthMin !== b.lengthMax ? ` (${b.lengthMin} ~ ${b.lengthMax})` : ''; + len.append(`${b.length}${range} `, el('span', b.basis, b.basis === '가정' ? 'badge warn' : 'badge')); tr.append(checkCell, role, el('td', b.mark, 'bar-no'), el('td', b.size), el('td', b.spacing), actual, el('td', b.shape), len, el('td', String(b.count)), el('td', b.position)); if (state.abutRebarNotes || on) { const nr = body.insertRow(); @@ -3412,15 +3443,130 @@ function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { ge const scroll = el('div', undefined, 'table-scroll'); scroll.append(table); box.append(scroll); + box.append(el('p', o.basisNote, 'muted small')); +} + +/** ③ 교대 벽체 카드 (시안, 교대벽체철근.md §9). */ +function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutWallGeom; result: AbutWallRebar }): void { + const { geom, result: r } = built; + const s = r.settings; + const D = ABUT_WALL_REBAR_DEFAULT; + const C = ABUT_WALL_COLORS; const SZ = ABUT_WALL_SIZES; + const fck = materialFor(state.material, state.fckByMember, 'abutBody').fck; + const w7 = r.bars.find((b) => b.mark === 'W7'); const lv = r.levels.find((l) => l.no === 1); - box.append( - el( - 'p', - `근거 · 교대벽체철근.md · spec_abut.md §6.3 · 규격 W1 · W3 · W4 · W5 ${SZ.W1} / W2 · W6 · W9 · W10 · W11 ${SZ.W2} / W7 · W8 ${SZ.W7} · 교대축 ${r.vertical.text}${lv ? ` · 삽도 재현: 구간 1 "${lv.text.split(' · ')[0]}"` : ''}`, - 'muted small', - ), - ); + abutRebarCard(box, { + idPrefix: `awr-${rec.name}`, + views: [['sec', '교대축 직각 단면'], ['elev', '교대축 단면 (계단)']], + view: state.abutWallView, + setView: (id) => (state.abutWallView = id as 'sec' | 'elev'), + svg: (pick) => (state.abutWallView === 'sec' ? abutWallRebarSectionSvg(geom, r, pick) : abutWallRebarElevationSvg(geom, r, pick)), + caption: + state.abutWallView === 'sec' + ? '교대축 직각 단면 · 구간 1 (IP3 끝) · 왼쪽 배면 → 오른쪽 전면 · 선 = 연직 · W2 · W7, 점 = W5 · W6 · W9 · W10' + : '교대축 단면 · 전면 철근 · 왼쪽 IP3 = 구간 1 → 오른쪽 IP1 · W5는 계단에서 끊김, W9 · W10은 구간마다 ⊓ (양끝 경계 − 피복에서 다리 l_d)', + memo: `벽체 높이 구간 ${[...geom.segments].sort((a, b) => a.no - b.no).map((sg) => `${sg.no} ${Math.round(sg.H)}`).join(' · ')} · 수평 W5는 높이가 들어가는 구간까지만 (위 계단 면 − ${lenText(s.c)}에서 끊음) · W9 · W10은 구간마다 ⊓, 양끝 다리 l_d ${r.ld.H16}`, + legendHtml: `━ 연직 (W1 · W3 · W4 · W11) · ━ 수평 · 교좌면 (W2 · W5 · W6 · W8) · ━ 계단 상면 (W9 실선 · W10 파선) · ┅ 간격재 W7`, + baseTitle: '기본간격 · 벽체 (부재별 입력)', + base: [ + ['교대축 (mm)', s.sL, D.sL, (v) => ({ sL: v })], + ['교대축 직각 (mm)', s.sW, D.sW, (v) => ({ sW: v })], + ['연직 (mm)', s.sV, D.sV, (v) => ({ sV: v })], + ], + // 사용자 결정 2026-11-04: W7 배수 편집, e_min 표시. + inputs: [ + ['피복 (mm)', s.c, D.c, (v) => ({ c: v })], + ['첫 철근 최소 여유 (mm)', s.eMin, D.eMin, (v) => ({ eMin: v })], + ], + pair: { label: '간격재 W7 배수 (교대축 × 연직)', value: [s.k7L, s.k7V], def: [D.k7L, D.k7V], sep: '×', error: 'W7 배수는 "8 × 4"처럼 두 정수로 입력하세요.', patch: (a, b) => ({ k7L: a, k7V: b }) }, + autos: [ + ['연직철근 격자 (저판 B1 · B3 피함)', `저판 격자 − ${lenText(r.vertical.shift)}`], + ['W7 간격', w7 ? `@${w7.spacing.replace(' × ', ' × @')}` : '—'], + ['교대축 직각 철근 방향', '① skew 고정'], + [`l_d (fck ${fck})`, `H13 ${r.ld.H13} · H16 ${r.ld.H16} · H19 ${r.ld.H19}`], + ], + bars: r.bars, + roleColor: { 연직: C.vertical, 수평: C.horizontal, 간격재: C.tie, '계단 상면': C.step }, + // 묶음별 간격 수정 (사용자 결정 2026-11-04): W3 · W4 (교대축), W5 · W6 · W8 (연직), W9 · W10 (폭). W1 · W2는 연직 격자를 따름. + overrides: { + W3: { key: 'sVert', base: s.sL, label: 'W1–W4 · W2 교대축 실간격' }, + W5: { key: 'sHoriz', base: s.sV, label: 'W5 · W6 · W8 연직 간격' }, + W9: { key: 'sTop', base: 2 * s.sW, label: 'W9 · W10 폭 간격' }, + }, + basisNote: `근거 · 교대벽체철근.md · spec_abut.md §6.3 · 규격 W1 · W3 · W4 · W5 ${SZ.W1} / W2 · W6 · W9 · W10 · W11 ${SZ.W2} / W7 · W8 ${SZ.W7} · 교대축 ${r.vertical.text}${lv ? ` · 삽도 재현: 구간 1 "${lv.text.split(' · ')[0]}"` : ''}`, + set: (patch) => { + try { + applyAbutRecord(applyWallRebar(rec, patch)); + } catch (err) { + setError((err as Error).message); + renderAll(); + } + }, + }); +} + +/** ③ 교대 흉벽 · 브라켓 카드 (시안, 교대흉벽철근.md §10). */ +function fillAbutParapetRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutParapetGeom; result: AbutParapetRebar }): void { + const { geom, result: r } = built; + const s = r.settings; + const D = ABUT_PARAPET_REBAR_DEFAULT; + const C = ABUT_PARAPET_COLORS; + const SZ = ABUT_PARAPET_SIZES; + const fck = materialFor(state.material, state.fckByMember, 'abutBody').fck; + const p6 = r.bars.find((b) => b.mark === 'P6'); + const off = (v: number) => (v >= 0 ? `+ ${lenText(v)}` : `− ${lenText(-v)}`); + const view = state.abutParapetView; + abutRebarCard(box, { + idPrefix: `apr-${rec.name}`, + // 사용자 결정 2026-11-04: 시안 두 탭에 교대축 단면을 더해 세 탭. + views: [['sec', '단면'], ['elev', '교대축 단면'], ['dw', 'P10 다웰 배치']], + view, + setView: (id) => (state.abutParapetView = id as 'sec' | 'elev' | 'dw'), + svg: (pick) => (view === 'sec' ? abutParapetSectionSvg(geom, r, pick) : view === 'elev' ? abutParapetElevationSvg(geom, r, pick) : abutParapetDowelSvg(geom, r, pick)), + caption: + view === 'sec' + ? `교대축 직각 단면 · 대표 단면 (흉벽 높이 ${lenText(geom.Hp)}) · 왼쪽 브라켓 배면 → 오른쪽 흉벽 전면` + : view === 'elev' + ? '교대축 단면 · 전면 · 왼쪽 IP3 → 오른쪽 IP1 · 수평 P4는 흉벽 상단 경사와 나란함, 끝면 P9 (위) · P8 (아래) · P11' + : `P10 다웰 배치 · 평면 (접속슬래브 폭 ${geom.slab ? Math.round(geom.slab.B) : '—'}) · 다웰이 P3 수평 다리와 겹치면 가장 가까운 자리로 옮김 (Q-P5)`, + memo: `흉벽 상단선은 받침 ① · 마지막 받침으로 정해져 흉벽 높이가 위치마다 다릅니다 (IP1 ${Math.round(geom.top[0] - (geom.wall.HF + geom.wall.segments[0].H))} · IP3 ${Math.round(geom.top[1] - (geom.wall.HF + geom.wall.segments[geom.wall.segments.length - 1].H))}). 철근표 대표 길이 · 단 수는 흉벽 높이 ${lenText(geom.Hp)} 단면, 괄호는 범위입니다.`, + legendHtml: `━ 연직 (P1–P3 · P11) · ━ 수평 (P4 · P5) · ┅ 간격재 (P6 · P7) · ┅ 측면 (P8 · P9) · 다웰 P10`, + baseTitle: '기본간격 · 흉벽 (부재별 입력) · 교대축 직각은 두지 않음', + base: [ + ['교대축 (mm)', s.sL, D.sL, (v) => ({ sL: v })], + ['연직 (mm)', s.sV, D.sV, (v) => ({ sV: v })], + ], + inputs: [ + ['피복 (mm)', s.c, D.c, (v) => ({ c: v })], + ['간격재 P6 · P7 배수 · 교대축', s.k67L, D.k67L, (v) => ({ k67L: v })], + ['간격재 P6 · P7 배수 · 연직', s.k67V, D.k67V, (v) => ({ k67V: v })], + ], + pair: { label: 'P10 간격 · 길이 (기본간격과 별도)', value: [s.s10, s.L10], def: [D.s10, D.L10], sep: '·', error: 'P10 간격 · 길이는 "400 · 600"처럼 두 숫자로 입력하세요.', patch: (a, b) => ({ s10: a, L10: b }) }, + autos: [ + ['상단 · 하단 경계', `브라켓 상면 (흉벽 상단 − ${lenText(geom.bracketH)})`], + ['연직 격자 (저판 기준)', `P1 ${off(r.offsets.P1)} · P2 ${off(r.offsets.P2)} · P3 ${off(r.offsets.P3)}`], + ['P6 · P7 간격', p6 ? `@${p6.spacing.replace(' × ', ' × @')}` : '—'], + [`l_d (fck ${fck})`, `H13 ${r.ld.H13} · H16 ${r.ld.H16}`], + ['P10', `${r.dowel.n}개 · 양끝 여유 ${lenText(r.settings.c + r.dowel.e)}${r.dowel.moved ? ` · ${r.dowel.moved}개 P3 비켜 옮김` : ''}`], + ], + bars: r.bars, + roleColor: { 연직: C.vertical, 수평: C.horizontal, 간격재: C.tie, 측면: C.side, 다웰: C.side }, + // 묶음별 간격 수정 (사용자 결정 2026-11-04): P1–P3 (교대축), P4 · P5 · P8 · P9 (연직). P10은 별도 입력, P11은 흉벽 선. + overrides: { + P2: { key: 'sVert', base: s.sL, label: 'P1–P3 교대축 실간격' }, + P4: { key: 'sHoriz', base: s.sV, label: 'P4 · P5 · P8 · P9 연직 간격' }, + }, + basisNote: `근거 · 교대흉벽철근.md · spec_abut.md §6.4 · 규격 P1–P5 · P11 ${SZ.P1} / P6–P9 ${SZ.P6} / P10 ${SZ.P10} · 흉벽 연직철근 격자 = 저판 격자 ${off(r.offsets.P1)} (P1) / ${off(r.offsets.P2)} (P2) / ${off(r.offsets.P3)} (P3)`, + set: (patch) => { + try { + applyAbutRecord(applyParapetRebar(rec, patch)); + } catch (err) { + setError((err as Error).message); + renderAll(); + } + }, + }); } /** ③ 교대 확대기초 카드 (시안, 교대확대기초철근.md §10): 보기 탭 · 삽도 → 범례 → 기본간격 · 배근 방향 · 입력 → 자동 값 → 철근표 → 근거. */