diff --git a/index.html b/index.html
index abaa213..eb1dddd 100644
--- a/index.html
+++ b/index.html
@@ -181,6 +181,7 @@
+
굽힘 내면반경 (× 철근 지름)
diff --git a/src/core/rebar/common.ts b/src/core/rebar/common.ts
index 4e65548..b9a0f31 100644
--- a/src/core/rebar/common.ts
+++ b/src/core/rebar/common.ts
@@ -49,9 +49,11 @@ export interface Material {
grade: Record;
/** 굽힘 내면반경 배수 (입력). */
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>; fyH?: unknown; fyS?: unknown; bend?: Partial> };
+ const r = (raw ?? {}) as { fck?: unknown; grade?: Partial>; fyH?: unknown; fyS?: unknown; bend?: Partial> ; 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. */
diff --git a/src/core/rebar/quantity.ts b/src/core/rebar/quantity.ts
new file mode 100644
index 0000000..1224184
--- /dev/null
+++ b/src/core/rebar/quantity.ts
@@ -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>();
+ 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();
+ 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;
+}
diff --git a/src/main.ts b/src/main.ts
index 4778140..717bed8 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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) {
$(`bend-${k}`).value = String(m.bend[k]);
}
+ $('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 @@ $('nav-shape').addEventListener('click', () => setStage('shap
$('nav-plan').addEventListener('click', () => setStage('plan'));
$('nav-rebar').addEventListener('click', () => setStage('rebar'));
$('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value));
+$('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value));
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
$(`bend-${k}`).addEventListener('change', (ev) => setBend(k, (ev.target as HTMLInputElement).value));
}
diff --git a/tests/project.test.ts b/tests/project.test.ts
index c678722..d19413f 100644
--- a/tests/project.test.ts
+++ b/tests/project.test.ts
@@ -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();
});
diff --git a/tests/rebar-grade.test.ts b/tests/rebar-grade.test.ts
index 5af90e2..ef10ee9 100644
--- a/tests/rebar-grade.test.ts
+++ b/tests/rebar-grade.test.ts
@@ -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', () => {
diff --git a/tests/rebar-quantity.test.ts b/tests/rebar-quantity.test.ts
new file mode 100644
index 0000000..1687a8d
--- /dev/null
+++ b/tests/rebar-quantity.test.ts
@@ -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('커플러 (개수만)');
+ });
+});