feat(abut-rebar): ③ 교대 벽체 카드

- 보기 탭(교대축 직각 단면 / 계단 단면) · 삽도 · 구간 메모 · 범례
- 입력: 기본간격 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 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 16:31:03 +09:00
1 parent 079748e5e7
commit a17c9ebbb4
2 files changed
+365 -26

No files matched your search

+90
View File
@@ -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 <input.xlsx> <out dir>
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); });
+275 -26
View File
@@ -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 = $<HTMLUListElement>('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<AbutWallRebarSettings>) => {
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<SVGGElement>('[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 = `<span style="color:${C.vertical}">━ 연직 (W1 · W3 · W4 · W11)</span> · <span style="color:${C.horizontal}">━ 수평 · 교좌면 (W2 · W5 · W6 · W8)</span> · <span style="color:${C.step}">━ 계단 상면 (W9 실선 · W10 파선)</span> · <span style="color:${C.tie}">┅ 간격재 W7</span>`;
box.append(legend);
// 입력: 기본간격 · 피복 · 첫 철근 최소 여유 · W7 배수 (사용자 결정 2026-11-04: W7 배수 편집, e_min 표시).
const num = (into: HTMLElement, label: string, value: number, apply: (v: number) => Partial<AbutWallRebarSettings>, 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<string, string> = { 연직: C.vertical, 수평: C.horizontal, 간격재: C.tie, '계단 상면': C.step };
// 묶음별 간격 수정 (사용자 결정 2026-11-04): W3 · W4 (교대축), W5 · W6 · W8 (연직), W9 · W10 (폭). W1 · W2는 연직 격자를 따름.
const overrideOf: Partial<Record<string, { key: 'sVert' | 'sHoriz' | 'sTop'; base: number; label: string }>> = {
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 = `<span style="display:inline-block;width:22px;border-top:3px solid ${roleColor[b.role]};vertical-align:middle"></span> ${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 = `<span style="display:inline-block;width:22px;border-top:3px solid ${roleColor[b.role]};vertical-align:middle"></span> ${b.role}`;
const markCell = el('td', b.mark, 'bar-no');