From b6bf087334cf4f8662f7e77016558574c3cee7fc Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 23 Oct 2026 10:16:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(rebar):=20=EC=B2=A0=EA=B7=BC=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=ED=91=9C=20by=20=EA=B0=95=EC=A2=85=20=E2=86=92=20?= =?UTF-8?q?=EC=A7=81=EA=B2=BD=20=E2=86=92=20=EB=B2=88=ED=98=B8=20with=20un?= =?UTF-8?q?it=20weights,=20=ED=95=A0=EC=A6=9D=20and=20Excel=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.html | 1 + src/core/rebar/common.ts | 9 ++- src/core/rebar/quantity.ts | 122 +++++++++++++++++++++++++++++++++++ src/main.ts | 60 +++++++++++++++++ tests/project.test.ts | 2 +- tests/rebar-grade.test.ts | 2 +- tests/rebar-quantity.test.ts | 51 +++++++++++++++ 7 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 src/core/rebar/quantity.ts create mode 100644 tests/rebar-quantity.test.ts 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('커플러 (개수만)'); + }); +});