feat(rebar): 철근재료표 by 강종 → 직경 → 번호 with unit weights, 할증 and Excel export

The quantity table sits below the 철근표 for the whole pier. Lines group by grade and nominal diameter, with
subtotals, unit weight 0.00617·d² (kg/m), the total in kg and ton, and a 할증 total. 할증 is an input in 공통 설정,
default 0 as an assumption. Couplers are counted, not weighed. Bars with no length yet (C9, C10, C11, C12, P1, P2) are
listed as missing and kept out of the totals. The table exports to Excel with SheetJS.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-23 10:16:56 +09:00
1 parent 30e5e75c22
commit b6bf087334
7 files changed
+242 -5

No files matched your search

+60
View File
@@ -14,6 +14,7 @@ import { rebarSummary, type RebarRow } from './core/rebar/summary';
import { shearSegments } from './core/rebar/coping';
import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
import { buildQuantity, quantityRows } from './core/rebar/quantity';
import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection';
import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options';
import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project';
@@ -949,11 +950,56 @@ function renderRebar(): void {
box.appendChild(st);
}
// Table and 3D model point at each other: the focused bar is highlighted in both.
renderQuantity(box, rows, pier.input.name);
rebar3d?.show(state.rebarSelection.size ? state.rebarSelection : null, state.rebarIsolate);
const focused = box.querySelector('tr.hl');
if (focused && state.rebarSelection.size) focused.scrollIntoView({ block: 'center' });
}
/** 철근재료표 (whole pier): bars by 강종 → 직경 → 번호 with subtotals; 할증 and the Excel export (승인 2026-10-23). */
function renderQuantity(box: HTMLElement, rows: RebarRow[], pierName: string): void {
const q = buildQuantity(rows, state.material);
box.appendChild(el('p', '철근재료표 · 교각 전체', 'lbl'));
const info = el('p', `할증 ${q.wastePercent}% (기본 0%, 가정). 길이가 없는 철근 ${q.missing.length}개는 제외했습니다${q.missing.length ? `: ${q.missing.join(', ')}` : ''}. 커플러는 개수만 적습니다.`, 'muted small');
box.appendChild(info);
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
for (const g of q.groups) {
for (const d of g.diameters) {
for (const l of d.lines) {
body.appendChild(el('tr', undefined)).append(
el('td', `SD${g.grade}`), el('td', l.name, 'bar-no'), el('td', l.shape), el('td', l.size), el('td', String(l.count)),
el('td', String(l.length)), el('td', l.totalM.toFixed(1)), el('td', l.kgPerM.toFixed(3)), el('td', l.kg.toFixed(1)),
);
}
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', ''), el('td', `${d.size} 소계`), el('td', ''), el('td', d.size), el('td', ''), el('td', ''), el('td', d.totalM.toFixed(1)), el('td', ''), el('td', d.kg.toFixed(1)));
}
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1)));
}
table.appendChild(body);
box.appendChild(table);
const totals = el('ul', undefined, 'pnote');
totals.append(
el('li', `합계 ${q.kg.toFixed(1)} kg`),
el('li', `할증 ${q.wastePercent}% 포함 ${q.kgWithWaste.toFixed(1)} kg · ${q.ton.toFixed(3)} ton`),
...q.couplers.map((c) => el('li', `${c.name}: ${c.count}개`)),
);
box.appendChild(totals);
const exportBtn = el('button', '엑셀로 내보내기', 'btn small');
exportBtn.type = 'button';
exportBtn.addEventListener('click', () => {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(quantityRows(q)), '철근재료표');
XLSX.writeFile(wb, `${pierName}_철근재료표.xlsx`);
});
box.appendChild(exportBtn);
}
/** Above the 철근표: selection count, select-all / clear for the shown member, and 선택만 보기 (승인 2026-10-23). */
function rebarSelectBar(names: string[]): HTMLDivElement {
const bar = el('div', undefined, 'rebar-select');
@@ -1003,6 +1049,7 @@ function renderMaterial(): void {
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
$<HTMLInputElement>(`bend-${k}`).value = String(m.bend[k]);
}
$<HTMLInputElement>('mat-waste').value = String(m.waste);
$('mat-note').textContent = assumed ? 'fck 가정값 (I02): 입력을 바꾸면 정착·이음 길이가 달라집니다.' : '입력값을 사용합니다.';
}
@@ -1050,6 +1097,18 @@ function renderMemberFck(box: HTMLDivElement, member: FckMember): void {
box.appendChild(row);
}
/** 재료표 할증 (%) on 공통 설정. Zero or more. */
function setWaste(raw: string): void {
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
setStatus('재료표 할증은 0 이상의 숫자여야 합니다.');
renderMaterial();
return;
}
state.material = { ...state.material, waste: value };
renderAll();
}
/** Bend factor (× diameter) input on 공통 설정. Must be a positive number. */
function setBend(key: keyof BendFactors, raw: string): void {
const value = Number(raw);
@@ -1477,6 +1536,7 @@ $<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shap
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
$<HTMLInputElement>('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value));
$<HTMLInputElement>('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value));
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
$<HTMLInputElement>(`bend-${k}`).addEventListener('change', (ev) => setBend(k, (ev.target as HTMLInputElement).value));
}