diff --git a/src/core/rebar/quantity.ts b/src/core/rebar/quantity.ts index 1224184..3bd50aa 100644 --- a/src/core/rebar/quantity.ts +++ b/src/core/rebar/quantity.ts @@ -19,9 +19,13 @@ export interface QuantityLine { name: string; size: string; member: RebarRow['member']; + /** 개수 as in the 철근표 row (bars; for P1 each bar is several pieces). */ count: number; - /** Length of one bar (mm). */ - length: number; + /** Pieces in the row: the sum of the piece counts (C9 108 = 18 positions × 6 pieces). */ + pieceCount: number; + /** Length of one bar (mm), or null when the row has several lengths (see pieces). */ + length: number | null; + pieces: { length: number; count: number }[]; /** Total length (m) = count × length. */ totalM: number; kgPerM: number; @@ -67,15 +71,21 @@ export function buildQuantity(rows: RebarRow[], material: Material): Quantity { continue; } if (!isBarSize(r.size)) continue; - if (r.length === null) { + // A row with several lengths carries pieces; a row with one length is one piece (count bars of that length). + const pieces = r.pieces ?? (r.length === null ? null : [{ length: r.length, count: r.count }]); + if (!pieces) { 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 pieceCount = pieces.reduce((s, p) => s + p.count, 0); + const totalM = pieces.reduce((s, p) => s + (p.count * p.length) / 1000, 0); + const line: QuantityLine = { + name: r.name, size: r.size, member: r.member, count: r.count, pieceCount, + length: pieces.length === 1 ? pieces[0].length : null, pieces, 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); @@ -103,7 +113,7 @@ export function quantityRows(q: Quantity): (string | number)[][] { 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([`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)]); } diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index 2839386..c88dbb9 100644 --- a/src/core/rebar/summary.ts +++ b/src/core/rebar/summary.ts @@ -15,7 +15,10 @@ export interface RebarRow { name: string; size: string; count: number; + /** One length (mm) when the row has a single length; null when the row has several (see pieces). */ length: number | null; + /** Lengths of the bars in this row, as pieces: count bars of length (mm). Their sum is the row's total (재료표). */ + pieces?: { length: number; count: number }[]; note: string; /** 간격 (설계 간격, mm or text), 실간격 (cycle 묶음 기준), 위치, 방향, 형상, 근거. */ spacing: string; @@ -170,11 +173,16 @@ export function rebarSummary( }; const shearNote = (label: string, xs: number[]) => `${label}, 하부 U ${uRange(xs)}, 삿갓 ${capRange(xs)} (l_cap 추천안, Q-K5), 조각 ${xs.length * PIECES_PER_POSITION}`; - rows.push({ member: 'coping', name: 'C9', size: 'H25', count: shear.c9.length, length: null, note: shearNote('전단 좌 @250', shear.c9) }); - rows.push({ member: 'coping', name: 'C10', size: 'H25', count: shear.c10.length, length: null, note: shearNote('전단 Level @300', shear.c10) }); - rows.push({ member: 'coping', name: 'C11', size: 'H25', count: shear.c11.length, length: null, note: shearNote('전단 우 @250', shear.c11) }); + // Each position has three 폐합, each with one 하부 U and one 삿갓 (6 pieces). Pieces: U and 삿갓 lengths at each position. + const loopPieces = (xs: number[]) => xs.flatMap((x) => [ + { length: shearULength(x, L), count: 3 }, + { length: capLength(x, L), count: 3 }, + ]); + rows.push({ member: 'coping', name: 'C9', size: 'H25', count: shear.c9.length, length: null, pieces: loopPieces(shear.c9), note: shearNote('전단 좌 @250', shear.c9) }); + rows.push({ member: 'coping', name: 'C10', size: 'H25', count: shear.c10.length, length: null, pieces: loopPieces(shear.c10), note: shearNote('전단 Level @300', shear.c10) }); + rows.push({ member: 'coping', name: 'C11', size: 'H25', count: shear.c11.length, length: null, pieces: loopPieces(shear.c11), note: shearNote('전단 우 @250', shear.c11) }); if (shear.between.length > 0) { - rows.push({ member: 'coping', name: 'C9·C11 사이', size: 'H25', count: shear.between.length, length: null, note: `기둥 사이, 양쪽 C10에서 안쪽으로 (추천안, Q-K2), 조각 ${shear.between.length * PIECES_PER_POSITION}` }); + rows.push({ member: 'coping', name: 'C9·C11 사이', size: 'H25', count: shear.between.length, length: null, pieces: loopPieces(shear.between), note: `기둥 사이, 양쪽 C10에서 안쪽으로 (추천안, Q-K2), 조각 ${shear.between.length * PIECES_PER_POSITION}` }); } if (nCol > 1) { warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 T형 기준으로 두었습니다 (기둥마다 반복 규칙 미정, Q-K11).`, target: 'coping' }); @@ -183,8 +191,10 @@ export function rebarSummary( const side = copingSideBars(model.copingL * 1000, 41.288, 42.538); const hook = model.copingB * 1000 - 2 * 127 + 2 * SIDE_HOOK_LEG; // ㄷ body across the width + two legs const c12Range = side.c12.length ? `${Math.round(side.c12[0].straight)}–${Math.round(side.c12[side.c12.length - 1].straight)}` : '—'; - rows.push({ member: 'coping', name: 'C12', size: 'H25', count: 4 * side.c12.length, length: null, note: `측면, 경사 하면에서 끊김, 일자 ${c12Range} (${2 * side.c12.length}) · ㄷ ${Math.round(hook)} (${2 * side.c12.length})` }); - rows.push({ member: 'coping', name: 'C13', size: 'H25', count: 4 * side.c13.length, length: side.c13.length ? side.c13[0].straight : null, note: `측면, 끝면까지, 일자 (${2 * side.c13.length}) · ㄷ ${Math.round(hook)} (${2 * side.c13.length})` }); + // Per bar: 2 일자 (straight) and 2 ㄷ (hook body + legs), so 4 bars per row position. + const sidePieces = (bars: { straight: number }[]) => bars.flatMap((b) => [{ length: b.straight, count: 2 }, { length: hook, count: 2 }]); + rows.push({ member: 'coping', name: 'C12', size: 'H25', count: 4 * side.c12.length, length: null, pieces: sidePieces(side.c12), note: `측면, 경사 하면에서 끊김, 일자 ${c12Range} (${2 * side.c12.length}) · ㄷ ${Math.round(hook)} (${2 * side.c12.length})` }); + rows.push({ member: 'coping', name: 'C13', size: 'H25', count: 4 * side.c13.length, length: null, pieces: sidePieces(side.c13), note: `측면, 끝면까지, 일자 (${2 * side.c13.length}) · ㄷ ${Math.round(hook)} (${2 * side.c13.length})` }); // C14 / C15 / C16 are derived from the model (코핑철근.md §7–8); the document's 38 / 11 / 22 and 3852 / 4070 / 4069 are checked in the tests. const L14 = model.copingL * 1000; const c14 = c14Bars(L14, model.copingRefTop, [...side.c12, ...side.c13], (x) => undersideAt(L14, x, model.endBottom, model.levelBottom)); @@ -197,8 +207,8 @@ export function rebarSummary( // Column (기둥철근.md · plan C4), P1 defaults const col = columnRebar(COLUMN_P1, mColumn); - rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` }); - rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` }); + rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, pieces: col.p1Pieces.map((length) => ({ length, count: col.mainP1 })), note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` }); + rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, pieces: col.p2Pieces.map((length) => ({ length, count: col.mainP2 })), note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` }); rows.push({ member: 'column', name: '주철근 커플러', size: 'D32용 (L150 Ø60)', count: col.couplers, length: 150, note: '@3000, P2는 +150 mm 상향 엇갈림' }); const tieCouplerCount = col.p5Count + col.p4Count * 2; rows.push({ member: 'column', name: '띠철근 커플러', size: 'D19용 (L100 Ø40)', count: tieCouplerCount, length: 100, note: `단부 P5 1개(${col.p5Count}) · 기초안 P4 2개(${col.p4Count * 2}) 폐합용` }); diff --git a/src/main.ts b/src/main.ts index d9eda0c..025e540 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,7 +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, type Quantity } from './core/rebar/quantity'; +import { buildQuantity, quantityRows, type Quantity, type QuantityLine } 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'; @@ -890,7 +890,7 @@ function renderRebar(): void { el('td', r.position), el('td', r.direction), el('td', r.shape), - el('td', r.length === null ? '—' : String(Math.round(r.length * 10) / 10)), + el('td', r.length === null ? (r.pieces ? '여러 길이' : '—') : String(Math.round(r.length * 10) / 10), undefined), el('td', String(r.count)), basisCell(r.basis), el('td', r.note), @@ -980,6 +980,14 @@ function quantityTargets(): Pier[] { return active?.model ? [active] : []; } +/** Length cell of a 재료표 line: the length, or 여러 길이 with the pieces on hover. */ +function lengthCell(l: QuantityLine): HTMLTableCellElement { + if (l.length !== null) return el('td', String(l.length)); + const cell = el('td', `여러 길이 (${l.pieces.length}종)`); + cell.title = l.pieces.map((p) => `${Math.round(p.length)} × ${p.count}`).join(' · '); + return cell; +} + function quantityTable(q: Quantity): HTMLTableElement { const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); @@ -993,7 +1001,7 @@ function quantityTable(q: Quantity): HTMLTableElement { for (const l of d.lines) { body.appendChild(el('tr')).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)), + lengthCell(l), 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))); @@ -1011,7 +1019,7 @@ function renderQuantityScreen(): void { const targets = quantityTargets(); $('step4-status').textContent = targets.length ? `${targets.length}기` : '예정'; $('quantity-note').textContent = targets.length - ? `${targets.length}기 · 할증 ${state.material.waste}% (기본 0%, 가정). 체크한 교각 기준이고, 체크가 없으면 현재 교각입니다.` + ? `${targets.length}기 · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 체크한 교각 기준이고, 체크가 없으면 현재 교각입니다.` : '계산된 교각이 없습니다. 교각을 생성하세요.'; let kg = 0; let kgWithWaste = 0; diff --git a/tests/rebar-pieces.test.ts b/tests/rebar-pieces.test.ts new file mode 100644 index 0000000..352631e --- /dev/null +++ b/tests/rebar-pieces.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { DEFAULT_MATERIAL } from '../src/core/rebar/common'; +import { buildQuantity } from '../src/core/rebar/quantity'; +import { rebarSummary, type RebarRow } from '../src/core/rebar/summary'; +import { loadP1 } from './helpers'; + +const rows = rebarSummary(buildPier(loadP1())).rows; +const row = (name: string): RebarRow => rows.find((r) => r.name === name)!; +const total = (r: RebarRow) => r.pieces!.reduce((s, p) => s + p.count * p.length, 0); +const bars = (r: RebarRow) => r.pieces!.reduce((s, p) => s + p.count, 0); +const hasLength = (r: RebarRow, mm: number, tol = 3) => r.pieces!.some((p) => Math.abs(p.length - mm) < tol); + +describe('조각 길이 (승인 2026-10-23): 여러 길이를 가진 행', () => { + it('C9 and C11 pieces: U (3522 first, 6098 last) and 삿갓, 3 loops each; 108 bars per row', () => { + for (const name of ['C9', 'C11']) { + expect(row(name).pieces, name).toBeDefined(); + expect(bars(row(name)), name).toBe(108); + expect(hasLength(row(name), 3522), name).toBe(true); + expect(hasLength(row(name), 6098), name).toBe(true); + } + }); + + it('C10 pieces: outer U 6249 and Level U 6340; 54 bars', () => { + expect(bars(row('C10'))).toBe(54); + expect(hasLength(row('C10'), 6249)).toBe(true); + expect(hasLength(row('C10'), 6340)).toBe(true); + }); + + it('C12 and C13 pieces: the bars per row equal the row count (4 per position)', () => { + expect(bars(row('C12'))).toBe(row('C12').count); + expect(bars(row('C13'))).toBe(row('C13').count); + expect(total(row('C12'))).toBeGreaterThan(0); + }); + + it('P1 and P2 pieces: each splice piece counts all the bars (230)', () => { + expect(row('P1').pieces!.every((p) => p.count === 230)).toBe(true); + expect(row('P2').pieces!.every((p) => p.count === 230)).toBe(true); + expect(row('P1').pieces!.length).toBeGreaterThan(1); + }); + + it('the 재료표 no longer leaves out any P1 bar: no missing bars', () => { + const q = buildQuantity(rows, DEFAULT_MATERIAL); + expect(q.missing).toEqual([]); + expect(q.groups.flatMap((g) => g.diameters.flatMap((d) => d.lines.map((l) => l.name)))).toEqual(expect.arrayContaining(['C9', 'C10', 'C11', 'C12', 'P1', 'P2'])); + }); +}); diff --git a/tests/rebar-quantity.test.ts b/tests/rebar-quantity.test.ts index 1687a8d..d4b8b5e 100644 --- a/tests/rebar-quantity.test.ts +++ b/tests/rebar-quantity.test.ts @@ -22,11 +22,10 @@ describe('철근재료표 (승인 2026-10-23)', () => { } }); - it('bars with no length yet are listed as missing and kept out of the totals', () => { + it('a row with several lengths is totalled from its pieces, not left out', () => { 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'); + expect(q.missing).toEqual([]); + expect(q.groups.flatMap((g) => g.diameters.flatMap((d) => d.lines.map((l) => l.name)))).toContain('C9'); }); it('couplers are counted, not weighed', () => { diff --git a/tests/rebar-summary.test.ts b/tests/rebar-summary.test.ts index 7fa1d74..dce33b3 100644 --- a/tests/rebar-summary.test.ts +++ b/tests/rebar-summary.test.ts @@ -53,7 +53,8 @@ describe('③ 철근표 요약 (P1)', () => { }); it('lengths from the documents: C13 11746, C14 3447.5, C15 3852, C16 4070, P3 ≈ C_t + 1.3·l_d, P5 ≈ C_t', () => { - expect(rows.find((r) => r.name === 'C13')!.length).toBe(11746); + // C13 has several lengths now: its straight bars are pieces (수정 2026-10-23); 11746 is the first 일자. + expect(rows.find((r) => r.name === 'C13')!.pieces!.some((p) => p.length === 11746)).toBe(true); expect(rows.find((r) => r.name === 'C14')!.length).toBe(3447.5); expect(rows.find((r) => r.name === 'C15')!.length).toBe(3852); expect(rows.find((r) => r.name === 'C16')!.length).toBe(4070);