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

+1
View File
@@ -181,6 +181,7 @@
<label class="mat">D 규격 강도 <select id="mat-grade-d"><option value="300" selected>SD300</option><option value="400">SD400</option><option value="500">SD500</option></select></label>
<label class="mat">H 규격 강도 <select id="mat-grade-h"><option value="300">SD300</option><option value="400" selected>SD400</option><option value="500">SD500</option></select></label>
<label class="mat">S 규격 강도 <select id="mat-grade-s"><option value="300">SD300</option><option value="400">SD400</option><option value="500" selected>SD500</option></select></label>
<label class="mat">재료표 할증 (%) · 기본 0 가정 <input id="mat-waste" type="number" step="0.5" min="0" /></label>
<p class="lbl">굽힘 내면반경 (× 철근 지름)</p>
<label class="mat">D16 이하 <input id="bend-small16" type="number" step="0.5" min="0.5" /></label>
<label class="mat">D19–D25 <input id="bend-main25" type="number" step="0.5" min="0.5" /></label>
+6 -3
View File
@@ -49,9 +49,11 @@ export interface Material {
grade: Record<GradeKey, number>;
/** 굽힘 내면반경 배수 (입력). */
bend: BendFactors;
/** 재료표 할증 (%, 입력). Default 0 is an assumption. */
waste: number;
}
export const DEFAULT_MATERIAL: Material = { fck: 27, grade: { D: 300, H: 400, S: 500 }, bend: DEFAULT_BEND };
export const DEFAULT_MATERIAL: Material = { fck: 27, grade: { D: 300, H: 400, S: 500 }, bend: DEFAULT_BEND, waste: 0 };
/** Yield strength (MPa) of a bar from its 규격, e.g. H19 → the H grade. */
export function fyOf(m: Material, size: string): number {
@@ -63,7 +65,7 @@ export function fyOf(m: Material, size: string): number {
* A value outside GRADE_CHOICES falls back to the default for that prefix.
*/
export function materialFrom(raw: unknown): Material {
const r = (raw ?? {}) as { fck?: unknown; grade?: Partial<Record<GradeKey, unknown>>; fyH?: unknown; fyS?: unknown; bend?: Partial<Record<keyof BendFactors, unknown>> };
const r = (raw ?? {}) as { fck?: unknown; grade?: Partial<Record<GradeKey, unknown>>; fyH?: unknown; fyS?: unknown; bend?: Partial<Record<keyof BendFactors, unknown>> ; waste?: unknown };
const factor = (v: unknown, fallback: number) => (typeof v === 'number' && v > 0 ? v : fallback);
const bend: BendFactors = {
small16: factor(r.bend?.small16, DEFAULT_BEND.small16),
@@ -77,7 +79,8 @@ export function materialFrom(raw: unknown): Material {
H: pick(r.grade?.H ?? r.fyH, DEFAULT_MATERIAL.grade.H),
S: pick(r.grade?.S ?? r.fyS, DEFAULT_MATERIAL.grade.S),
};
return { fck: typeof r.fck === 'number' ? r.fck : DEFAULT_MATERIAL.fck, grade, bend };
const waste = typeof r.waste === 'number' && r.waste >= 0 ? r.waste : DEFAULT_MATERIAL.waste;
return { fck: typeof r.fck === 'number' ? r.fck : DEFAULT_MATERIAL.fck, grade, bend, waste };
}
/** Members that can take their own fck on ② (부재 카드). Other members always use the 공통 fck. */
+122
View File
@@ -0,0 +1,122 @@
/**
* 철근재료표 (quantity table): bars grouped by 강종 (grade) and nominal diameter, with unit weights and totals.
* Pure functions. Rows with no length yet are listed as missing and left out of the totals (no guessed lengths).
*/
import { fyOf, Material, NOMINAL_DIAMETER, type BarSize } from './common';
import type { RebarRow } from './summary';
/** Unit weight (kg/m) of a bar of nominal diameter d (mm): 0.00617 · d². H19 (19.1) → 2.25. */
export function unitWeight(dMm: number): number {
return 0.00617 * dMm * dMm;
}
/** True for a 규격 that is a rebar size (H19, S32, D13 …), not a coupler label. */
export function isBarSize(size: string): size is BarSize {
return size in NOMINAL_DIAMETER;
}
export interface QuantityLine {
name: string;
size: string;
member: RebarRow['member'];
count: number;
/** Length of one bar (mm). */
length: number;
/** Total length (m) = count × length. */
totalM: number;
kgPerM: number;
/** Total weight (kg) before the 할증. */
kg: number;
shape: string;
}
export interface QuantityDiameter {
size: string;
dMm: number;
lines: QuantityLine[];
totalM: number;
kg: number;
}
export interface QuantityGroup {
grade: number;
diameters: QuantityDiameter[];
kg: number;
}
export interface Quantity {
groups: QuantityGroup[];
couplers: { name: string; count: number }[];
/** Bar names with no length yet; they are not in the totals. */
missing: string[];
kg: number;
/** 할증 applied to the total, in percent (입력, 기본 0 가정). */
wastePercent: number;
kgWithWaste: number;
ton: number;
}
export function buildQuantity(rows: RebarRow[], material: Material): Quantity {
const byGrade = new Map<number, Map<string, QuantityDiameter>>();
const couplers: { name: string; count: number }[] = [];
const missing: string[] = [];
for (const r of rows) {
if (r.name.includes('커플러')) {
couplers.push({ name: r.name, count: r.count });
continue;
}
if (!isBarSize(r.size)) continue;
if (r.length === null) {
missing.push(r.name);
continue;
}
const grade = fyOf(material, r.size);
const dMm = NOMINAL_DIAMETER[r.size];
const kgPerM = unitWeight(dMm);
const totalM = (r.count * r.length) / 1000;
const line: QuantityLine = { name: r.name, size: r.size, member: r.member, count: r.count, length: r.length, totalM, kgPerM, kg: totalM * kgPerM, shape: r.shape };
const diameters = byGrade.get(grade) ?? new Map<string, QuantityDiameter>();
const d = diameters.get(r.size) ?? { size: r.size, dMm, lines: [], totalM: 0, kg: 0 };
d.lines.push(line);
d.totalM += totalM;
d.kg += line.kg;
diameters.set(r.size, d);
byGrade.set(grade, diameters);
}
const groups: QuantityGroup[] = [...byGrade.entries()]
.sort(([a], [b]) => a - b)
.map(([grade, diameters]) => {
const list = [...diameters.values()].sort((a, b) => a.dMm - b.dMm);
return { grade, diameters: list, kg: list.reduce((s, d) => s + d.kg, 0) };
});
const kg = groups.reduce((s, g) => s + g.kg, 0);
const wastePercent = material.waste;
const kgWithWaste = kg * (1 + wastePercent / 100);
return { groups, couplers, missing, kg, wastePercent, kgWithWaste, ton: kgWithWaste / 1000 };
}
/** The 철근재료표 as rows (for the Excel export): group and diameter headers, lines, subtotals, totals. */
export function quantityRows(q: Quantity): (string | number)[][] {
const out: (string | number)[][] = [['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']];
for (const g of q.groups) {
for (const d of g.diameters) {
for (const l of d.lines) {
out.push([`SD${g.grade}`, l.name, l.shape, l.size, l.count, l.length, round(l.totalM), round(l.kgPerM, 3), round(l.kg)]);
}
out.push(['', `${d.size} 소계`, '', d.size, '', '', round(d.totalM), '', round(d.kg)]);
}
out.push([`SD${g.grade} 소계`, '', '', '', '', '', '', '', round(g.kg)]);
}
out.push(['합계', '', '', '', '', '', '', '', round(q.kg)]);
out.push([`할증 ${q.wastePercent}% 포함`, '', '', '', '', '', '', '', round(q.kgWithWaste)]);
out.push(['총중량 (ton)', '', '', '', '', '', '', '', round(q.ton, 3)]);
for (const c of q.couplers) out.push(['커플러 (개수만)', c.name, '', '', c.count, '', '', '', '']);
return out;
}
function round(v: number, digits = 1): number {
const f = 10 ** digits;
return Math.round(v * f) / f;
}
+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));
}
+1 -1
View File
@@ -68,7 +68,7 @@ describe('project file round trip (spec §8, plan B3)', () => {
describe('③ 공통 설정 in the project file', () => {
it('fck and fy are kept through save and open; older files get the defaults through the app', () => {
const p = sampleProject();
const withCommon = { ...p, common: { fck: 35, grade: { D: 300, H: 400, S: 500 }, bend: { small16: 2, main25: 3, main35: 4, main51: 5 } } };
const withCommon = { ...p, common: { fck: 35, grade: { D: 300, H: 400, S: 500 }, bend: { small16: 2, main25: 3, main35: 4, main51: 5 }, waste: 0 } };
expect(parseProject(serializeProject(withCommon)).common).toEqual(withCommon.common);
expect(parseProject(serializeProject(p)).common).toBeUndefined();
});
+1 -1
View File
@@ -19,7 +19,7 @@ describe('③ 철근 강도: 규격 접두어별 입력 (승인 2026-10-23)', ()
});
it('an older file that stored fyH / fyS is read into the grade map', () => {
expect(materialFrom({ fck: 35, fyH: 400, fyS: 400 })).toEqual({ fck: 35, grade: { D: 300, H: 400, S: 400 }, bend: DEFAULT_BEND });
expect(materialFrom({ fck: 35, fyH: 400, fyS: 400 })).toEqual({ fck: 35, grade: { D: 300, H: 400, S: 400 }, bend: DEFAULT_BEND, waste: 0 });
});
it('a value outside SD300 · SD400 · SD500 falls back to the default for its prefix', () => {
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
import { buildQuantity, quantityRows, unitWeight } from '../src/core/rebar/quantity';
import { rebarSummary } from '../src/core/rebar/summary';
import { loadP1 } from './helpers';
const model = buildPier(loadP1());
const rows = rebarSummary(model).rows;
describe('철근재료표 (승인 2026-10-23)', () => {
it('unit weight is 0.00617 · d² on the nominal diameter (H19 19.1 → 2.25 kg/m)', () => {
expect(unitWeight(19.1)).toBeCloseTo(2.25, 2);
});
it('groups by 강종 then diameter; the group totals add up to the total', () => {
const q = buildQuantity(rows, DEFAULT_MATERIAL);
expect(q.groups.map((g) => g.grade)).toEqual([300, 400, 500].filter((g) => q.groups.some((x) => x.grade === g)));
expect(q.groups.reduce((s, g) => s + g.kg, 0)).toBeCloseTo(q.kg, 6);
for (const g of q.groups) {
expect(g.diameters.reduce((s, d) => s + d.kg, 0)).toBeCloseTo(g.kg, 6);
}
});
it('bars with no length yet are listed as missing and kept out of the totals', () => {
const q = buildQuantity(rows, DEFAULT_MATERIAL);
expect(q.missing).toContain('C9');
expect(q.missing).toContain('C11');
expect(q.groups.flatMap((g) => g.diameters.flatMap((d) => d.lines.map((l) => l.name)))).not.toContain('C9');
});
it('couplers are counted, not weighed', () => {
const q = buildQuantity(rows, DEFAULT_MATERIAL);
expect(q.couplers.find((c) => c.name === '주철근 커플러')!.count).toBe(368);
expect(q.couplers.find((c) => c.name === '띠철근 커플러')!.count).toBe(35);
});
it('할증 is 0% by default and multiplies the total when set', () => {
const q0 = buildQuantity(rows, DEFAULT_MATERIAL);
expect(q0.kgWithWaste).toBeCloseTo(q0.kg, 6);
const q10 = buildQuantity(rows, { ...DEFAULT_MATERIAL, waste: 10 });
expect(q10.kgWithWaste).toBeCloseTo(q0.kg * 1.1, 6);
expect(q10.ton).toBeCloseTo(q0.kg * 1.1 / 1000, 9);
});
it('the Excel rows start with the header and end with the couplers', () => {
const aoa = quantityRows(buildQuantity(rows, DEFAULT_MATERIAL));
expect(aoa[0][0]).toBe('강종');
expect(aoa[aoa.length - 1][0]).toBe('커플러 (개수만)');
});
});