diff --git a/src/core/geometry.ts b/src/core/geometry.ts index f3becea..e5515fc 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -1,5 +1,6 @@ import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis'; import { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block'; +import { type BlockRebarSettings, DEFAULT_BLOCK_REBAR } from './rebar/bearing-block'; import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; import type { PierInput, Row } from './types'; import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; @@ -173,6 +174,8 @@ export interface PierModel { blocks: BlockModel[]; /** 일체 블록 (승인 2026-10-26): empty unless 일체 is chosen and allowed. The per-bearing blocks stay for the solplate and pads. */ integratedBlocks: IntegratedBlock[]; + /** 받침블럭 철근 설정 used by the rebar (승인 2026-10-26). */ + blockRebar: BlockRebarSettings; columns: ColumnModel[]; footings: FootingModel[]; blindings: BlindingModel[]; @@ -460,6 +463,7 @@ export function buildPier(input: PierInput): PierModel { bearings: [...frontPlaced, ...backPlaced].map((b) => ({ no: b.no, row: b.row, k: b.k, s: b.s, g: b.g, n: b.n, z: b.z })), blocks, integratedBlocks: buildIntegratedBlocks(blocks, input.bearingLayout === '일체'), + blockRebar: { ...DEFAULT_BLOCK_REBAR, ...input.blockRebar }, columns, footings, blindings, diff --git a/src/core/rebar/bearing-block.ts b/src/core/rebar/bearing-block.ts index e5f01fd..ab93ada 100644 --- a/src/core/rebar/bearing-block.ts +++ b/src/core/rebar/bearing-block.ts @@ -3,19 +3,42 @@ import { INTEGRATED_BLOCK, type IntegratedBlock } from '../integrated-block'; /** * 받침블럭 철근 (spec §6.3 · plan C2), 배치 분리: one block per bearing, 1200 × 1200. - * S2 is the upper bar (cover 50 from the block top); S1 is the lower bar (69.1 below the top). - * Both have the same layout: 7@125 = 875 with ends of 162.5 in a 1200 mm block, and legs of 700 below the bar. + * The settings come from the input (승인 2026-10-26): S1 and S2 have their own spacing (따로 입력), the side cover c_s + * and the first-bar gap e_s are shared, and the S1 depth is the top cover + half of S1 + half of S2 (피복 + S1/2 + S2/2). + * S2 is the upper bar (depth = top cover); S1 is the lower bar. */ -export const BLOCK_REBAR = { - dim: 1200, - cover: 50, - firstBarGap: 75, - spacing: 125, +export interface BlockRebarSettings { + /** 상면 피복 (철근 중심까지, mm). */ + coverTop: number; + /** 측면 피복 (S1 다리·S2 끝, mm). */ + coverSide: number; + /** 첫 철근 최소 여유 (측면 피복 다음, mm). */ + firstGap: number; + /** 간격 S1 (mm). */ + spacingS1: number; + /** 간격 S2 (mm). */ + spacingS2: number; + /** 다리 길이 (철근 중심에서 아래로, mm). */ + legLength: number; +} + +/** Defaults: the P1 values of 받침블럭철근.md (피복 50 · 여유 75 · @125 · 다리 700). */ +export const DEFAULT_BLOCK_REBAR: BlockRebarSettings = { + coverTop: 50, + coverSide: 50, + firstGap: 75, + spacingS1: 125, + spacingS2: 125, legLength: 700, - /** Depth of each bar centre below the block top (mm). */ - depthS2: 50, - depthS1: 69.1, -} as const; +}; + +/** H19 bar diameter (mm) of both S1 and S2. */ +const H19 = 19.1; + +/** Depth of each bar centre below the block top (mm): S2 = top cover; S1 = top cover + S1/2 + S2/2. */ +export function barDepths(s: BlockRebarSettings): { S1: number; S2: number } { + return { S2: s.coverTop, S1: s.coverTop + H19 / 2 + H19 / 2 }; +} export interface BarRow { name: 'S1' | 'S2'; @@ -26,31 +49,35 @@ export interface BarRow { firstEnd: number; /** Straight length plus two legs (mm). */ length: number; - /** Coping embedment of the legs (mm): 700 − (block height − bar depth). */ + /** Coping embedment of the legs (mm): legLength − (block height − bar depth). */ embedment: number; } -/** Bars along one 1200 mm side: available = dim − 2·(cover + first gap); gaps = floor(available / spacing). */ -export function barsAcross(dim: number = BLOCK_REBAR.dim): { count: number; spacing: number; firstEnd: number } { - const available = dim - 2 * (BLOCK_REBAR.cover + BLOCK_REBAR.firstBarGap); - const gaps = Math.floor(available / BLOCK_REBAR.spacing); - const span = gaps * BLOCK_REBAR.spacing; +/** Bars along one side: available = dim − 2·(cover + first gap); gaps = floor(available / spacing). */ +export function barsAcross( + dim: number = 1200, + spacing: number = DEFAULT_BLOCK_REBAR.spacingS2, + s: BlockRebarSettings = DEFAULT_BLOCK_REBAR, +): { count: number; spacing: number; firstEnd: number } { + const available = dim - 2 * (s.coverSide + s.firstGap); + const gaps = Math.floor(available / spacing); + const span = gaps * spacing; const leftover = available - span; - return { count: gaps + 1, spacing: BLOCK_REBAR.spacing, firstEnd: BLOCK_REBAR.cover + BLOCK_REBAR.firstBarGap + leftover / 2 }; + return { count: gaps + 1, spacing, firstEnd: s.coverSide + s.firstGap + leftover / 2 }; } -/** Length of one bar (mm): the straight part a − 2·c_s plus two legs of 700 (spec §5; 2500 for the 1200 block). */ -export function barLength(dim: number = BLOCK_REBAR.dim): number { - return dim - 2 * BLOCK_REBAR.cover + 2 * BLOCK_REBAR.legLength; +/** Length of one bar (mm): the straight part a − 2·c_s plus two legs (2500 for the 1200 block with the defaults). */ +export function barLength(dim: number = 1200, s: BlockRebarSettings = DEFAULT_BLOCK_REBAR): number { + return dim - 2 * s.coverSide + 2 * s.legLength; } -/** Rebar of one block; blockHeight is the bearing block height in mm (h1 front or h2 back, pad included). */ -export function blockBars(blockHeight: number, dim: number = BLOCK_REBAR.dim): BarRow[] { - const across = barsAcross(dim); - const length = barLength(dim); +/** Rebar of one block; blockHeight is the bearing block height in mm (block top − bottom, pad excluded). */ +export function blockBars(blockHeight: number, dim: number = 1200, s: BlockRebarSettings = DEFAULT_BLOCK_REBAR): BarRow[] { + const depth = barDepths(s); + const length = barLength(dim, s); return [ - { name: 'S1', ...across, length, embedment: BLOCK_REBAR.legLength - (blockHeight - BLOCK_REBAR.depthS1) }, - { name: 'S2', ...across, length, embedment: BLOCK_REBAR.legLength - (blockHeight - BLOCK_REBAR.depthS2) }, + { name: 'S1', ...barsAcross(dim, s.spacingS1, s), length, embedment: s.legLength - (blockHeight - depth.S1) }, + { name: 'S2', ...barsAcross(dim, s.spacingS2, s), length, embedment: s.legLength - (blockHeight - depth.S2) }, ]; } @@ -61,45 +88,46 @@ export interface BlockRebar { } /** - * Rebar of every block of a pier. The block height h_b is the model's own block (coping top → block top, pad excluded), - * since the bar depths are measured from the block top. h1 / h2 include the 50 pad and would count it twice. + * Rebar of one 일체 block (승인 2026-10-26, 받침블럭철근.md "일체 a 2500"): S2 runs along the 교각축 (1200) with the bars + * across the 2500 length (10 on the front half, 9 on the back half); its legs are measured from the top of the half it sits in. + * S1 runs along the 2500 length, across the 1200 depth; its depth is taken from the lower top. */ -export function bearingBlockRebar(model: PierModel): BlockRebar[] { - // 일체 (승인 2026-10-26): one block per pair replaces the two per-bearing blocks. - if (model.integratedBlocks.length > 0) { - return model.integratedBlocks.map((ib) => ({ no: ib.frontNo, row: '전열', bars: integratedBlockBars(ib) })); - } - return model.blocks.map((b) => ({ - no: b.no, - row: b.row, - bars: blockBars((b.top - b.bottom) * 1000), - })); -} - -/** - * Rebar of one 일체 block (승인 2026-10-26, 받침블럭철근.md "일체 a 2500"). - * S2 runs along the 교각축 (1200), so its bars are spaced across the 2500 length: 19 bars at 125. Its legs are measured - * from the top of the half it sits in; the bar at the step line (centre) counts with the front half. - * S1 runs along the 2500 length, 8 bars across the 1200 depth, its depth taken from the lower top (cover on the higher half is larger). - */ -export function integratedBlockBars(ib: IntegratedBlock): BarRow[] { +export function integratedBlockBars(ib: IntegratedBlock, s: BlockRebarSettings = DEFAULT_BLOCK_REBAR): BarRow[] { const half = INTEGRATED_BLOCK.length / 2; - const s2 = barsAcross(INTEGRATED_BLOCK.length); + const depth = barDepths(s); + const s2 = barsAcross(INTEGRATED_BLOCK.length, s.spacingS2, s); const frontCount = Array.from({ length: s2.count }, (_, k) => s2.firstEnd + k * s2.spacing).filter((x) => x <= half + 1e-9).length; const backCount = s2.count - frontCount; const hFront = (ib.topFront - ib.bottom) * 1000; const hBack = (ib.topBack - ib.bottom) * 1000; - const legS2 = (h: number) => BLOCK_REBAR.legLength - (h - BLOCK_REBAR.depthS2); - const s1 = barsAcross(INTEGRATED_BLOCK.depth); + const legS2 = (h: number) => s.legLength - (h - depth.S2); + const s1 = barsAcross(INTEGRATED_BLOCK.depth, s.spacingS1, s); const hLow = Math.min(hFront, hBack); const rows: BarRow[] = [ - { name: 'S2', count: frontCount, spacing: s2.spacing, firstEnd: s2.firstEnd, length: barLength(INTEGRATED_BLOCK.depth), embedment: legS2(hFront) }, + { name: 'S2', count: frontCount, spacing: s2.spacing, firstEnd: s2.firstEnd, length: barLength(INTEGRATED_BLOCK.depth, s), embedment: legS2(hFront) }, ]; - if (backCount > 0) rows.push({ name: 'S2', count: backCount, spacing: s2.spacing, firstEnd: s2.firstEnd, length: barLength(INTEGRATED_BLOCK.depth), embedment: legS2(hBack) }); - rows.push({ name: 'S1', count: s1.count, spacing: s1.spacing, firstEnd: s1.firstEnd, length: barLength(INTEGRATED_BLOCK.length), embedment: BLOCK_REBAR.legLength - (hLow - BLOCK_REBAR.depthS1) }); + if (backCount > 0) rows.push({ name: 'S2', count: backCount, spacing: s2.spacing, firstEnd: s2.firstEnd, length: barLength(INTEGRATED_BLOCK.depth, s), embedment: legS2(hBack) }); + rows.push({ name: 'S1', count: s1.count, spacing: s1.spacing, firstEnd: s1.firstEnd, length: barLength(INTEGRATED_BLOCK.length, s), embedment: s.legLength - (hLow - depth.S1) }); return rows; } +/** + * Rebar of every block of a pier. The block height is the model's own block (coping top → block top, pad excluded), + * since the bar depths are measured from the block top. The settings come from the model (input). + */ +export function bearingBlockRebar(model: PierModel): BlockRebar[] { + const s = model.blockRebar; + // 일체 (승인 2026-10-26): one block per pair replaces the two per-bearing blocks. + if (model.integratedBlocks.length > 0) { + return model.integratedBlocks.map((ib) => ({ no: ib.frontNo, row: '전열', bars: integratedBlockBars(ib, s) })); + } + return model.blocks.map((b) => ({ + no: b.no, + row: b.row, + bars: blockBars((b.top - b.bottom) * 1000, 1200, s), + })); +} + /** Total bars of one pier: bars per block × blocks, for S1 and S2 separately. */ export function totals(rebar: BlockRebar[]): { S1: number; S2: number } { return rebar.reduce( diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index 3796924..a9ee2dc 100644 --- a/src/core/rebar/summary.ts +++ b/src/core/rebar/summary.ts @@ -148,8 +148,8 @@ export function rebarSummary( if (shortest < anchorage) { warnings.push({ code: 'RB-S04', message: `받침블럭 철근 묻힘 ${Math.round(shortest)} mm < 정착길이 ${anchorage} mm (코핑 안 묻힘 부족)`, target: 'bearing' }); } - rows.push({ member: 'bearing', name: 'S1', size: 'H19', count: blk.S1, length: barLength(), note: `블럭 배치 분리, 블럭당 8본 · 묻힘 ${range(emb1)} (블럭 상면 − 69.1)` }); - rows.push({ member: 'bearing', name: 'S2', size: 'H19', count: blk.S2, length: barLength(), note: `블럭 배치 분리, 블럭당 8본 · 묻힘 ${range(emb2)} (블럭 상면 − 50)` }); + rows.push({ member: 'bearing', name: 'S1', size: 'H19', count: blk.S1, length: barLength(1200, model.blockRebar), note: `블럭 배치 분리, 블럭당 8본 · 묻힘 ${range(emb1)} (블럭 상면 − 69.1)` }); + rows.push({ member: 'bearing', name: 'S2', size: 'H19', count: blk.S2, length: barLength(1200, model.blockRebar), note: `블럭 배치 분리, 블럭당 8본 · 묻힘 ${range(emb2)} (블럭 상면 − 50)` }); // Coping (코핑철근.md §5–§8) const width = widthLayout(B); diff --git a/src/core/types.ts b/src/core/types.ts index ad92d52..fe74b7f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -70,6 +70,8 @@ export interface PierInput { lowWaterEl?: number; /** 받침 종류·용량 as text (승인 2026-10-26). Absent = 탄성받침 (the default). */ bearingType?: string; + /** 받침블럭 철근 설정 (승인 2026-10-26): 피복, 여유, 간격 S1·S2 (따로), 다리. Absent = the P1 defaults. */ + blockRebar?: Partial<{ coverTop: number; coverSide: number; firstGap: number; spacingS1: number; spacingS2: number; legLength: number }>; /** 받침 배치 (승인 2026-10-26): 분리 (default) or 일체. 일체 is allowed only when every front–back pair is within 100 mm. */ bearingLayout?: '분리' | '일체'; strataRows?: StrataRow[]; diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index b81b1af..a992eba 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -5,7 +5,7 @@ import { bottomPiece, MAIN, widthLayout } from '../core/rebar/coping-main'; import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping'; import { sideRowLevels } from '../core/coping-geom'; import { columnRebar, columnInputFor, COLUMN_P1 } from '../core/rebar/column'; -import { barsAcross, BLOCK_REBAR } from '../core/rebar/bearing-block'; +import { barDepths, barsAcross } from '../core/rebar/bearing-block'; import { blockFramePoint } from '../core/geometry'; import { INTEGRATED_BLOCK } from '../core/integrated-block'; import { bendRadius, DEFAULT_MATERIAL, FckByMember, hookLength, Material, materialFor, NOMINAL_DIAMETER } from '../core/rebar/common'; @@ -466,34 +466,42 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, }); } - // Bearing blocks: S2 (upper, along s) and S1 (lower, along n), 8 bars each across the 1200 block. - // Positions from barsAcross (162.5 from the block edge, @125), bars a − 2·c_s long, legs 700 down at both ends (§3–5). - const across = barsAcross(); - const half = (BLOCK_REBAR.dim - 2 * BLOCK_REBAR.cover) / 2000; - const legM = BLOCK_REBAR.legLength / 1000; - const offsets = Array.from({ length: across.count }, (_, i) => (across.firstEnd + i * across.spacing) / 1000 - BLOCK_REBAR.dim / 2000); + // Bearing blocks: S2 (upper, along s) and S1 (lower, along n). Each set has its own spacing (승인 2026-10-26). + // Positions from barsAcross (the first bar at the side cover + first gap), legs of legLength down at both ends (§3–5). + const br = m.blockRebar; + const depth = barDepths(br); + const legM = br.legLength / 1000; + const half = (1200 - 2 * br.coverSide) / 2000; + const offsetsOf = (spacing: number) => { + const a = barsAcross(1200, spacing, br); + return Array.from({ length: a.count }, (_, i) => (a.firstEnd + i * a.spacing) / 1000 - 0.6); + }; + const offsetsS2 = offsetsOf(br.spacingS2); + const offsetsS1 = offsetsOf(br.spacingS1); const dBar = NOMINAL_DIAMETER.H19; // The bars are drawn in the block's own frame (a along its s side, c along its n side) and turned with the block. for (const b of m.blocks) { if (m.integratedBlocks.length > 0) break; // 일체: drawn per pair below - const z2 = b.top - BLOCK_REBAR.depthS2 / 1000; - const z1 = b.top - BLOCK_REBAR.depthS1 / 1000; + const z2 = b.top - depth.S2 / 1000; + const z1 = b.top - depth.S1 / 1000; const P = (a: number, c: number, y: number): [number, number, number] => { const [s, n] = blockFramePoint(b, a, c); return [s, y, n]; }; - for (const off of offsets) { + for (const off of offsetsS2) { addBar('S2', b.k, P(-half, off, z2), P(half, off, z2), dBar, COLOR.block); addBar('S2', b.k, P(-half, off, z2 - legM), P(-half, off, z2), dBar, COLOR.block); addBar('S2', b.k, P(half, off, z2), P(half, off, z2 - legM), dBar, COLOR.block); + } + for (const off of offsetsS1) { addBar('S1', b.k, P(off, -half, z1), P(off, half, z1), dBar, COLOR.block); addBar('S1', b.k, P(off, -half, z1 - legM), P(off, -half, z1), dBar, COLOR.block); addBar('S1', b.k, P(off, half, z1), P(off, half, z1 - legM), dBar, COLOR.block); } } - // 일체 block (승인 2026-10-26): S2 runs along the 교각축 (1200) with 19 bars across the 2500 (교축), and S1 runs - // along the 2500 with 8 bars across the 1200. Each S2 bar sits under its own half's top (front: offset ≤ 0). + // 일체 block (승인 2026-10-26): S2 runs along the 교각축 (1200) with the bars across the 2500 (교축); S1 runs along + // the 2500 with the bars across the 1200. Each S2 bar sits under its own half's top (front: offset ≤ 0). for (const ib of m.integratedBlocks) { const k = m.blocks.find((b) => b.no === ib.frontNo && b.row === '전열')!.k; const frame = { s: ib.s, n: ib.n, rowSkewDeg: ib.rowSkewDeg }; @@ -501,13 +509,13 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, const [s, n] = blockFramePoint(frame, a, c); return [s, y, n]; }; - const acrossN = barsAcross(INTEGRATED_BLOCK.length); - const acrossS = barsAcross(INTEGRATED_BLOCK.depth); - const halfS = (INTEGRATED_BLOCK.depth - 2 * BLOCK_REBAR.cover) / 2000; - const halfN = (INTEGRATED_BLOCK.length - 2 * BLOCK_REBAR.cover) / 2000; - const z1 = Math.min(ib.topFront, ib.topBack) - BLOCK_REBAR.depthS1 / 1000; + const acrossN = barsAcross(INTEGRATED_BLOCK.length, br.spacingS2, br); + const acrossS = barsAcross(INTEGRATED_BLOCK.depth, br.spacingS1, br); + const halfS = (INTEGRATED_BLOCK.depth - 2 * br.coverSide) / 2000; + const halfN = (INTEGRATED_BLOCK.length - 2 * br.coverSide) / 2000; + const z1 = Math.min(ib.topFront, ib.topBack) - depth.S1 / 1000; for (const off of Array.from({ length: acrossN.count }, (_, i) => (acrossN.firstEnd + i * acrossN.spacing) / 1000 - INTEGRATED_BLOCK.length / 2000)) { - const z2 = (off <= 1e-9 ? ib.topFront : ib.topBack) - BLOCK_REBAR.depthS2 / 1000; + const z2 = (off <= 1e-9 ? ib.topFront : ib.topBack) - depth.S2 / 1000; addBar('S2', k, P(-halfS, off, z2), P(halfS, off, z2), dBar, COLOR.block); addBar('S2', k, P(-halfS, off, z2 - legM), P(-halfS, off, z2), dBar, COLOR.block); addBar('S2', k, P(halfS, off, z2), P(halfS, off, z2 - legM), dBar, COLOR.block); diff --git a/tests/block-rebar-settings.test.ts b/tests/block-rebar-settings.test.ts new file mode 100644 index 0000000..1eea982 --- /dev/null +++ b/tests/block-rebar-settings.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { barDepths, bearingBlockRebar, DEFAULT_BLOCK_REBAR, totals } from '../src/core/rebar/bearing-block'; +import { loadP1 } from './helpers'; + +describe('받침블럭 철근 설정 (승인 2026-10-26)', () => { + it('the defaults give the P1 depths: S2 50, S1 69.1 (= 피복 + S1/2 + S2/2 with H19)', () => { + expect(barDepths(DEFAULT_BLOCK_REBAR)).toEqual({ S2: 50, S1: 50 + 19.1 }); + }); + + it('S1 depth follows the top cover: 피복 60 → S1 79.1, S2 60', () => { + expect(barDepths({ ...DEFAULT_BLOCK_REBAR, coverTop: 60 })).toEqual({ S2: 60, S1: 60 + 19.1 }); + }); + + it('separate spacings: S1 @100 across 1200 gives 10 bars; S2 stays @125 (8 bars)', () => { + const m = buildPier({ ...loadP1(), blockRebar: { spacingS1: 100 } }); + const t = totals(bearingBlockRebar(m)); + expect(t.S1).toBe(10 * 10); + expect(t.S2).toBe(8 * 10); + }); + + it('P1 with the defaults keeps S1 80 and S2 80', () => { + expect(totals(bearingBlockRebar(buildPier(loadP1())))).toEqual({ S1: 80, S2: 80 }); + }); +});