From a17c9ebbb419f38d21f2a620edb462af3083a4a1 Mon Sep 17 00:00:00 2001 From: ypshim Date: Wed, 4 Nov 2026 16:31:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut-rebar):=20=E2=91=A2=20=EA=B5=90?= =?UTF-8?q?=EB=8C=80=20=EB=B2=BD=EC=B2=B4=20=EC=B9=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보기 탭(교대축 직각 단면 / 계단 단면) · 삽도 · 구간 메모 · 범례 - 입력: 기본간격 3종, 피복, 첫 철근 최소 여유, W7 배수(편집). 자동 값: 연직 격자, W7 간격, skew 고정, l_d (fck 교대 벽체 등) - 철근표: 선택 · 구분 · 번호 · 규격 · 간격 · 실간격(묶음 수정 W3 / W5 / W9) · 형상 · 길이 · 근거(규칙/가정) · 개수 · 위치 - 부재별 경고 수 · 알림(RB-AB), 행 선택 함수를 저판 카드와 공용 - scripts/verify-abut-wall.cjs: 헤드리스 Chrome 화면 확인 스크립트 Co-Authored-By: Claude Haiku 5.5 --- scripts/verify-abut-wall.cjs | 90 +++++++++++ src/main.ts | 301 ++++++++++++++++++++++++++++++++--- 2 files changed, 365 insertions(+), 26 deletions(-) create mode 100644 scripts/verify-abut-wall.cjs diff --git a/scripts/verify-abut-wall.cjs b/scripts/verify-abut-wall.cjs new file mode 100644 index 0000000..cf07b30 --- /dev/null +++ b/scripts/verify-abut-wall.cjs @@ -0,0 +1,90 @@ +// ③ 교대 벽체 카드 화면 확인 (2026-11-04): 헤드리스 Chrome(CDP)으로 입력 파일을 불러와 A1 → ③ → 벽체를 열고 스크린샷을 남긴다. +// 사용 (WSL Node ≥ 22, 개발 서버 실행 중): +// chrome-headless-shell --headless --remote-debugging-port=9444 & (~/.cache/ms-playwright/chromium_headless_shell-*/) +// node scripts/verify-abut-wall.cjs +const fs = require('fs'); +const path = require('path'); + +const APP_URL = process.env.APP_URL || 'http://127.0.0.1:5173/'; +const CDP = process.env.CDP || 'http://127.0.0.1:9444'; +const [INPUT_FILE, OUT_DIR] = process.argv.slice(2); +if (!INPUT_FILE || !OUT_DIR) throw new Error('INPUT_FILE and OUT_DIR are required'); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + const targets = await (await fetch(`${CDP}/json`)).json(); + const page = targets.find((t) => t.type === 'page'); + const ws = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((res, rej) => ((ws.onopen = res), (ws.onerror = rej))); + let seq = 0; + const waiting = new Map(); + const consoleEvents = []; + ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.id && waiting.has(m.id)) { + waiting.get(m.id)(m); + waiting.delete(m.id); + } else if (m.method === 'Runtime.exceptionThrown') consoleEvents.push(`exception: ${m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text}`); + else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') consoleEvents.push(`console.error: ${m.params.args.map((a) => a.value ?? a.description ?? '').join(' ')}`); + }; + const send = (method, params = {}) => new Promise((res) => { const id = ++seq; waiting.set(id, res); ws.send(JSON.stringify({ id, method, params })); }); + const evalJs = async (expression) => { + const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); + if (r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400)); + return r.result.result.value; + }; + const shot = async (name, full = false) => { + const params = { format: 'png' }; + if (full) { + const m = await send('Page.getLayoutMetrics'); + params.clip = { x: 0, y: 0, width: 1600, height: Math.min(m.result.cssContentSize.height, 6000), scale: 1 }; + params.captureBeyondViewport = true; + } + const r = await send('Page.captureScreenshot', params); + fs.writeFileSync(path.join(OUT_DIR, name), Buffer.from(r.result.data, 'base64')); + console.log(`SHOT ${name}`); + }; + const click = (js) => evalJs(`(() => { const e = ${js}; if (!e) throw new Error('없음: ${js.replace(/'/g, '')}'); e.click(); return true; })()`); + + await send('Page.enable'); + await send('Runtime.enable'); + await send('DOM.enable'); + await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false }); + await send('Page.navigate', { url: APP_URL }); + await sleep(4000); + await click(`document.getElementById('open-import')`); + await sleep(500); + const doc = await send('DOM.getDocument', { depth: -1 }); + const q = await send('DOM.querySelector', { nodeId: doc.result.root.nodeId, selector: '#file' }); + await send('DOM.setFileInputFiles', { nodeId: q.result.nodeId, files: [INPUT_FILE] }); + await sleep(3000); + await click(`document.getElementById('dlg-apply')`); + await sleep(2500); + await click(`document.getElementById('nav-rebar')`); + await sleep(1500); + await click(`[...document.querySelectorAll('#rebar-pier-items button')].find((b) => b.textContent.includes('교대'))`); + await sleep(2000); + await click(`[...document.querySelectorAll('#rebar-tabs button')].find((b) => b.textContent.startsWith('벽체'))`); + await sleep(2000); + console.log('STATUS', await evalJs(`document.getElementById('step3-status').textContent`)); + console.log('TABS', await evalJs(`[...document.querySelectorAll('#rebar-tabs button')].map((b) => b.textContent).join(' | ')`)); + console.log('NOTE', await evalJs(`document.getElementById('rebar-note').textContent`)); + console.log('ROWS', await evalJs(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter((r) => r.cells.length > 2).map((r) => [r.cells[2].textContent, r.cells[7].textContent, r.cells[8].textContent].join(' ')).join(' | ')`)); + console.log('ISSUES', await evalJs(`document.getElementById('rebar-issues').innerText.replace(/\\n+/g, ' / ')`)); + console.log('BARS3D', await evalJs(`document.querySelectorAll('canvas').length`)); + await shot('wall-1-section.png', true); + await click(`[...document.querySelectorAll('.abut-optt')].find((b) => b.textContent.startsWith('교대축 단면'))`); + await sleep(1200); + await shot('wall-2-elevation.png', true); + // W9 행 선택 → 선택만 보기. + await click(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].find((r) => r.cells[2] && r.cells[2].textContent === 'W9')`); + await sleep(800); + await click(`[...document.querySelectorAll('#rebar-table .rebar-select button')].find((b) => b.textContent === '선택만 보기')`); + await sleep(1500); + console.log('SELECTED', await evalJs(`document.querySelector('#rebar-table .rebar-select span').textContent`)); + await shot('wall-3-w9-only.png'); + console.log(`CONSOLE_EVENTS ${consoleEvents.length}`); + for (const e of consoleEvents) console.log(` ${e}`); + ws.close(); +} +main().catch((e) => { console.error('FAILED', e); process.exit(1); }); diff --git a/src/main.ts b/src/main.ts index 054f10e..aca0082 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,9 +27,12 @@ import { SLAB_EDIT_FIELDS, setAbutGround, applyFootingRebar as applyAbutFootingRebar, + applyWallRebar, } 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 { skewAngle } from './core/abut/rebar/skew-layout'; +import { lenText, skewAngle } from './core/abut/rebar/skew-layout'; +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'; import { ABUT_FOOTING_COLORS, abutFootingRebarPlanSvg, abutFootingSectionLSvg, abutFootingSectionWSvg, type AbutRebarPick } from './view/abut-footing-rebar-svg'; import { abutGroundIssues, supportTopEl, type AbutGround } from './core/abut/ground'; @@ -214,6 +217,8 @@ const state = { /** ③ 교대 (2026-11-04): 부재, 보기 탭, 비고 펼치기. 선택한 철근은 교각과 같은 rebarSelection · rebarIsolate. */ abutRebarMember: 'foot' as AbutRebarMemberId, abutRebarView: 'w' as 'w' | 'l' | 'plan', + /** ③ 교대 벽체 보기 탭 (교대축 직각 단면 / 교대축 단면 계단). */ + abutWallView: 'sec' as 'sec' | 'elev', abutRebarNotes: false, /** Member card selected on ② (시안 ② 부재 목록). */ member: 'coping' as MemberId, @@ -3084,11 +3089,11 @@ function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void { box.appendChild(wrap); } -/** ③ 교대 부재 (시안 순서, plan_abut AD9). Only the 확대기초 has rules today (AD3); the rest come next. */ +/** ③ 교대 부재 (시안 순서, plan_abut AD9). 확대기초 (AD3) · 벽체 (AD4) 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: false }, + { id: 'wall', label: '벽체', marks: 'W1–W11', ready: true }, { id: 'para', label: '흉벽·브라켓', marks: 'P1–P11', ready: false }, { id: 'foot', label: '확대기초', marks: 'B1–B10', ready: true }, { id: 'wing', label: '날개벽', marks: 'WR1–WR20', ready: false }, @@ -3108,10 +3113,24 @@ function abutFootingBuilt(rec: AbutRecord): { geom: AbutFootingGeom; result: Abu } } -/** ③ with an abutment selected (2026-11-04): 부재 · 교대 list, the member card, and the RB-AF alerts. */ +/** The 벽체 rebar of an abutment, on its 저판 grid (null when the abutment does not build). */ +function abutWallBuilt(rec: AbutRecord, footing: AbutFootingRebar): { geom: AbutWallGeom; result: AbutWallRebar } | null { + try { + const model = buildAbut(toAbutInput(rec)); + const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); + const geom = abutWallGeom(model.length, members, skewAngle(rec.skewDeg)); + return { geom, result: abutWallRebar(geom, footing, rec.rebar?.wall, 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 alerts. */ function renderAbutRebar(rec: AbutRecord): void { const built = abutFootingBuilt(rec); - const warn = built ? built.result.issues.filter((i) => i.level !== 'I').length : 0; + const wall = built ? abutWallBuilt(rec, built.result) : null; + const issuesOf = (id: AbutRebarMemberId) => (id === 'foot' ? built?.result.issues : id === 'wall' ? wall?.result.issues : undefined) ?? []; + const warnOf = (id: AbutRebarMemberId) => issuesOf(id).filter((i) => i.level !== 'I').length; const tabs = $('rebar-tabs'); tabs.replaceChildren(); for (const m of ABUT_REBAR_MEMBERS) { @@ -3121,7 +3140,7 @@ function renderAbutRebar(rec: AbutRecord): void { btn.type = 'button'; btn.title = `Type-1 · ${m.marks}${m.ready ? '' : ' · 다음 단계'}`; btn.setAttribute('aria-pressed', String(m.id === state.abutRebarMember)); - if (m.ready && warn > 0) btn.append(el('span', String(warn), 'badge warn')); + if (m.ready && warnOf(m.id) > 0) btn.append(el('span', String(warnOf(m.id)), 'badge warn')); btn.addEventListener('click', () => { state.abutRebarMember = m.id; state.rebarSelection = new Set(); @@ -3139,24 +3158,270 @@ 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) { + } else if (!built || (member.id === 'wall' && !wall)) { $('rebar-note').textContent = `${rec.name} · 교대 모델을 만들 수 없어 배근하지 않습니다.`; + } else if (member.id === 'wall' && wall) { + const g = wall.geom; + const low = g.segments.find((sg) => sg.no === 1)!; + 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 { const g = built.geom; const toe = Math.round(g.W - g.xf); $('rebar-note').textContent = `${rec.name} · Type-1 · ${Math.round(g.L)} × ${Math.round(g.W)} × ${Math.round(g.H)} · ${g.plan === 'skew' ? 'skew 평행사변형' : '직사각'} · 앞굽 ${toe} / 벽체 ${Math.round(g.xf - g.xb)} / 뒷굽 ${Math.round(g.xb)}`; fillAbutFootingRebarCard(box, rec, built); } - for (const i of built?.result.issues ?? []) { + for (const i of issuesOf(member.id)) { const li = el('li', `${i.code} ${i.message}`); if (i.level === 'W' || i.level === 'E') li.classList.add('warn'); issues.appendChild(li); } if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.')); - $('step3-status').textContent = `${rec.name} 확대기초 계산됨 · 경고 ${warn}`; + $('step3-status').textContent = member.ready ? `${rec.name} ${member.label} 계산됨 · 경고 ${warnOf(member.id)}` : `${rec.name} ${member.label} 다음 단계`; rebar3d?.show(state.rebarSelection.size ? state.rebarSelection : null, state.rebarIsolate); } +/** One ③ 교대 철근표 row's selection (교각 철근표와 같음): a row click selects the bar alone, the checkbox adds or removes it. Returns the 선택 cell. */ +function abutSelectRow(tr: HTMLTableRowElement, mark: string, togglePick: (mark: string) => void): HTMLTableCellElement { + const on = state.rebarSelection.has(mark); + if (on) tr.classList.add('hl'); + tr.addEventListener('click', () => togglePick(mark)); + const check = el('input'); + check.type = 'checkbox'; + check.checked = on; + check.setAttribute('aria-label', `${mark} 선택`); + // The checkbox changes the selection on its own; its click must not reach the row handler. + check.addEventListener('click', (ev) => ev.stopPropagation()); + check.addEventListener('change', () => { + const next = new Set(state.rebarSelection); + if (check.checked) next.add(mark); + else next.delete(mark); + state.rebarSelection = next; + renderAll(); + }); + const cell = el('td'); + cell.append(check); + 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; + 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]); + renderAll(); + }; + // 보기 탭. + 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' : ''}`); + b.type = 'button'; + b.setAttribute('aria-pressed', String(state.abutWallView === id)); + b.addEventListener('click', () => { + state.abutWallView = id; + renderAll(); + }); + head.append(b); + } + box.append(head); + const picked = r.bars.filter((b) => state.rebarSelection.has(b.mark)); + box.append( + el( + 'p', + picked.length === 0 + ? '전체 표시 · 표의 행이나 도면의 번호 풍선을 누르면 그 철근만 강조합니다' + : `강조 중 · ${picked.map((b) => b.mark).join(' · ')}${picked.length === 1 ? ` — ${picked[0].position}` : ''}${state.rebarIsolate ? ' · 선택만 보기' : ''} · 철근표의 [전체 해제]로 해제`, + 'muted small', + ), + ); + const fig = el('figure', undefined, 'ground-fig abut-rebar-fig'); + fig.innerHTML = state.abutWallView === 'sec' ? abutWallRebarSectionSvg(geom, r, pick) : abutWallRebarElevationSvg(geom, r, pick); + fig.querySelectorAll('[data-pick]').forEach((g) => { + const mark = g.getAttribute('data-pick')!; + g.addEventListener('click', () => togglePick(mark)); + g.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + togglePick(mark); + } + }); + }); + 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', + ), + ); + 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; + const legend = el('p', undefined, 'muted small abut-rebar-legend'); + legend.innerHTML = `━ 연직 (W1 · W3 · W4 · W11) · ━ 수평 · 교좌면 (W2 · W5 · W6 · W8) · ━ 계단 상면 (W9 실선 · W10 파선) · ┅ 간격재 W7`; + 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 lab = el('label', label); + lab.htmlFor = id; + const input = el('input'); + input.id = id; + input.type = 'number'; + input.step = 'any'; + input.value = String(value); + if (value !== def) input.classList.add('changed-input'); + input.addEventListener('change', () => set(apply(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); + 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); + { + const id = `awr-${rec.name}-k7`; + const lab = el('label', '간격재 W7 배수 (교대축 × 연직)'); + 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.addEventListener('change', () => { + const n = input.value.split(/[×x*,\s]+/).filter(Boolean).map(Number); + if (n.length !== 2) { + setError('W7 배수는 "8 × 4"처럼 두 정수로 입력하세요.'); + renderAll(); + return; + } + set({ k7L: n[0], k7V: 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) => { + 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); + bar.append(el('span', assumed.length ? `가정값 ${assumed.length}개 (${assumed.join(' · ')}) · 나머지는 규칙 계산` : '가정값 없음 · 모든 철근이 규칙 계산', 'muted small')); + const notes = el('button', state.abutRebarNotes ? '비고 접기' : '비고 모두 펼치기', 'btn small'); + notes.type = 'button'; + notes.addEventListener('click', () => { + state.abutRebarNotes = !state.abutRebarNotes; + renderAll(); + }); + bar.append(notes); + box.append(bar); + box.append(rebarSelectBar(r.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) { + 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}`; + const actual = el('td'); + const ov = overrideOf[b.mark]; + if (ov) { + const input = el('input'); + input.type = 'number'; + input.step = 'any'; + input.value = b.actual; + 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) })); + actual.append(input); + if (b.modified) { + actual.append(el('span', ' 수정됨', 'muted small abut-note-warn')); + const back = el('button', '되돌리기', 'btn small'); + back.type = 'button'; + back.addEventListener('click', (ev) => { + ev.stopPropagation(); + set({ [ov.key]: undefined }); + }); + actual.append(back); + } + } else { + actual.textContent = b.actual + (b.modified && b.mark !== 'W7' ? ' · 수정됨' : ''); + } + const len = el('td'); + len.append(`${b.length}${b.lengthMax !== b.length ? ` ~ ${b.lengthMax}` : ''} `, 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(); + const cell = el('td', `${b.mark} 비고 · ${b.note}`, 'muted small'); + cell.colSpan = 10; + nr.append(cell); + } + } + const scroll = el('div', undefined, 'table-scroll'); + scroll.append(table); + box.append(scroll); + const SZ = ABUT_WALL_SIZES; + 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', + ), + ); +} + /** ③ 교대 확대기초 카드 (시안, 교대확대기초철근.md §10): 보기 탭 · 삽도 → 범례 → 기본간격 · 배근 방향 · 입력 → 자동 값 → 철근표 → 근거. */ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutFootingGeom; result: AbutFootingRebar; skewK: number }): void { const { geom, result: r, skewK } = built; @@ -3328,23 +3593,7 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { for (const b of r.bars) { const on = state.rebarSelection.has(b.mark); const tr = body.insertRow(); - if (on) tr.classList.add('hl'); - tr.addEventListener('click', () => togglePick(b.mark)); - const check = el('input'); - check.type = 'checkbox'; - check.checked = on; - check.setAttribute('aria-label', `${b.mark} 선택`); - // The checkbox changes the selection on its own; its click must not reach the row handler. - check.addEventListener('click', (ev) => ev.stopPropagation()); - check.addEventListener('change', () => { - const next = new Set(state.rebarSelection); - if (check.checked) next.add(b.mark); - else next.delete(b.mark); - state.rebarSelection = next; - renderAll(); - }); - const checkCell = el('td'); - checkCell.append(check); + const checkCell = abutSelectRow(tr, b.mark, togglePick); const role = el('td'); role.innerHTML = ` ${b.role}`; const markCell = el('td', b.mark, 'bar-no');