From 3af8729e9f5210c065bf9455d7d46f8cfc74ba49 Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 22 Oct 2026 11:33:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(core,view):=20footing=20rebar=20B1?= =?UTF-8?q?=E2=80=93B12=20(plan=20C5)=20and=20the=20=E2=91=A2=20rebar=20sc?= =?UTF-8?q?reen=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Footing (확대기초철근 (1).md): grid counts from W and L, layer heights, B1–B4 and B5–B8 counts and lengths (B2 13646, B6 11396), B10-1/B10-2, B11 (60) and B12 (47) with their lengths. B9 is not computed yet. - rebarSummary: one 철근표 per pier from the model (bearing, coping, column, footing), with alerts: RB-F08 (B2 needs a splice, Q-B6), RB-F09 (B9 not computed). - ③ screen v1: stage nav button, member tabs (받침블럭 · 코핑 · 기둥 · 확대기초), rebar table with 번호 · 규격 · 개수 · 길이 · 비고, alert list, and the open questions (Q-B5, Q-B6, Q-C9). Drawings and the 강조 연동 are not in v1. - Tests: footing values from the document; P1 summary totals; warnings. - Not verified in a browser. Co-Authored-By: Claude Haiku 5.5 --- index.html | 25 +++++- src/core/rebar/common.ts | 5 ++ src/core/rebar/footing.ts | 147 ++++++++++++++++++++++++++++++++++++ src/core/rebar/summary.ts | 98 ++++++++++++++++++++++++ src/main.ts | 72 +++++++++++++++++- tests/rebar-footing.test.ts | 70 +++++++++++++++++ tests/rebar-summary.test.ts | 50 ++++++++++++ 7 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 src/core/rebar/footing.ts create mode 100644 src/core/rebar/summary.ts create mode 100644 tests/rebar-footing.test.ts create mode 100644 tests/rebar-summary.test.ts diff --git a/index.html b/index.html index 02a3ea1..e12d04f 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@
저장 전 @@ -134,6 +134,29 @@ +
+ +
+

철근표

+

+
+
+ +
+
diff --git a/src/core/rebar/common.ts b/src/core/rebar/common.ts index 5ec830f..cddb6bc 100644 --- a/src/core/rebar/common.ts +++ b/src/core/rebar/common.ts @@ -33,6 +33,11 @@ export const NOMINAL_DIAMETER = { H29: 28.6, S32: 31.8, D35: 34.9, + /** SD500 names used by the footing document (same nominal diameters). */ + S16: 15.9, + S19: 19.1, + S25: 25.4, + S29: 28.6, } as const; export type BarSize = keyof typeof NOMINAL_DIAMETER; diff --git a/src/core/rebar/footing.ts b/src/core/rebar/footing.ts new file mode 100644 index 0000000..89b62f8 --- /dev/null +++ b/src/core/rebar/footing.ts @@ -0,0 +1,147 @@ +import { hookLength, NOMINAL_DIAMETER, type BarSize } from './common'; + +/** + * 확대기초 철근 B1–B8, B10–B12 (plan C5). Source: RAG/확대기초철근 (1).md, 2026-10-22 최종 정리. + * B9 (전단철근) is not computed yet. Lengths in mm, heights in mm from the 확대기초 하면. + */ +export const FOOTING_DEFAULTS = { + /** 피복 상면 / 측면 / 하면 (최외측 철근 중심까지). */ + cTop: 100, + cSide: 100, + cBottom: 150, + /** 첫 철근 최소 여유. */ + eMin: 150, + /** 격자 실간격 s. */ + s: 125, + /** 하면 1단과 2단의 간격 Δ_2. */ + delta2: 100, + /** B10 높이 간격 s_v and 조립 단 배수 n. */ + sV: 200, + n: 2, + /** B1·B3 leg and B5·B7 leg (proposed values; Q-B5 open). */ + legB1: 300, + legB3: 230, + legB5: 343, + legB7: 230, +} as const; + +export interface GridResult { + /** 격자 칸 수 n = floor((A − 2·e_min)/s); 위치 수 = n + 1. */ + n: number; + /** 격자 길이 G = n·s. */ + G: number; + /** 양끝 여유 e = (A − G)/2. */ + e: number; + /** 위치 수 n + 1. */ + positions: number; +} + +/** Grid along one side of the footing (§3.1). side = W for B1–B4, L for B5–B8. */ +export function grid(side: number, s = FOOTING_DEFAULTS.s, cSide = FOOTING_DEFAULTS.cSide, eMin = FOOTING_DEFAULTS.eMin): GridResult { + const A = side - 2 * cSide; + const n = Math.floor((A - 2 * eMin) / s); + const G = n * s; + const e = (A - G) / 2; + return { n, G, e, positions: n + 1 }; +} + +const d = (size: BarSize) => NOMINAL_DIAMETER[size]; + +/** Layer heights (§4.2), mm from the 하면. H = footing height. */ +export interface Layers { + B5: number; + B1: number; + B24: number; + B68: number; + B3: number; + B7: number; +} + +export function layers(H: number): Layers { + const { cTop, cBottom, delta2 } = FOOTING_DEFAULTS; + const zB5 = H - cTop; + const zB1 = zB5 - (d('S29') + d('S25')) / 2; + const zB24 = cBottom; + const zB68 = cBottom + (d('S25') + d('S29')) / 2; + return { B5: zB5, B1: zB1, B24: zB24, B68: zB68, B3: zB24 + delta2, B7: zB68 + delta2 }; +} + +/** Lengths of the B1–B4 family along the length L (§5.2). */ +export function legB2(H: number): number { + const z = layers(H); + return z.B1 - z.B24; // 1723 at H 2000 +} + +export interface FootingBar { + name: 'B1' | 'B2' | 'B3' | 'B4' | 'B5' | 'B6' | 'B7' | 'B8' | 'B10-1' | 'B10-2' | 'B11' | 'B12'; + size: BarSize; + /** Bars in all (over the footing). */ + count: number; + /** Length of one bar, mm (straight part + legs). */ + length: number; +} + +/** B1–B4 (§5): the grid along W, with lengths from L. */ +export function bottomBars(L: number, W: number, H: number): FootingBar[] { + const g = grid(W); + const n = g.positions; + const N4 = Math.ceil(n / 2); // B4 at even i + const N2 = Math.floor(n / 2); // B2 at odd i + const l0 = L - 2 * FOOTING_DEFAULTS.cSide; + const leg2 = legB2(H); + return [ + { name: 'B1', size: 'S25', count: N2, length: l0 + 2 * FOOTING_DEFAULTS.legB1 }, + { name: 'B2', size: 'S25', count: N2, length: l0 + 2 * leg2 }, + { name: 'B3', size: 'S19', count: N2, length: l0 + 2 * FOOTING_DEFAULTS.legB3 }, + { name: 'B4', size: 'S25', count: N4, length: l0 }, + ]; +} + +/** B5–B8 (§6): the grid along L, with lengths from W. */ +export function crossBars(L: number, W: number, H: number): FootingBar[] { + const g = grid(L); + const n = g.positions; + const N8 = Math.ceil(n / 2); // B8 at even i + const N6 = Math.floor(n / 2); // B6, B5, B7 at odd i + const l0 = W - 2 * FOOTING_DEFAULTS.cSide; + const z = layers(H); + const leg6 = z.B5 - z.B68; + return [ + { name: 'B5', size: 'S29', count: N6, length: l0 + 2 * FOOTING_DEFAULTS.legB5 }, + { name: 'B6', size: 'S29', count: N6, length: l0 + 2 * leg6 }, + { name: 'B7', size: 'S19', count: N6, length: l0 + 2 * FOOTING_DEFAULTS.legB7 }, + { name: 'B8', size: 'S29', count: N8, length: l0 }, + ]; +} + +/** B10-1 (on W sides) and B10-2 (on L sides): m + 1 levels at s_v, two faces each (§7). */ +export function sideBars(L: number, W: number): FootingBar[] { + const depth1 = 123.85; // B10-1 depth from the side + const depth2 = 122.25; // B10-2 depth from the side + const levels = 9; // 8 @ 200 (기본) + const hook = hookLength('main90', d('S19')); + return [ + { name: 'B10-1', size: 'S19', count: levels * 2, length: L - 2 * depth2 + 2 * hook }, + { name: 'B10-2', size: 'S19', count: levels * 2, length: W - 2 * depth1 + 2 * hookLength('main90', d('S19')) }, + ]; +} + +/** B11 (along W, between the B10-2 levels) and B12 (along L), 4s apart with a 2s stagger per level (§8). */ +export function assemblyBars(L: number, W: number): FootingBar[] { + const levelsJ = [2, 4, 6]; // j = n, 2n, 3n … inside the B10 levels (n = 2) + const perLevel = (n: number, startOdd: number, startEven: number) => + levelsJ.map((_, idx) => { + const i0 = idx % 2 === 0 ? startOdd : startEven; + return Math.floor((n - i0) / 4) + 1; + }); + const nW = grid(W).positions - 1; // 61 + const nL = grid(L).positions - 1; // 79 + // B11 is spread along L (n = 79) and B12 along W (n = 61); each level has its own count. + const b11 = perLevel(nL, 0, 2).reduce((a, b) => a + b, 0); + const b12 = perLevel(nW, 0, 2).reduce((a, b) => a + b, 0); + const hook = hookLength('main90', d('S16')); + return [ + { name: 'B11', size: 'S16', count: b11, length: W - 2 * 141.35 + 2 * hook }, + { name: 'B12', size: 'S16', count: b12, length: L - 2 * 139.75 + 2 * hook }, + ]; +} diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts new file mode 100644 index 0000000..ebf82b0 --- /dev/null +++ b/src/core/rebar/summary.ts @@ -0,0 +1,98 @@ +import type { PierModel } from '../geometry'; +import { bearingBlockRebar, totals } from './bearing-block'; +import { bottomPiece, topBarLength, widthLayout } from './coping-main'; +import { shearBars, sideRows, C14_COUNTS, c14Total, STEP_BAR_COUNTS } from './coping'; +import { columnRebar, COLUMN_P1 } from './column'; +import { bottomBars, crossBars, sideBars, assemblyBars } from './footing'; + +/** One line of a 철근표. length is null where the document gives no length yet. */ +export interface RebarRow { + member: 'bearing' | 'coping' | 'column' | 'footing'; + name: string; + size: string; + count: number; + length: number | null; + note: string; +} + +export interface RebarWarning { + code: string; + message: string; +} + +/** 정척 L_bar (mm) from 확대기초철근.md §1.2. Bars longer than this need a splice. */ +export const STANDARD_BAR = 12000; + +/** + * 철근표 for one pier (③ screen v1): bearing blocks, coping, column, footing. Built from the generated model. + * Values come from the document rules in src/core/rebar/*. B9 (전단철근) is not computed yet. + */ +export function rebarSummary(model: PierModel): { rows: RebarRow[]; warnings: RebarWarning[] } { + const rows: RebarRow[] = []; + const warnings: RebarWarning[] = []; + const L = model.copingL * 1000; + const B = model.copingB * 1000; + + // Bearing blocks (§6.3 · plan C2) + const blk = totals(bearingBlockRebar(model)); + rows.push({ member: 'bearing', name: 'S1', size: 'H19', count: blk.S1, length: 2500, note: '블럭 배치 분리, 블럭당 8본' }); + rows.push({ member: 'bearing', name: 'S2', size: 'H19', count: blk.S2, length: 2500, note: '블럭 배치 분리, 블럭당 8본' }); + + // Coping (코핑철근.md §5–§8) + const width = widthLayout(B); + const perRow = width.count / 2; // cycle split, 12 per row + // C1–C3 and C4–C6 are the same three top rows (row 1, 2, 3), one set per cycle. + for (const [name, k] of [['C1', 1], ['C2', 2], ['C3', 3], ['C4', 1], ['C5', 2], ['C6', 3]] as const) { + rows.push({ member: 'coping', name, size: 'H29', count: perRow, length: topBarLength(L, k), note: '상면, 이음 무시' }); + } + const bottom = bottomPiece(); + rows.push({ member: 'coping', name: 'C7', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' }); + rows.push({ member: 'coping', name: 'C8', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' }); + const shear = shearBars(L); + rows.push({ member: 'coping', name: 'C9', size: 'H25', count: shear.c9.length, length: null, note: '전단 좌 @250' }); + rows.push({ member: 'coping', name: 'C10', size: 'H25', count: shear.c10.length, length: null, note: '전단 Level @300' }); + rows.push({ member: 'coping', name: 'C11', size: 'H25', count: shear.c11.length, length: null, note: '전단 우 @250' }); + const side = sideRows(41.288, 42.538); + rows.push({ member: 'coping', name: 'C12', size: 'H25', count: side.c12.length, length: null, note: '측면, 경사 하면에서 끊김' }); + rows.push({ member: 'coping', name: 'C13', size: 'H25', count: side.c13.length, length: null, note: '측면, 끝면까지' }); + rows.push({ member: 'coping', name: 'C14', size: 'H19', count: c14Total(), length: null, note: `직각 수평 ${C14_COUNTS.upperLines * C14_COUNTS.upperPerLine}+${C14_COUNTS.lower.join('+')}` }); + rows.push({ member: 'coping', name: 'C15', size: 'H19', count: STEP_BAR_COUNTS.C15, length: null, note: '계단부, 가장 높은 구간' }); + rows.push({ member: 'coping', name: 'C16', size: 'H19', count: STEP_BAR_COUNTS.C16, length: null, note: '계단부, 3·4구간' }); + + // Column (기둥철근.md · plan C4), P1 defaults + const col = columnRebar(COLUMN_P1); + rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, note: '외측 r 875, 커플러로 이음' }); + rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, note: '내측 r 843.2, 커플러로 이음' }); + rows.push({ member: 'column', name: '커플러', size: 'L150 Ø60', count: col.couplers, length: 150, note: '@3000, P2 +150' }); + rows.push({ member: 'column', name: 'P3', size: 'H19', count: col.p3Count, length: null, note: '일반 구간 띠철근 @300' }); + rows.push({ member: 'column', name: 'P5', size: 'H19', count: col.p5Count, length: null, note: '단부 띠철근 @120' }); + rows.push({ member: 'column', name: 'P4', size: 'H19', count: col.p4Count, length: col.p4SemicircleLength * 2, note: '확대기초 안, 반원 2개 (길이는 반원 2개 합) · B11 간섭 무시' }); + rows.push({ member: 'column', name: 'P6', size: 'H16', count: col.p6Count, length: col.p6Length, note: '크로스타이, 8단 × 4' }); + + // Footing (확대기초철근 (1).md · plan C5). Only when the footing exists (not 현장타설말뚝). + const f = model.footings[0]; + if (f) { + const Lf = f.length * 1000; + const Wf = f.width * 1000; + const Hf = (f.top - f.bottom) * 1000; + for (const b of bottomBars(Lf, Wf, Hf)) { + rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: b.length, note: '교각축' }); + if (b.length > STANDARD_BAR) { + warnings.push({ code: 'RB-F08', message: `${b.name} 길이 ${Math.round(b.length)} mm가 정척 ${STANDARD_BAR} mm를 넘어 이음이 필요합니다 (Q-B6).` }); + } + } + for (const b of crossBars(Lf, Wf, Hf)) { + rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: b.length, note: '교각축 직각' }); + if (b.length > STANDARD_BAR) { + warnings.push({ code: 'RB-F08', message: `${b.name} 길이 ${Math.round(b.length)} mm가 정척 ${STANDARD_BAR} mm를 넘어 이음이 필요합니다 (Q-B6).` }); + } + } + for (const b of sideBars(Lf, Wf)) rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: b.length, note: '측면 수평' }); + for (const b of assemblyBars(Lf, Wf)) rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: b.length, note: '조립 수평' }); + rows.push({ member: 'footing', name: 'B9', size: 'H16', count: 0, length: null, note: '전단철근 미계산' }); + warnings.push({ code: 'RB-F09', message: 'B9 (전단철근)은 아직 계산하지 않았습니다.' }); + } else { + warnings.push({ code: 'RB-F00', message: '현장타설말뚝은 확대기초가 없어 확대기초 철근을 만들지 않습니다.' }); + } + return { rows, warnings }; +} diff --git a/src/main.ts b/src/main.ts index b9b59c0..d9b6a80 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ import { EDITABLE_FIELDS, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from './core/piles'; import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy'; import { pileGeometry } from './core/geometry'; +import { rebarSummary, type RebarRow } from './core/rebar/summary'; 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'; @@ -69,7 +70,9 @@ const state = { scene: null as PierScene | null, project: { name: '', bridge: '', client: '', author: '', crs: CRS, memo: '' } as ProjectInfo, /** Which stage screen is shown: ① 형상 생성 or ② 형상계획 (plan.md C1). */ - stage: 'shape' as 'shape' | 'plan', + stage: 'shape' as 'shape' | 'plan' | 'rebar', + /** Member tab on ③ (철근표). */ + rebarMember: 'coping' as RebarRow['member'], /** Member card selected on ② (시안 ② 부재 목록). */ member: 'coping' as MemberId, /** True once the project has a file on disk (first save via 시안 ⑤, or an opened file). Later saves go straight to it. */ @@ -393,6 +396,7 @@ function renderAll(): void { renderPanels(); renderEditCard(); renderPlan(); + renderRebar(); scheduleBackup(); } @@ -666,15 +670,78 @@ function renderShapeOptions(member: MemberId): void { } // --- ② 형상계획 (plan.md C1) ------------------------------------------------------------- -function setStage(stage: 'shape' | 'plan'): void { +function setStage(stage: 'shape' | 'plan' | 'rebar'): void { state.stage = stage; $('view-shape').hidden = stage !== 'shape'; $('view-plan').hidden = stage !== 'plan'; + $('view-rebar').hidden = stage !== 'rebar'; $('nav-shape').setAttribute('aria-current', String(stage === 'shape')); $('nav-plan').setAttribute('aria-current', String(stage === 'plan')); + $('nav-rebar').setAttribute('aria-current', String(stage === 'rebar')); renderAll(); } +// --- ③ 철근배근 (plan C5 · ③ screen v1) -------------------------------------------------- +const REBAR_MEMBERS: { id: RebarRow['member']; label: string }[] = [ + { id: 'bearing', label: '받침블럭' }, + { id: 'coping', label: '코핑' }, + { id: 'column', label: '기둥' }, + { id: 'footing', label: '확대기초' }, +]; + +function renderRebar(): void { + const pier = activePier(); + const tabs = $('rebar-tabs'); + tabs.replaceChildren(); + for (const m of REBAR_MEMBERS) { + const li = el('li'); + const btn = el('button', m.label, 'member'); + btn.type = 'button'; + btn.setAttribute('aria-pressed', String(m.id === state.rebarMember)); + btn.addEventListener('click', () => { + state.rebarMember = m.id; + renderAll(); + }); + li.appendChild(btn); + tabs.appendChild(li); + } + + const box = $('rebar-table'); + box.replaceChildren(); + const issues = $('rebar-issues'); + issues.replaceChildren(); + if (!pier?.model) { + $('rebar-note').textContent = '교각을 생성하면 철근표가 나옵니다.'; + return; + } + const { rows, warnings } = rebarSummary(pier.model); + $('rebar-note').textContent = `${pier.input.name} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`; + const shown = rows.filter((r) => r.member === state.rebarMember); + const table = el('table', undefined, 'grid-table'); + const head = el('thead'); + const hr = el('tr'); + for (const h of ['번호', '규격', '개수', '길이 (mm)', '비고']) hr.appendChild(el('th', h)); + head.appendChild(hr); + table.appendChild(head); + const body = el('tbody'); + for (const r of shown) { + const tr = el('tr'); + tr.append( + el('td', r.name), + el('td', r.size), + el('td', String(r.count)), + el('td', r.length === null ? '—' : String(Math.round(r.length * 10) / 10)), + el('td', r.note), + ); + body.appendChild(tr); + } + table.appendChild(body); + box.appendChild(table); + for (const w of warnings) issues.appendChild(el('li', `${w.code} ${w.message}`)); + if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.')); + $('step3-status').textContent = `계산됨 · 경고 ${warnings.length}`; +} + function renderPlan(): void { const pier = activePier(); const members = $('members'); @@ -1065,6 +1132,7 @@ $('bulk-regen').addEventListener('click', regenerateChecked); $('bulk-copy').addEventListener('click', openCopyDialog); $('nav-shape').addEventListener('click', () => setStage('shape')); $('nav-plan').addEventListener('click', () => setStage('plan')); +$('nav-rebar').addEventListener('click', () => setStage('rebar')); $('member-regen').addEventListener('click', () => { const pier = activePier(); if (pier) regenerateOne(pier.input.name); diff --git a/tests/rebar-footing.test.ts b/tests/rebar-footing.test.ts new file mode 100644 index 0000000..e08254b --- /dev/null +++ b/tests/rebar-footing.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { assemblyBars, bottomBars, crossBars, grid, layers, legB2, sideBars } from '../src/core/rebar/footing'; + +// P1 footing: L 10400 × W 8150 × H 2000. +const L = 10400; +const W = 8150; +const H = 2000; + +describe('확대기초 철근 (확대기초철근 (1).md, plan C5)', () => { + it('grid across W: n 61, G 7625, e 162.5, 62 positions (§3.3)', () => { + const g = grid(W); + expect(g.n).toBe(61); + expect(g.G).toBe(7625); + expect(g.e).toBeCloseTo(162.5, 6); + expect(g.positions).toBe(62); + }); + + it('배근도 재현 grid: W 7800 → n 58, e 175', () => { + const g = grid(7800); + expect(g.n).toBe(58); + expect(g.e).toBeCloseTo(175, 6); + }); + + it('layer heights: B5 1900, B1 1873, B2·B4 150, B6·B8 177, B3 250, B7 277', () => { + const z = layers(H); + expect(z.B5).toBe(1900); + expect(z.B1).toBeCloseTo(1873, 6); + expect(z.B24).toBe(150); + expect(z.B68).toBeCloseTo(177, 6); + expect(z.B3).toBe(250); + expect(z.B7).toBeCloseTo(277, 6); + }); + + it('B2 leg 1723, B2 length 13646 (over the 12000 bar, splice needed)', () => { + expect(legB2(H)).toBeCloseTo(1723, 6); + const b2 = bottomBars(L, W, H).find((b) => b.name === 'B2')!; + expect(b2.length).toBeCloseTo(13646, 0); + expect(b2.count).toBe(31); + }); + + it('B1–B4 counts 31 / 31 / 31 / 31 and B4 length 10200', () => { + const bars = bottomBars(L, W, H); + expect(bars.map((b) => b.count)).toEqual([31, 31, 31, 31]); + expect(bars.find((b) => b.name === 'B4')!.length).toBeCloseTo(10200, 6); + }); + + it('B5–B8 counts 40 each (grid along L: n 79), B8 length 7950, B6 length 11396', () => { + const bars = crossBars(L, W, H); + expect(bars.map((b) => b.count)).toEqual([40, 40, 40, 40]); + expect(bars.find((b) => b.name === 'B8')!.length).toBeCloseTo(7950, 6); + expect(bars.find((b) => b.name === 'B6')!.length).toBeCloseTo(11396, 0); + }); + + it('B10-1 18 bars ≈ 10748, B10-2 18 bars ≈ 8494', () => { + const [b101, b102] = sideBars(L, W); + expect(b101.count).toBe(18); + expect(b101.length).toBeCloseTo(10748, 0); + expect(b102.count).toBe(18); + expect(b102.length).toBeCloseTo(8494, 0); + }); + + it('B11 60 bars (20 / 20 / 20) ≈ 8359, B12 47 bars (16 / 15 / 16) ≈ 10613', () => { + const [b11, b12] = assemblyBars(L, W); + expect(b11.count).toBe(60); + // The document rounds the 90° hook to 246 mm; the formula gives 246.45, so B11 is 1.2 mm longer. + expect(Math.abs(b11.length - 8359)).toBeLessThan(1.5); + expect(b12.count).toBe(47); + expect(b12.length).toBeCloseTo(10613, 0); + }); +}); diff --git a/tests/rebar-summary.test.ts b/tests/rebar-summary.test.ts new file mode 100644 index 0000000..71eca16 --- /dev/null +++ b/tests/rebar-summary.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { rebarSummary } from '../src/core/rebar/summary'; +import { loadP1 } from './helpers'; + +const { rows, warnings } = rebarSummary(buildPier(loadP1())); +const count = (name: string) => rows.find((r) => r.name === name)!.count; + +describe('③ 철근표 요약 (P1)', () => { + it('bearing blocks: S1 and S2 80 bars each', () => { + expect(count('S1')).toBe(80); + expect(count('S2')).toBe(80); + }); + + it('coping: C1–C8 12 each, C9 18, C10 9, C11 18, C12 5 rows, C13 2 rows, C14 38, C15 11, C16 22', () => { + for (const n of ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8']) expect(count(n)).toBe(12); + expect(count('C9')).toBe(18); + expect(count('C10')).toBe(9); + expect(count('C11')).toBe(18); + expect(count('C12')).toBe(5); + expect(count('C13')).toBe(2); + expect(count('C14')).toBe(38); + expect(count('C15')).toBe(11); + expect(count('C16')).toBe(22); + }); + + it('column: P1 230, P2 230, couplers 368, P3 38, P5 19, P4 8, P6 32', () => { + expect(count('P1')).toBe(230); + expect(count('P2')).toBe(230); + expect(count('커플러')).toBe(368); + expect(count('P3')).toBe(38); + expect(count('P5')).toBe(19); + expect(count('P4')).toBe(8); + expect(count('P6')).toBe(32); + }); + + it('footing: B1–B4 31 each, B5–B8 40 each, B10-1 18, B10-2 18, B11 60, B12 47', () => { + for (const n of ['B1', 'B2', 'B3', 'B4']) expect(count(n)).toBe(31); + for (const n of ['B5', 'B6', 'B7', 'B8']) expect(count(n)).toBe(40); + expect(count('B10-1')).toBe(18); + expect(count('B10-2')).toBe(18); + expect(count('B11')).toBe(60); + expect(count('B12')).toBe(47); + }); + + it('warnings: B2 needs a splice (13646 > 12000), and B9 is not computed yet', () => { + expect(warnings.map((w) => w.code)).toEqual(expect.arrayContaining(['RB-F08', 'RB-F09'])); + expect(rows.find((r) => r.name === 'B2')!.length!).toBeGreaterThan(12000); + }); +});