diff --git a/src/core/rebar/coping.ts b/src/core/rebar/coping.ts index 44568bb..844a126 100644 --- a/src/core/rebar/coping.ts +++ b/src/core/rebar/coping.ts @@ -131,3 +131,44 @@ export function shearSegments(coping: number): { segment: string; from: number; seg('우 경사', b.c11, 'C11', COPING_REBAR.spacingS), ]; } + +/** + * Shear bars for a multi-column coping (코핑철근.md §9, 다주형 확장 원칙). + * C10: each column's centre, @300 out to the first position beyond its Level edge. + * C9 / C11: outward from the outermost C10 on each end, as for one column. + * Between columns: @250 in from both C10 groups, the remaining length shared evenly (추천안, Q-K2). + * With one column this is the same as shearBars. + */ +export function shearBarsMulti(coping: number, columns: number[]): ShearBars & { between: number[] } { + if (columns.length <= 1) { + const one = shearBars(coping, columns[0] ?? coping / 2); + return { ...one, between: [] }; + } + const { spacingL, spacingS, levelWidth } = COPING_REBAR; + const kMax = Math.floor(levelWidth / 2 / spacingL) + 1; + const groups = columns.map((xc) => + Array.from({ length: 2 * kMax + 1 }, (_, i) => Math.round(xc + (i - kMax) * spacingL)), + ); + for (let i = 1; i < groups.length; i++) { + if (groups[i][0] <= groups[i - 1][groups[i - 1].length - 1]) { + throw new Error('기둥 간격이 좁아 C10 구간이 겹칩니다. 다주형 코핑 철근을 만들 수 없습니다.'); + } + } + const c10 = groups.flat(); + const left = groups[0][0]; + const right = groups[groups.length - 1][groups[groups.length - 1].length - 1]; + const n9 = Math.floor((left - COPING_END_GAP) / spacingS); + if (n9 < 0) throw new Error('RB-K01: C10 바깥 구간이 끝 여유보다 짧아 C9를 놓을 수 없습니다.'); + const c9 = Array.from({ length: n9 }, (_, j) => Math.round(left - (n9 - j) * spacingS)); + const c11 = Array.from({ length: n9 }, (_, j) => Math.round(right + (j + 1) * spacingS)); + const between: number[] = []; + for (let i = 1; i < groups.length; i++) { + const a = groups[i - 1][groups[i - 1].length - 1]; + const b = groups[i][0]; + const gap = b - a; + const pitches = Math.floor(gap / spacingS); + for (let j = 1; j < pitches; j++) between.push(Math.round(a + (gap * j) / pitches)); + } + const endGap = Math.min(left - c9[0], coping - c11[c11.length - 1]); + return { c10, c9, c11, between, endGap }; +} diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index 85a8f75..fc47ad1 100644 --- a/src/core/rebar/summary.ts +++ b/src/core/rebar/summary.ts @@ -1,7 +1,7 @@ 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, shearULength, SIDE_LENGTHS, STEP_LENGTHS, capLength, PIECES_PER_POSITION } from './coping'; +import { shearBars, shearBarsMulti, sideRows, C14_COUNTS, c14Total, STEP_BAR_COUNTS, shearULength, SIDE_LENGTHS, STEP_LENGTHS, capLength, PIECES_PER_POSITION } from './coping'; import { columnRebar, COLUMN_P1 } from './column'; import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing'; import { DEFAULT_MATERIAL, isAssumedMaterial, Material, splitPieces } from './common'; @@ -74,6 +74,7 @@ const META: Record; export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): { rows: RebarRow[]; warnings: RebarWarning[] } { @@ -114,7 +135,9 @@ export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): const bottom = bottomPiece(m); 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); + const colS = model.columns.map((c) => c.s * 1000); + const nCol = Math.max(1, colS.length); + const shear = nCol > 1 ? shearBarsMulti(L, colS) : { ...shearBars(L), between: [] as number[] }; const uRange = (xs: number[]) => { const u = xs.map((x) => Math.round(shearULength(x, L))); return `${Math.min(...u)}–${Math.max(...u)}`; @@ -128,6 +151,12 @@ export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): 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) }); + 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}` }); + } + if (nCol > 1) { + warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 T형 기준으로 두었습니다 (기둥마다 반복 규칙 미정, Q-K11).`, target: 'coping' }); + } const side = sideRows(41.288, 42.538); rows.push({ member: 'coping', name: 'C12', size: 'H25', count: side.c12.length, length: null, note: `측면, 경사 하면에서 끊김, 일자 ${SIDE_LENGTHS.C12[0]}–${SIDE_LENGTHS.C12[4]}` }); rows.push({ member: 'coping', name: 'C13', size: 'H25', count: side.c13.length, length: SIDE_LENGTHS.C13, note: '측면, 끝면까지' }); @@ -145,28 +174,33 @@ export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): 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' }); + // Column bars and couplers repeat for each column (기둥철근 is per column; 다주형은 기둥마다). + for (const r of rows) if (r.member === 'column') r.count *= nCol; // 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; - // Bars longer than the standard length are split in two (추천안, Q-B6). The count is still the bar count. - const addBar = (b: { name: string; size: string; count: number; length: number }, note: string) => { + if (model.footings.length > 0) { + // Each footing (분리 or 일체) gives the same bar set. Several footings add their counts per bar name; + // the length is the longest of them (the largest footing governs the bar). + const agg = new Map(); + for (const f of model.footings) { + for (const b of footingBarList(f.length * 1000, f.width * 1000, (f.top - f.bottom) * 1000)) { + const cur = agg.get(b.name); + if (cur) { + cur.count += b.count; + cur.length = Math.max(cur.length, b.length); + } else { + agg.set(b.name, { ...b }); + } + } + } + for (const b of agg.values()) { const split = splitPieces(b.length, 25.4, m.fyS, m.fck, STANDARD_BAR); - if (split) { - rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: split.pieceLength, note: `${note}, 2조각 이음 (추천안, 겹침 ${Math.round(split.lap)})` }); + if (split && b.length > STANDARD_BAR && b.name !== 'B9') { + rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: split.pieceLength, note: `${b.note}, 2조각 이음 (추천안, 겹침 ${Math.round(split.lap)})` }); warnings.push({ code: 'RB-F08', message: `${b.name} 길이 ${Math.round(b.length)} mm > 정척 ${STANDARD_BAR} mm: 2조각 이음을 추천안으로 적용 (Q-B6).`, target: 'footing', bar: b.name }); } else { - rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: b.length, note }); + rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: b.length, note: b.note }); } - }; - for (const b of bottomBars(Lf, Wf, Hf)) addBar(b, '교각축'); - for (const b of crossBars(Lf, Wf, Hf)) addBar(b, '교각축 직각'); - 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: '조립 수평' }); - const b9 = shearB9(Lf, Wf, Hf); - rows.push({ member: 'footing', name: 'B9', size: 'H16', count: b9.count, length: b9.length, note: `전단 @500 엇갈림, ${b9.rows}행 (위치·갈고리는 Q-B18·B19 제안)` }); + } } else { warnings.push({ code: 'RB-F00', message: '현장타설말뚝은 확대기초가 없어 확대기초 철근을 만들지 않습니다.', target: 'footing' }); } diff --git a/tests/rebar-multi.test.ts b/tests/rebar-multi.test.ts new file mode 100644 index 0000000..3769e91 --- /dev/null +++ b/tests/rebar-multi.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { shearBarsMulti } from '../src/core/rebar/coping'; +import { rebarSummary } from '../src/core/rebar/summary'; +import { loadP1 } from './helpers'; + +// 2주형 and 3주형 on the P1 sheet with a 12 m coping (columns at L/5, 4L/5 and L/5, L/2, 4L/5). +const two = buildPier({ ...loadP1(), type: '2주형', columnCount: 2, copingLengthMm: 12000 }); +const three = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLengthMm: 12000 }); +const count = (rows: ReturnType['rows'], name: string) => rows.find((r) => r.name === name)!.count; + +describe('다주형 철근 (2주형·3주형, 코핑철근.md §9)', () => { + it('2주형: C10 2 × 9 = 18; C9 and C11 3 each outside; 18 between (추천안, Q-K2)', () => { + const rows = rebarSummary(two).rows; + expect(count(rows, 'C10')).toBe(18); + expect(count(rows, 'C9')).toBe(3); + expect(count(rows, 'C11')).toBe(3); + expect(count(rows, 'C9·C11 사이')).toBe(18); + }); + + it('3주형: C10 3 × 9 = 27; two gaps of 3 between', () => { + const rows = rebarSummary(three).rows; + expect(count(rows, 'C10')).toBe(27); + expect(count(rows, 'C9·C11 사이')).toBe(6); + }); + + it('column bars repeat per column: P1 230 × 2 and 230 × 3', () => { + expect(count(rebarSummary(two).rows, 'P1')).toBe(460); + expect(count(rebarSummary(three).rows, 'P1')).toBe(690); + }); + + it('a multi-column pier gets the RB-M01 note about C14', () => { + expect(rebarSummary(two).warnings.map((w) => w.code)).toContain('RB-M01'); + }); + + it('the single column (T형) has no between row and no RB-M01', () => { + const t = rebarSummary(buildPier(loadP1())); + expect(t.rows.find((r) => r.name === 'C9·C11 사이')).toBeUndefined(); + expect(t.warnings.map((w) => w.code)).not.toContain('RB-M01'); + }); + + it('shear bars for a columns too close to share a Level are refused', () => { + expect(() => shearBarsMulti(12000, [3000, 3500])).toThrow('겹칩니다'); + }); +});