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('커플러 (개수만)'); + }); +});