From a2317caf6c371c7b82c89288f30126bd623fcfb5 Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 22 Oct 2026 17:45:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(rebar):=20add=20downward=2090=C2=B0=20hook?= =?UTF-8?q?s=20(135=20mm)=20to=20P6=20crossties=20in=203D=20rebar=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/rebar/bearing-block.ts | 16 +- src/core/rebar/column.ts | 14 +- src/core/rebar/coping.ts | 152 ++++++++++++++++++- src/core/rebar/footing.ts | 119 +++++++++++---- src/core/rebar/summary.ts | 60 +++++--- src/main.ts | 7 +- src/style.css | 4 + src/view/rebar3d.ts | 258 +++++++++++++++++++++++++++----- tests/rebar-bearing.test.ts | 25 +++- tests/rebar-column.test.ts | 16 ++ tests/rebar-coping.test.ts | 13 +- tests/rebar-footing.test.ts | 36 +++-- tests/rebar-stairs.test.ts | 67 +++++++++ tests/rebar-summary.test.ts | 12 +- tests/rebar3d.test.ts | 74 ++++++++- 15 files changed, 740 insertions(+), 133 deletions(-) create mode 100644 tests/rebar-stairs.test.ts diff --git a/src/core/rebar/bearing-block.ts b/src/core/rebar/bearing-block.ts index 790db49..877393c 100644 --- a/src/core/rebar/bearing-block.ts +++ b/src/core/rebar/bearing-block.ts @@ -10,8 +10,6 @@ export const BLOCK_REBAR = { cover: 50, firstBarGap: 75, spacing: 125, - /** Straight part of the bar for the 1200 block (spec table: 1100 + 700 × 2). */ - straight: 1100, legLength: 700, /** Depth of each bar centre below the block top (mm). */ depthS2: 50, @@ -40,10 +38,15 @@ export function barsAcross(dim: number = BLOCK_REBAR.dim): { count: number; spac return { count: gaps + 1, spacing: BLOCK_REBAR.spacing, firstEnd: BLOCK_REBAR.cover + BLOCK_REBAR.firstBarGap + 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; +} + /** 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 = BLOCK_REBAR.straight + 2 * BLOCK_REBAR.legLength; + const length = barLength(dim); 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) }, @@ -56,12 +59,15 @@ export interface BlockRebar { bars: BarRow[]; } -/** Rebar of every block of a pier, using the model's block heights (h1 front, h2 back; pad included, Q16 결정). */ +/** + * 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. + */ export function bearingBlockRebar(model: PierModel): BlockRebar[] { return model.blocks.map((b) => ({ no: b.no, row: b.row, - bars: blockBars(b.row === '전열' ? model.h1 * 1000 : model.h2 * 1000), + bars: blockBars((b.top - b.bottom) * 1000), })); } diff --git a/src/core/rebar/column.ts b/src/core/rebar/column.ts index 912502a..5069ea3 100644 --- a/src/core/rebar/column.ts +++ b/src/core/rebar/column.ts @@ -97,7 +97,15 @@ export function columnRebar(x: ColumnInput = COLUMN_P1, m: Material = DEFAULT_MA const zTop1 = x.Hc + x.hcop - x.eP1; const lD2 = developmentLength(dS32, m.fyS, m.fck); const zTop2 = x.Hc + lD2; + // Bottom of P1 and P2: 확대기초 하면 2단 (B7) 위 + (d_B7 + d_P1)/2 (기둥철근.md §3.2, the same for both). + const zBot = -x.Hf + layers(x.Hf).B7 + (NOMINAL_DIAMETER.S19 + dS32) / 2; const couplerLevels = Array.from({ length: Math.max(0, Math.floor(zTop1 / x.ctcCoupler) - 1) }, (_, i) => (i + 1) * x.ctcCoupler); + // Pieces of each main bar between its couplers (기둥철근.md §3.2): the bottom piece carries the 90° hook (16.5·d = 525). + const p1Hook = Math.round(hookLength('main90', dS32)); + const p1Points = [zBot, ...couplerLevels, zTop1]; + const p1Pieces = p1Points.slice(1).map((z, i) => round(z - p1Points[i] + (i === 0 ? p1Hook : 0))); + const p2Points = [zBot, ...couplerLevels.map((z) => z + x.dzCp2), zTop2]; + const p2Pieces = p2Points.slice(1).map((z, i) => round(z - p2Points[i])); const couplersP1 = couplerLevels.length; const mainLines = x.nDiv; const mainP1 = mainLines * (couplersP1 + 1); @@ -126,7 +134,7 @@ export function columnRebar(x: ColumnInput = COLUMN_P1, m: Material = DEFAULT_MA for (let z = z4first; z <= x.Hc; z += x.ctcP6) p6Levels.push(z); const p6Straight = 2 * tieRadius; const p6Hook = hookLength('tie90', dH16); - const p6Length = p6Straight + 2 * p6Hook; + const p6Length = p6Straight + 2 * Math.round(p6Hook); // 135 mm per hook, as in the document // P3 lap: Q-C6 is open; recommended = 1.3·l_d of H19 with the material inputs. const lapP3 = spliceLength(dH19, m.fyH, m.fck); @@ -142,6 +150,10 @@ export function columnRebar(x: ColumnInput = COLUMN_P1, m: Material = DEFAULT_MA angleStepDeg: 360 / x.nDiv, zTop1: round(zTop1), zTop2: round(zTop2), + zBot: round(zBot, 2), + p1Hook, + p1Pieces, + p2Pieces, couplerP1: couplerLevels, couplerP2: couplerLevels.map((z) => z + x.dzCp2), mainP1, diff --git a/src/core/rebar/coping.ts b/src/core/rebar/coping.ts index d2698de..4dad3aa 100644 --- a/src/core/rebar/coping.ts +++ b/src/core/rebar/coping.ts @@ -2,6 +2,7 @@ * 코핑 철근 (plan C3). Source: RAG/코핑철근.md, 2026-10-22 최종 정리 (spec §6.4 is older and is not used here). * Positions are along the coping length from its start, in mm. */ +import { DEFAULT_MATERIAL, developmentLength, hookLength, Material, NOMINAL_DIAMETER } from './common'; export const COPING_REBAR = { /** 피복 (최외측 철근 중심까지). */ cover: 100, @@ -15,6 +16,8 @@ export const COPING_REBAR = { spacingS: 250, /** C12·C13 측면 수평 간격. */ spacingSide: 250, + /** 테이퍼 높이 h2 (코핑철근.md §2). */ + taperHeight: 1500, /** C14 위 2줄 간격 / 아래 줄 수평 간격 / 아래 줄 연직 간격 / 끝 여유. */ c14: { upperSpacing: 1000, lowerSpacing: 2000, verticalSpacing: 500, endGap: 500 }, } as const; @@ -39,7 +42,7 @@ export interface ShearBars { */ export function shearBars(coping: number, xc = coping / 2): ShearBars { const { spacingL, spacingS, levelWidth } = COPING_REBAR; - const kMax = Math.floor(levelWidth / 2 / spacingL) + 1; + const kMax = Math.ceil(levelWidth / 2 / spacingL); // k_max = ceil((W_L/2)/s_L), spec §3.1 const c10 = Array.from({ length: 2 * kMax + 1 }, (_, i) => Math.round(xc + (i - kMax) * spacingL)); const left = c10[0]; const right = c10[c10.length - 1]; @@ -90,12 +93,153 @@ export function shearULength(x: number, coping: number): number { return SHEAR_U.flat + 2 * shearUHeight(x, coping); } -/** C12 straight lengths per row (코핑철근.md §6 table, mm) and C13. */ -export const SIDE_LENGTHS = { C12: [3717, 5333, 6950, 8567, 10183], C13: 11746 } as const; +/** C13 sits 127 in from each end face (코핑철근.md §6.2). */ +export const SIDE_C13_END = 127; + +/** Leg of each ㄷ형 bar along the side, 1.3·l_d = 1525 (Q-K9). */ +export const SIDE_HOOK_LEG = 1525; + +export interface SideBar { + /** Row height (m, EL). */ + z: number; + /** Distance from each end face to where the 일자 bar starts (mm). */ + xw: number; + /** Length of the 일자 bar, L − 2·x_w (mm). */ + straight: number; +} + +/** + * C12 and C13 rows for a coping of length L (mm), T형 (코핑철근.md §6.2). + * C12 meets the sloped bottom at x_w = c + (z_break − z_k)·(x_L0 − c)/h2, with x_L0 = L/2 − W_L/2. + * C13 starts at x_w = 127. Each row has two 일자 bars (side faces) and two ㄷ bars (one at each end). + */ +export function copingSideBars(L: number, zBottom: number, zBreak: number): { c12: SideBar[]; c13: SideBar[] } { + const c = COPING_REBAR.cover; + const xL0 = L / 2 - COPING_REBAR.levelWidth / 2; + const bar = (z: number, xw: number): SideBar => ({ z, xw, straight: L - 2 * xw }); + const rows = sideRows(zBottom, zBreak); + return { + c12: rows.c12.map((z) => bar(z, c + ((zBreak - z) * 1000 * (xL0 - c)) / COPING_REBAR.taperHeight)), + c13: rows.c13.map((z) => bar(z, SIDE_C13_END)), + }; +} /** C15 (거꾸로 된 U, 3852) and C16 (L형, 4070 / 4069) lengths, mm (코핑철근.md §7). */ export const STEP_LENGTHS = { C15: 3852, C16: [4070, 4069] as const, C14: 3447.5 } as const; +/** + * Stair zone of the P1 coping (코핑철근.md §7.1): each segment from x to x (mm from the coping start), and its top rise + * above the lowest top (mm). The stair positions are the document's; the model has no stair geometry yet. + */ +export const STAIR_SEGMENTS = [ + { from: 2196, to: 4701, rise: 72 }, // 2구간 + { from: 4701, to: 7207, rise: 144 }, // 3구간 + { from: 7207, to: 9712, rise: 215 }, // 4구간 + { from: 9712, to: 12000, rise: 287 }, // 5구간 (to = L in the document) +] as const; + +/** C14 end gap e_14 (코핑철근.md §8.1) and the inset of its ends from the side faces: c + (d_shear + d_14)/2 = 122.25. */ +const C14_END_GAP = 500; +export const C14_INSET = COPING_REBAR.cover + (NOMINAL_DIAMETER.H25 + NOMINAL_DIAMETER.H19) / 2; + +/** Development length l_d of H19 (882 for fy 400, fck 27), rounded to mm as the document does (§7.2). */ +export function stepDevLength(m: Material = DEFAULT_MATERIAL): number { + return Math.round(developmentLength(NOMINAL_DIAMETER.H19, m.fyH, m.fck)); +} + +export interface C14Bar { + /** 1–2: upper lines (C2, C3). 3–6: lower rows from the 7th, 5th, 3rd and 1st side rows. */ + line: number; + /** Position along the coping (mm from its start). */ + x: number; + /** EL of the bar (m). */ + z: number; + /** false when the 연직 발 would end below the coping underside at x, so both legs are left out (§8). */ + legs: boolean; +} + +/** Underside of the coping (EL m) at x (mm from the start), T형: sloped from each end to the Level zone, flat in the middle. */ +export function undersideAt(L: number, x: number, endBottom: number, levelBottom: number): number { + const xL0 = L / 2 - COPING_REBAR.levelWidth / 2; + return endBottom + (levelBottom - endBottom) * Math.min(Math.min(x, L - x) / xL0, 1); +} + +/** + * C14 ⊓ bars across the width (코핑철근.md §8.1). Upper lines hang from C2 and C3 at @1000 from the column centre. + * Lower rows use the side rows 7, 5, 3, 1 from the bottom (k = m − 2(j − 1)): odd j at x_c ± 2000k, even j at x_c ± 1000 ± 2000k, + * only where that side row exists (x_w ≤ x ≤ L − x_w). `sideRows` are the side rows (z m, x_w mm), ascending. + */ +export function c14Bars(L: number, topEl: number, sideRows: { z: number; xw: number }[], under: (x: number) => number): C14Bar[] { + const leg = Math.round(hookLength('main90', NOMINAL_DIAMETER.H19)) / 1000; // 296 mm, EL m + const xc = L / 2; + const lo = COPING_REBAR.cover + C14_END_GAP; + const hi = L - lo; + const out: C14Bar[] = []; + const dUpper = (NOMINAL_DIAMETER.H29 + NOMINAL_DIAMETER.H19) / 2000; // m, over C2 / C3 (H29) + for (const k of [2, 3]) { + const z = topEl - 0.127 - (k - 1) * 0.057 + dUpper; + for (let q = -10; q <= 10; q++) { + const x = xc + q * 1000; + if (x >= lo && x <= hi) out.push({ line: k - 1, x, z, legs: z - leg >= under(x) }); + } + } + const dLower = (NOMINAL_DIAMETER.H25 + NOMINAL_DIAMETER.H19) / 2000; // m, over a H25 side row + for (let j = 1; 7 - 2 * (j - 1) >= 1; j++) { + const row = sideRows[7 - 2 * (j - 1) - 1]; + const a = Math.max(lo, row.xw); + const b = Math.min(hi, L - row.xw); + for (let q = -10; q <= 10; q++) { + const x = xc + (j % 2 === 1 ? 0 : 1000) + 2000 * q; + if (x >= a && x <= b) out.push({ line: 2 + j, x, z: row.z + dLower, legs: row.z + dLower - leg >= under(x) }); + } + } + return out; +} + +/** C14 length (mm): the straight run between the inset ends plus two 15.5·d_b legs (§8.2). 3447.5 in the document. */ +export function c14Length(B: number): number { + return B - 2 * C14_INSET + 2 * Math.round(hookLength('main90', NOMINAL_DIAMETER.H19)); // legs rounded to mm (296) +} + +export interface StepBar { + /** Position across the width, mm from the centre line. */ + n: number; + /** Start and end along the coping (mm). */ + x0: number; + x1: number; + /** EL of the horizontal run (m). */ + z: number; + /** Legs: C15 hangs at both ends, C16 only at its start. */ + legs: 'both' | 'start'; + /** Leg length l_d (mm). */ + leg: number; + /** Developed length (mm): the horizontal run plus the leg(s) at its ends. */ + length: number; +} + +/** + * C15 (거꾸로 된 U, on the highest segment) and C16 (L형, on segments 3 and 4), 11 bars each across the width (코핑철근.md §7). + * Across: 137 from the cover line, then @250 (피복선 기준). Along: from each segment start + c to the next end − c. + */ +export function stairBars(L: number, B: number, topEl: number, m: Material = DEFAULT_MATERIAL): { c15: StepBar[]; c16: StepBar[] } { + const ld = stepDevLength(m); + const c = COPING_REBAR.cover; + const ns = Array.from({ length: 11 }, (_, k) => -B / 2 + c + 137 + 250 * k); + const seg = STAIR_SEGMENTS; + const top = (i: number) => topEl + seg[i].rise / 1000; + const c15Z = top(3) - c / 1000; + const c16Z = (i: number) => top(i) - c / 1000 - NOMINAL_DIAMETER.H19 / 1000; // (d_C15 + d_C16)/2 = 19.1 + const x0c15 = seg[3].from + c; + const x1c15 = L - c; + const c15: StepBar[] = ns.map((n) => ({ n, x0: x0c15, x1: x1c15, z: c15Z, legs: 'both', leg: ld, length: x1c15 - x0c15 + 2 * ld })); + const c16: StepBar[] = [1, 2].flatMap((i) => { + const x0 = seg[i].from + c; + const x1 = seg[i].to - c + ld; // the horizontal run runs on past the segment end by l_d + return ns.map((n): StepBar => ({ n, x0, x1, z: c16Z(i), legs: 'start', leg: ld, length: x1 - x0 + ld })); + }); + return { c15, c16 }; +} + /** * 삿갓 (upper cap) of C9–C11 (코핑철근.md §4.3). l_cap is open (Q-K5); recommended here: l_cap = h − c (top cover 100), * so that l_cap stays below h (RB-K04). Cap length = 1740 + 2·l_cap. @@ -152,7 +296,7 @@ export function shearBarsMulti(coping: number, columns: number[]): ShearBars & { return { ...one, between: [] }; } const { spacingL, spacingS, levelWidth } = COPING_REBAR; - const kMax = Math.floor(levelWidth / 2 / spacingL) + 1; + const kMax = Math.ceil(levelWidth / 2 / spacingL); // k_max = ceil((W_L/2)/s_L), spec §3.1 const groups = columns.map((xc) => Array.from({ length: 2 * kMax + 1 }, (_, i) => Math.round(xc + (i - kMax) * spacingL)), ); diff --git a/src/core/rebar/footing.ts b/src/core/rebar/footing.ts index eb3066e..e102e09 100644 --- a/src/core/rebar/footing.ts +++ b/src/core/rebar/footing.ts @@ -18,11 +18,8 @@ export const FOOTING_DEFAULTS = { /** 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, + /** 상단 최소 여유 e_top (B10 top level, §7.1). */ + eTop: 150, } as const; export interface GridResult { @@ -47,6 +44,9 @@ export function grid(side: number, s: number = FOOTING_DEFAULTS.s, cSide: number const d = (size: BarSize) => NOMINAL_DIAMETER[size]; +/** Leg of B1·B3·B5·B7: 12·d_b on each end (proposed rule, spec §1.2 · Q-B5). */ +export const legLength = (size: BarSize) => 12 * d(size); + /** Layer heights (§4.2), mm from the 하면. H = footing height. */ export interface Layers { B5: number; @@ -90,9 +90,9 @@ export function bottomBars(L: number, W: number, H: number): FootingBar[] { 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: 'B1', size: 'S25', count: N2, length: l0 + 2 * legLength('S25') }, { name: 'B2', size: 'S25', count: N2, length: l0 + 2 * leg2 }, - { name: 'B3', size: 'S19', count: N2, length: l0 + 2 * FOOTING_DEFAULTS.legB3 }, + { name: 'B3', size: 'S19', count: N2, length: l0 + 2 * legLength('S19') }, { name: 'B4', size: 'S25', count: N4, length: l0 }, ]; } @@ -107,28 +107,50 @@ export function crossBars(L: number, W: number, H: number): FootingBar[] { const z = layers(H); const leg6 = z.B5 - z.B68; return [ - { name: 'B5', size: 'S29', count: N6, length: l0 + 2 * FOOTING_DEFAULTS.legB5 }, + { name: 'B5', size: 'S29', count: N6, length: l0 + 2 * legLength('S29') }, { name: 'B6', size: 'S29', count: N6, length: l0 + 2 * leg6 }, - { name: 'B7', size: 'S19', count: N6, length: l0 + 2 * FOOTING_DEFAULTS.legB7 }, + { name: 'B7', size: 'S19', count: N6, length: l0 + 2 * legLength('S19') }, { name: 'B8', size: 'S29', count: N8, length: l0 }, ]; } +/** B10 levels: m = floor((z_B5 − e_top − c_b)/s_v) (§7.1), so the count is m + 1 (9 for P1, H 2000). */ +export function b10Levels(H: number): number { + const m = Math.floor((layers(H).B5 - FOOTING_DEFAULTS.eTop - FOOTING_DEFAULTS.cBottom) / FOOTING_DEFAULTS.sV); + return m + 1; +} + +/** Depth of a side bar from the side face: c_s + (d_a + d_b)/2 (§7, §8.3). B10-1 hangs on B6, B10-2 on B2. */ +export const sideDepth = (dOuter: number, dB10: number) => FOOTING_DEFAULTS.cSide + (dOuter + dB10) / 2; + /** 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 (기본) +export function sideBars(L: number, W: number, H: number): FootingBar[] { + const depth1 = sideDepth(NOMINAL_DIAMETER.S29, NOMINAL_DIAMETER.S19); // B10-1: 123.85 + const depth2 = sideDepth(NOMINAL_DIAMETER.S25, NOMINAL_DIAMETER.S19); // B10-2: 122.25 + const levels = b10Levels(H); 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')) }, + { name: 'B10-2', size: 'S19', count: levels * 2, length: W - 2 * depth1 + 2 * hook }, ]; } +/** Assembly levels j = n, 2n, … strictly inside the B10 levels, 0 < j < m (§8.1). */ +export function assemblyJs(H: number): number[] { + const m = b10Levels(H) - 1; + const js: number[] = []; + for (let j = FOOTING_DEFAULTS.n; j < m; j += FOOTING_DEFAULTS.n) js.push(j); + return js; +} + +/** Ends of B11 / B12 lie (d_B10 + d_assembly)/2 further inside than the B10 depth, so the two bars just touch (§8.3). */ +export function assemblyEndOffset(): number { + return (d('S19') + d('S16')) / 2; // 17.5 +} + /** 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) +export function assemblyBars(L: number, W: number, H: number): FootingBar[] { + const levelsJ = assemblyJs(H); const perLevel = (n: number, startOdd: number, startEven: number) => levelsJ.map((_, idx) => { const i0 = idx % 2 === 0 ? startOdd : startEven; @@ -139,31 +161,63 @@ export function assemblyBars(L: number, W: number): FootingBar[] { // 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')); + const hook = Math.round(hookLength('main90', d('S16'))); // 246 mm, rounded as in the document (§8.3) + const depth1 = sideDepth(NOMINAL_DIAMETER.S29, NOMINAL_DIAMETER.S19); + const depth2 = sideDepth(NOMINAL_DIAMETER.S25, NOMINAL_DIAMETER.S19); 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 }, + { name: 'B11', size: 'S16', count: b11, length: W - 2 * (depth1 + assemblyEndOffset()) + 2 * hook }, + { name: 'B12', size: 'S16', count: b12, length: L - 2 * (depth2 + assemblyEndOffset()) + 2 * hook }, ]; } +/** Plane positions of the grid along one side, mm from the footing centre: x_i = −side/2 + c_s + e + i·s (§3.1). */ +export function gridPositions(side: number): number[] { + const g = grid(side); + return Array.from({ length: g.positions }, (_, i) => -side / 2 + FOOTING_DEFAULTS.cSide + g.e + i * FOOTING_DEFAULTS.s); +} + +/** B9 bar of the rule (§9.1, Q-B18): a vertical bar at (x, n), mm from the footing centre. */ +export interface B9Bar { + x: number; + n: number; +} + /** - * B9 전단철근 (확대기초철근 (1).md §9). Wraps B5 (top outer) and B2·B4 (bottom outer), H16 @500 staggered. - * Plane positions and hooks are proposed rules (Q-B18, Q-B19). + * B9 positions (확대기초철근 (1).md §9.1, rule applied): rows at W index 1, 5, 9 … (step 4 on the grid); in row r the L index + * starts at 1 (even r) or 3 (odd r) and steps by 4. A bar is left out when it falls in a column zone: its centre is closer + * than the column radius plus half a bar to a column axis (Q-B20, applied: the 기둥 구역 is removed). */ -export function shearB9(L: number, W: number, H: number): { rows: number; perRow: number[]; count: number; length: number } { - const nW = grid(W).n; // 61 - const nL = grid(L).n; // 79 +export function b9Bars(L: number, W: number, columnX: number[], columnD: number = 2000): { rows: number; perRow: number[]; bars: B9Bar[] } { + const xs = gridPositions(L); + const ns = gridPositions(W); const rowIndices: number[] = []; - for (let i = 1; i <= nW; i += 4) rowIndices.push(i); - // Even-numbered rows start at i_L = 1, odd-numbered rows at i_L = 3 (2s stagger). - const perRow = rowIndices.map((_, r) => { + for (let i = 1; i < ns.length; i += 4) rowIndices.push(i); + const halfBar = NOMINAL_DIAMETER.H16 / 2; + const bars: B9Bar[] = []; + const perRow = rowIndices.map((iW, r) => { const start = r % 2 === 0 ? 1 : 3; - return Math.floor((nL - start) / 4) + 1; + let kept = 0; + for (let iL = start; iL < xs.length; iL += 4) { + const x = xs[iL]; + const n = ns[iW]; + if (columnX.some((cx) => Math.hypot(x - cx, n) < columnD / 2 + halfBar)) continue; + bars.push({ x, n }); + kept++; + } + return kept; }); - const count = perRow.reduce((a, b) => a + b, 0); + return { rows: rowIndices.length, perRow, bars }; +} + +/** + * B9 전단철근 (확대기초철근 (1).md §9). Vertical bars from the B2·B4 level to the B5 level, H16, with 135° hooks at both ends. + * Length = l_v + 2·l_h (l_v = z_B5 − z_B24). The hook tail (6·d) turns toward the footing centre (Q-B19, applied). + */ +export function shearB9(L: number, W: number, H: number, columnX: number[] = [], columnD: number = 2000): { rows: number; perRow: number[]; count: number; length: number; bars: B9Bar[] } { + const { rows, perRow, bars } = b9Bars(L, W, columnX, columnD); const lv = layers(H).B5 - layers(H).B24; // 1750 const lh = hookLength('stirrup135', d('H16')); - return { rows: rowIndices.length, perRow, count, length: lv + 2 * lh }; + return { rows, perRow, count: bars.length, length: lv + 2 * lh, bars }; } /** Leg offsets from the grid line (§5.3, §6.3): the B1/B3 and B5/B7 legs sit beside the outer leg. */ @@ -183,11 +237,10 @@ export function legOffsets(): { B1: number; B3: number; B5: number; B7: number } } /** B11/B12 heights (§8.1): B10 levels at 150 + k·s_v; the assembly levels are j = n, 2n, 3n (n = 2), and B12 sits d_B10 above B11. */ -export function assemblyHeights(): { B11: number[]; B12: number[] } { +export function assemblyHeights(H: number): { B11: number[]; B12: number[] } { const { cBottom } = FOOTING_DEFAULTS; const sV = FOOTING_DEFAULTS.sV; - const n = FOOTING_DEFAULTS.n; - const js = [n, 2 * n, 3 * n]; + const js = assemblyJs(H); const B11 = js.map((j) => cBottom + j * sV); const B12 = B11.map((z) => z + NOMINAL_DIAMETER.S19); return { B11, B12 }; diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index fc47ad1..e1dbaa2 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 { barLength, bearingBlockRebar, totals } from './bearing-block'; import { bottomPiece, topBarLength, widthLayout } from './coping-main'; -import { shearBars, shearBarsMulti, sideRows, C14_COUNTS, c14Total, STEP_BAR_COUNTS, shearULength, SIDE_LENGTHS, STEP_LENGTHS, capLength, PIECES_PER_POSITION } from './coping'; +import { shearBars, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14Length, stairBars, stepDevLength, undersideAt, shearULength, 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'; @@ -98,14 +98,14 @@ interface FootingBar { } /** The footing bars of one footing (B1–B12 and B9), with the notes used in the 철근표. */ -function footingBarList(Lf: number, Wf: number, Hf: number): FootingBar[] { +function footingBarList(Lf: number, Wf: number, Hf: number, columnX: number[]): FootingBar[] { const out: FootingBar[] = []; for (const b of bottomBars(Lf, Wf, Hf)) out.push({ ...b, note: '교각축' }); for (const b of crossBars(Lf, Wf, Hf)) out.push({ ...b, note: '교각축 직각' }); - for (const b of sideBars(Lf, Wf)) out.push({ ...b, note: '측면 수평' }); - for (const b of assemblyBars(Lf, Wf)) out.push({ ...b, note: '조립 수평' }); - const b9 = shearB9(Lf, Wf, Hf); - out.push({ name: 'B9', size: 'H16', count: b9.count, length: b9.length, note: `전단 @500 엇갈림, ${b9.rows}행 (위치·갈고리는 Q-B18·B19 제안)` }); + for (const b of sideBars(Lf, Wf, Hf)) out.push({ ...b, note: '측면 수평' }); + for (const b of assemblyBars(Lf, Wf, Hf)) out.push({ ...b, note: '조립 수평' }); + const b9 = shearB9(Lf, Wf, Hf, columnX); + out.push({ name: 'B9', size: 'H16', count: b9.count, length: b9.length, note: `전단 @500 엇갈림, ${b9.rows}행, 기둥 구역 제외 (위치·갈고리는 Q-B18·B19 규칙 적용)` }); return out; } @@ -121,9 +121,20 @@ export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): 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본' }); + // Embedment in the coping (받침블럭철근.md §4): l_leg − (block height − bar depth), with the model's block heights. + const blocks = bearingBlockRebar(model); + const blk = totals(blocks); + const embedments = (name: 'S1' | 'S2') => blocks.flatMap((b) => b.bars.filter((r) => r.name === name).map((r) => r.embedment)); + const emb1 = embedments('S1'); + const emb2 = embedments('S2'); + const range = (xs: number[]) => `${Math.round(Math.min(...xs))}–${Math.round(Math.max(...xs))}`; + const anchorage = stepDevLength(m); // RB-S04: the embedment must reach the H19 development length + const shortest = Math.min(...emb1, ...emb2); + 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)` }); // Coping (코핑철근.md §5–§8) const width = widthLayout(B); @@ -157,17 +168,26 @@ export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): 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: '측면, 끝면까지' }); - rows.push({ member: 'coping', name: 'C14', size: 'H19', count: c14Total(), length: STEP_LENGTHS.C14, note: `위 ${C14_COUNTS.upperLines} 줄 ${C14_COUNTS.upperPerLine}본씩, 아래 ${C14_COUNTS.lower.join(' · ')} (합 ${c14Total()})` }); - rows.push({ member: 'coping', name: 'C15', size: 'H19', count: STEP_BAR_COUNTS.C15, length: STEP_LENGTHS.C15, note: '계단부, 가장 높은 구간 (거꾸로 U)' }); - rows.push({ member: 'coping', name: 'C16', size: 'H19', count: STEP_BAR_COUNTS.C16, length: STEP_LENGTHS.C16[0], note: `계단부, 3·4구간 (${STEP_LENGTHS.C16[0]} / ${STEP_LENGTHS.C16[1]})` }); + // Per row: two 일자 (side faces) and two ㄷ (one at each end), so 4 bars per row (코핑철근.md §6.2). + 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})` }); + // 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)); + const lineCount = (line: number) => c14.filter((b) => b.line === line).length; + const omitted = c14.filter((b) => !b.legs).length; + rows.push({ member: 'coping', name: 'C14', size: 'H19', count: c14.length, length: c14Length(model.copingB * 1000), note: `위 2 줄 ${lineCount(1)} · ${lineCount(2)}본씩, 아래 ${[3, 4, 5, 6].map(lineCount).join(' · ')} (합 ${c14.length}) · 연직 발 생략 ${omitted}개 (하면 밖)` }); + const stairs = stairBars(model.copingL * 1000, model.copingB * 1000, model.copingRefTop); + rows.push({ member: 'coping', name: 'C15', size: 'H19', count: stairs.c15.length, length: stairs.c15[0].length, note: '계단부, 가장 높은 구간 (거꾸로 U)' }); + rows.push({ member: 'coping', name: 'C16', size: 'H19', count: stairs.c16.length, length: stairs.c16[0].length, note: `계단부, 3·4구간 (${Math.round(stairs.c16[0].length)} / ${Math.round(stairs.c16[stairs.c16.length - 1].length)})` }); // Column (기둥철근.md · plan C4), P1 defaults const col = columnRebar(COLUMN_P1, m); - rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, note: '외측 r 875, 마디 4697.5 + 갈고리 525 / 3000 ×3 / 3147 (코핑 밖 마디는 기둥철근.md §3.2)' }); - rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, note: '내측 r 843.2, 마디 4847.5 / 3000 ×3 / 2389' }); + 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: '커플러', size: 'L150 Ø60', count: col.couplers, length: 150, note: '@3000, P2 +150' }); rows.push({ member: 'column', name: 'P3', size: 'H19', count: col.p3Count, length: col.p3Length, note: `일반 구간 띠철근 @300, 겹침 ${col.lapP3} (추천안, Q-C6)` }); rows.push({ member: 'column', name: 'P5', size: 'H19', count: col.p5Count, length: col.p5Length, note: '단부 띠철근 @120, 커플러 1개' }); @@ -182,7 +202,9 @@ export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): // 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)) { + // Columns on this footing, as x mm from the footing centre (for the B9 column-zone rule). + const columnX = model.columns.filter((c) => Math.abs(c.s - f.s) <= f.length / 2).map((c) => (c.s - f.s) * 1000); + for (const b of footingBarList(f.length * 1000, f.width * 1000, (f.top - f.bottom) * 1000, columnX)) { const cur = agg.get(b.name); if (cur) { cur.count += b.count; diff --git a/src/main.ts b/src/main.ts index 78a99d7..7f4e0d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -805,10 +805,9 @@ function renderRebar(): void { const body = el('tbody'); for (const r of shown) { const tr = el('tr'); - const nameCell = el('td', r.name); - nameCell.classList.add('bar-no'); - // Clicking a 번호 highlights that row (시안: 번호를 누르면 그 철근만 강조). - nameCell.addEventListener('click', () => { + const nameCell = el('td', r.name, 'bar-no'); + // Clicking a row (any cell) focuses that bar in the table and in 3D; clicking it again clears it (시안: 번호를 누르면 그 철근만 강조). + tr.addEventListener('click', () => { state.rebarFocus = state.rebarFocus === r.name ? null : r.name; renderAll(); }); diff --git a/src/style.css b/src/style.css index 6ab5d23..8b4dcdc 100644 --- a/src/style.css +++ b/src/style.css @@ -293,6 +293,10 @@ select { background: #e9eef4; } +.rebar-table tbody tr { cursor: pointer; } +.rebar-table tr.hl td { background: #fff3cc; } +.rebar-table .bar-no { font-weight: 700; } + .parts { border: 1px solid var(--line); border-radius: 6px; diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index e53a32b..7162aaa 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -1,10 +1,11 @@ import * as THREE from 'three'; import type { PierModel } from '../core/geometry'; -import { grid, layers } from '../core/rebar/footing'; -import { widthLayout } from '../core/rebar/coping-main'; -import { shearBars, shearBarsMulti, shearUHeight, sideRows } from '../core/rebar/coping'; +import { assemblyEndOffset, assemblyJs, b10Levels, b9Bars, FOOTING_DEFAULTS, gridPositions, grid, layers, legLength, legOffsets, sideDepth } from '../core/rebar/footing'; +import { bottomPiece, MAIN, widthLayout } from '../core/rebar/coping-main'; +import { C14_INSET, c14Bars, CAP, COPING_REBAR, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping'; import { columnRebar, COLUMN_P1 } from '../core/rebar/column'; -import { NOMINAL_DIAMETER } from '../core/rebar/common'; +import { barsAcross, BLOCK_REBAR } from '../core/rebar/bearing-block'; +import { hookLength, NOMINAL_DIAMETER } from '../core/rebar/common'; import { stretchMatrix } from './scene'; /** @@ -61,51 +62,131 @@ export function buildRebar3D(m: PierModel): RebarScene { const w = widthLayout(B * 1000); const xs = [w.yE, ...Array.from({ length: w.n + 1 }, (_, i) => w.yE + w.g + 125 * i), B * 1000 - w.yE].map((v) => mm(v) - B / 2); const cover = 0.1; - // Top rows C1–C6: row k is 3 bars per cycle; the even positions are cycle 1 (C1–C3), the odd cycle 2 (C4–C6). + // Top rows C1–C6 (코핑철근.md §5.1, §5.3): counted from the left, the even (1-based) positions are cycle 1 (C1–C3), + // the odd cycle 2 (C4–C6). In 0-based j that is odd j → cycle 1. Each row turns down 472 at both ends, and the + // k-th row's ends sit (k − 1)·57.2 further in. const topEl = m.copingRefTop; + const hookTop = MAIN.hook / 1000; for (let k = 1; k <= 3; k++) { const z = topEl - 0.127 - (k - 1) * 0.057; + const xk = cover + ((k - 1) * MAIN.layHorizontal) / 1000; xs.forEach((n, j) => { - const name = `C${k + (j % 2 === 1 ? 3 : 0)}`; - addBar(name, 0, [cover, z, n], [L - cover, z, n], NOMINAL_DIAMETER.H29, COLOR.coping); + const name = `C${k + (j % 2 === 1 ? 0 : 3)}`; + const pts: [number, number, number][] = [ + [xk, z - hookTop, n], + [xk, z, n], + [L - xk, z, n], + [L - xk, z - hookTop, n], + ]; + for (let s = 0; s + 1 < pts.length; s++) addBar(name, 0, pts[s], pts[s + 1], NOMINAL_DIAMETER.H29, COLOR.coping); }); } - // Bottom bars C7 / C8 along the Level height. + // Bottom bars C7 / C8 (코핑철근.md §5.2–5.3): they follow the sloped underside, 127 above it, with a vertical leg up + // at each end to the C1 level. Each bar is two pieces spliced at the column centre (overlap l_s). The slope runs x_L0 from each end (T형). + const xL0 = L / 2 - COPING_REBAR.levelWidth / 2000; + const under = (x: number) => undersideAt(L * 1000, x * 1000, m.endBottom, m.levelBottom); + const lap = bottomPiece().ls / 2000; xs.forEach((n, j) => { - const z = m.levelBottom + 0.127; - addBar(j % 2 === 0 ? 'C7' : 'C8', 0, [cover, z, n], [L - cover, z, n], NOMINAL_DIAMETER.H29, COLOR.coping); + const z = (x: number) => under(x) + 0.127; + const left: [number, number, number][] = [ + [cover, topEl - 0.127, n], + [cover, z(cover), n], + [xL0, z(xL0), n], + [L / 2 + lap, z(L / 2 + lap), n], + ]; + const right = left.map(([x, y, zz]): [number, number, number] => [L - x, y, zz]); + const name = j % 2 === 1 ? 'C7' : 'C8'; + for (const pts of [left, right]) { + for (let k = 0; k + 1 < pts.length; k++) addBar(name, 0, pts[k], pts[k + 1], NOMINAL_DIAMETER.H29, COLOR.coping); + } }); // Shear loops C9 / C10 / C11 and the gaps between columns: a closed U at each position. const colS = m.columns.map((c) => c.s * 1000); const sh = colS.length > 1 ? shearBarsMulti(L * 1000, colS) : { ...shearBars(L * 1000), between: [] as number[] }; const loops: [string, number[]][] = [['C9', sh.c9], ['C10', sh.c10], ['C11', sh.c11], ['C9·C11 사이', sh.between]]; - const ne = B / 2 - 0.1; + // Each position has three 폐합 (코핑철근.md §4.1): legs at y_i = c + i·s_leg (i = 0 … 5), 폐합 i = legs i and i + 3, + // 1740 wide. Each 폐합 is a 하부 U (base at the bottom, legs up to the top) and a 상부 삿갓 (top bar, legs down + // l_cap = h − c) that sits 25.4 beside the U along the coping. const top = topEl - 0.1; + const dShear = NOMINAL_DIAMETER.H25; + const sLeg = (B * 1000 - 2 * CAP.cover) / 5 / 1000; + const legN = (i: number) => -B / 2 + 0.1 + i * sLeg; for (const [name, list] of loops) { for (const x of list) { const xm = mm(x); - const bot = top - shearUHeight(x, L * 1000) / 1000; - const d = NOMINAL_DIAMETER.H25; - addBar(name, 0, [xm, top, -ne], [xm, top, ne], d, COLOR.shear); - addBar(name, 0, [xm, bot, -ne], [xm, bot, ne], d, COLOR.shear); - addBar(name, 0, [xm, top, -ne], [xm, bot, -ne], d, COLOR.shear); - addBar(name, 0, [xm, top, ne], [xm, bot, ne], d, COLOR.shear); + const h = shearUHeight(x, L * 1000) / 1000; + const bot = top - h; + const capBot = top - (h - CAP.cover / 1000); + const xc = xm + mm(NOMINAL_DIAMETER.S25); + for (let i = 0; i < 3; i++) { + const a = legN(i); + const b = legN(i + 3); + addBar(name, 0, [xm, bot, a], [xm, bot, b], dShear, COLOR.shear); + addBar(name, 0, [xm, bot, a], [xm, top, a], dShear, COLOR.shear); + addBar(name, 0, [xm, bot, b], [xm, top, b], dShear, COLOR.shear); + addBar(name, 0, [xc, top, a], [xc, top, b], dShear, COLOR.shear); + addBar(name, 0, [xc, top, a], [xc, capBot, a], dShear, COLOR.shear); + addBar(name, 0, [xc, top, b], [xc, capBot, b], dShear, COLOR.shear); + } } } - // Side horizontal bars C12 / C13 at both side faces. - const side = sideRows(41.288, 42.538); + // Side horizontal bars C12 / C13 (코핑철근.md §6.2): per row, two 일자 at the side faces from x_w to L − x_w, + // and two ㄷ bars: a body across the width at x_w, 25.4 above the row, with legs 1525 inward along the faces. const sideFace = B / 2 - 0.127; + const dSide = NOMINAL_DIAMETER.H25; + const side = copingSideBars(L * 1000, 41.288, 42.538); for (const [name, rows] of [['C12', side.c12], ['C13', side.c13]] as const) { - for (const z of rows) { - addBar(name, 0, [cover, z, -sideFace], [L - cover, z, -sideFace], NOMINAL_DIAMETER.H25, COLOR.shear); - addBar(name, 0, [cover, z, sideFace], [L - cover, z, sideFace], NOMINAL_DIAMETER.H25, COLOR.shear); + for (const r of rows) { + const xw = mm(r.xw); + addBar(name, 0, [xw, r.z, -sideFace], [L - xw, r.z, -sideFace], dSide, COLOR.shear); + addBar(name, 0, [xw, r.z, sideFace], [L - xw, r.z, sideFace], dSide, COLOR.shear); + const zh = r.z + mm(dSide); + for (const x0 of [xw, L - xw]) { + const leg = mm(SIDE_HOOK_LEG) * Math.sign(L / 2 - x0); + addBar(name, 0, [x0, zh, -sideFace], [x0, zh, sideFace], dSide, COLOR.shear); + addBar(name, 0, [x0, zh, -sideFace], [x0 + leg, zh, -sideFace], dSide, COLOR.shear); + addBar(name, 0, [x0, zh, sideFace], [x0 + leg, zh, sideFace], dSide, COLOR.shear); + } } } + + // C14 ⊓ (코핑철근.md §8): across the width at each x, both ends down 15.5·d_b. + const c14Hook = mm(hookLength('main90', NOMINAL_DIAMETER.H19)); + const c14Na = -B / 2 + mm(C14_INSET); + const c14Nb = B / 2 - mm(C14_INSET); + for (const b of c14Bars(L * 1000, topEl, [...side.c12, ...side.c13], (x) => undersideAt(L * 1000, x, m.endBottom, m.levelBottom))) { + const xm = mm(b.x); + addBar('C14', 0, [xm, b.z, c14Na], [xm, b.z, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping); + // 연직 발 below the coping underside is left out (코핑철근.md §8). + if (b.legs) { + addBar('C14', 0, [xm, b.z - c14Hook, c14Na], [xm, b.z, c14Na], NOMINAL_DIAMETER.H19, COLOR.coping); + addBar('C14', 0, [xm, b.z, c14Nb], [xm, b.z - c14Hook, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping); + } + } + // C15 (거꾸로 된 U) and C16 (L형) on the stair (코핑철근.md §7): 11 bars each across the width. + const stairs = stairBars(L * 1000, B * 1000, topEl); + for (const s of stairs.c15) { + const n = mm(s.n); + const ld = mm(s.leg); + const x0 = mm(s.x0); + const x1 = mm(s.x1); + addBar('C15', 0, [x0, s.z - ld, n], [x0, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping); + addBar('C15', 0, [x0, s.z, n], [x1, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping); + addBar('C15', 0, [x1, s.z, n], [x1, s.z - ld, n], NOMINAL_DIAMETER.H19, COLOR.coping); + } + for (const s of stairs.c16) { + const n = mm(s.n); + const ld = mm(s.leg); + const x0 = mm(s.x0); + const x1 = mm(s.x1); + addBar('C16', 0, [x0, s.z - ld, n], [x0, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping); + addBar('C16', 0, [x0, s.z, n], [x1, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping); + } } // Columns: P1 / P2 main bars on their radii, ties P3 / P5 / P4 as rings, P6 crossties across the column. const col = columnRebar(COLUMN_P1); - const zBot = -1697.5; + const zBot = col.zBot; // mm const ring = (name: string, k: number, sc: number, y: number, r: number, d: number) => { const N = 36; for (let i = 0; i < N; i++) { @@ -122,16 +203,30 @@ export function buildRebar3D(m: PierModel): RebarScene { const th = (2 * Math.PI * i) / COLUMN_P1.nDiv; const r1 = mm(col.r1); addBar('P1', c.k, [sc + r1 * Math.cos(th), base + mm(zBot), r1 * Math.sin(th)], [sc + r1 * Math.cos(th), base + mm(col.zTop1), r1 * Math.sin(th)], NOMINAL_DIAMETER.S32, COLOR.column); + // 90° bottom hook of P1, outward (기둥철근.md §3.2, Q-C5): the 12·d tail is drawn, the bend is approximated by the tail's start. + const tail = mm(12 * NOMINAL_DIAMETER.S32); + const rOut = r1 + tail; + addBar('P1', c.k, [sc + r1 * Math.cos(th), base + mm(zBot), r1 * Math.sin(th)], [sc + rOut * Math.cos(th), base + mm(zBot), rOut * Math.sin(th)], NOMINAL_DIAMETER.S32, COLOR.column); const r2 = mm(col.r2); addBar('P2', c.k, [sc + r2 * Math.cos(th), base + mm(zBot), r2 * Math.sin(th)], [sc + r2 * Math.cos(th), base + mm(col.zTop2), r2 * Math.sin(th)], NOMINAL_DIAMETER.S32, COLOR.column); } for (const z of col.p3) ring('P3', c.k, sc, base + mm(z), rt, NOMINAL_DIAMETER.H19); for (const z of col.p5) ring('P5', c.k, sc, base + mm(z), rt, NOMINAL_DIAMETER.H19); for (const z of col.p4) ring('P4', c.k, sc, base + mm(z), rt, NOMINAL_DIAMETER.H19); + const hookP6 = mm(Math.round(hookLength('tie90', NOMINAL_DIAMETER.H16))); // 135 mm for (const z of col.p6Levels) { for (const deg of [0, 46.96, 90, 133.04]) { const a = (deg * Math.PI) / 180; - addBar('P6', c.k, [sc - rt * Math.cos(a), base + mm(z), -rt * Math.sin(a)], [sc + rt * Math.cos(a), base + mm(z), rt * Math.sin(a)], NOMINAL_DIAMETER.H16, COLOR.tie); + const xA = sc - rt * Math.cos(a); + const zA = -rt * Math.sin(a); + const xB = sc + rt * Math.cos(a); + const zB = rt * Math.sin(a); + const yBar = base + mm(z); + // Main crosstie across the diameter + addBar('P6', c.k, [xA, yBar, zA], [xB, yBar, zB], NOMINAL_DIAMETER.H16, COLOR.tie); + // 90° hooks at both ends turning downward (135 mm, wrapping the tie) + addBar('P6', c.k, [xA, yBar, zA], [xA, yBar - hookP6, zA], NOMINAL_DIAMETER.H16, COLOR.tie); + addBar('P6', c.k, [xB, yBar, zB], [xB, yBar - hookP6, zB], NOMINAL_DIAMETER.H16, COLOR.tie); } } } @@ -151,37 +246,122 @@ export function buildRebar3D(m: PierModel): RebarScene { const nA = -Wf / 2 + 0.1; const nB = Wf / 2 - 0.1; const bottom = f.bottom; - const along = (name: string, n: number, y: number, dMm: number, color: number) => - addBar(name, 0, [f.s + sA, y, n], [f.s + sB, y, n], dMm, color); - const acrossN = (name: string, s: number, y: number, dMm: number, color: number) => - addBar(name, 0, [f.s + s, y, nA], [f.s + s, y, nB], dMm, color); + const off = legOffsets(); + // One name, several cylinders: each segment of the path is one bar piece. + const path = (name: string, pts: [number, number, number][], dMm: number) => { + for (let k = 0; k + 1 < pts.length; k++) addBar(name, 0, pts[k], pts[k + 1], dMm, COLOR.footing); + }; + // B1–B4: along s, at W position n. legTo (absolute EL, m) is where both ends turn up or down (§5.1); undefined = straight. + const along = (name: string, n: number, y: number, legTo: number | undefined, dMm: number) => { + const at = (t: number, h: number): [number, number, number] => [f.s + t, h, n]; + path(name, legTo === undefined ? [at(sA, y), at(sB, y)] : [at(sA, legTo), at(sA, y), at(sB, y), at(sB, legTo)], dMm); + }; + // B5–B8: across, at L position s. Same rule, with the ends on the W faces (§6.2). + const across = (name: string, s: number, y: number, legTo: number | undefined, dMm: number) => { + const at = (t: number, h: number): [number, number, number] => [f.s + s, h, t]; + path(name, legTo === undefined ? [at(nA, y), at(nB, y)] : [at(nA, legTo), at(nA, y), at(nB, y), at(nB, legTo)], dMm); + }; for (let j = 0; j <= gW.n; j++) { const n = posW(j); if (j % 2 === 1) { - along('B1', n, bottom + mm(z.B1), NOMINAL_DIAMETER.S25, COLOR.footing); - along('B3', n, bottom + mm(z.B3), NOMINAL_DIAMETER.S19, COLOR.footing); + // B1 ⊓ down, B2 ⊔ up to the B1 height, B3 ⊔ up. B1·B3 sit beside the B2 leg (+25.4 / +22.25, §5.3). + along('B1', n + mm(off.B1), bottom + mm(z.B1), bottom + mm(z.B1) - mm(legLength('S25')), NOMINAL_DIAMETER.S25); + along('B2', n, bottom + mm(z.B24), bottom + mm(z.B1), NOMINAL_DIAMETER.S25); + along('B3', n + mm(off.B3), bottom + mm(z.B3), bottom + mm(z.B3) + mm(legLength('S19')), NOMINAL_DIAMETER.S19); } else { - along('B4', n, bottom + mm(z.B24), NOMINAL_DIAMETER.S25, COLOR.footing); + along('B4', n, bottom + mm(z.B24), undefined, NOMINAL_DIAMETER.S25); } } for (let i = 0; i <= gL.n; i++) { const s = posL(i); if (i % 2 === 1) { - acrossN('B5', s, bottom + mm(z.B5), NOMINAL_DIAMETER.S29, COLOR.footing); - acrossN('B6', s, bottom + mm(z.B68), NOMINAL_DIAMETER.S29, COLOR.footing); - acrossN('B7', s, bottom + mm(z.B7), NOMINAL_DIAMETER.S19, COLOR.footing); + // B5 ⊓ down, B6 ⊔ up to the B5 height, B7 ⊔ up. B5·B7 sit beside the B6 leg (+28.6 / +23.85, §6.3). + across('B5', s + mm(off.B5), bottom + mm(z.B5), bottom + mm(z.B5) - mm(legLength('S29')), NOMINAL_DIAMETER.S29); + across('B6', s, bottom + mm(z.B68), bottom + mm(z.B5), NOMINAL_DIAMETER.S29); + across('B7', s + mm(off.B7), bottom + mm(z.B7), bottom + mm(z.B7) + mm(legLength('S19')), NOMINAL_DIAMETER.S19); } else { - acrossN('B8', s, bottom + mm(z.B68), NOMINAL_DIAMETER.S29, COLOR.footing); + across('B8', s, bottom + mm(z.B68), undefined, NOMINAL_DIAMETER.S29); } } + // B9 (확대기초철근 (1).md §9, rule applied): vertical H16 bars on the grid, outside the column zones, with 135° hooks. + // Each hook tail (6·d) turns toward the footing centre along the pier axis (Q-B19). + const dB9 = NOMINAL_DIAMETER.H16; + const tail9 = (6 * dB9) / 1000; + const colX = m.columns.filter((c) => Math.abs(c.s - f.s) <= Lf / 2).map((c) => (c.s - f.s) * 1000); + const yB9Bot = bottom + mm(z.B24); + const yB9Top = bottom + mm(z.B5); + for (const b of b9Bars(Lf * 1000, Wf * 1000, colX).bars) { + const x = mm(b.x); + const n = mm(b.n); + const tailDir = x >= 0 ? -1 : 1; + addBar('B9', 0, [f.s + x, yB9Bot, n], [f.s + x, yB9Top, n], dB9, COLOR.footing); + addBar('B9', 0, [f.s + x, yB9Top, n], [f.s + x + tailDir * tail9, yB9Top, n], dB9, COLOR.footing); + addBar('B9', 0, [f.s + x, yB9Bot, n], [f.s + x + tailDir * tail9, yB9Bot, n], dB9, COLOR.footing); + } + + // B10-1 / B10-2 (§7) at each B10 level, B11 / B12 (§8) between them. Ends turn 90° inward (B10) or down (B11, B12). + const polyline = (name: string, pts: [number, number, number][], dMm: number) => { + for (let k = 0; k + 1 < pts.length; k++) addBar(name, 0, pts[k], pts[k + 1], dMm, COLOR.footing); + }; + const hookB10 = mm(hookLength('main90', NOMINAL_DIAMETER.S19)); // 296 + const hookAsm = mm(Math.round(hookLength('main90', NOMINAL_DIAMETER.S16))); // 246 + const d1 = sideDepth(NOMINAL_DIAMETER.S29, NOMINAL_DIAMETER.S19); // B10-1 depth (mm), on the W faces + const d2 = sideDepth(NOMINAL_DIAMETER.S25, NOMINAL_DIAMETER.S19); // B10-2 depth (mm), on the L faces + const sEdge = Lf / 2 - mm(d2); + const nEdge = Wf / 2 - mm(d1); + const dB10 = mm(NOMINAL_DIAMETER.S19); + for (let j = 0; j < b10Levels(Hf); j++) { + const zj = bottom + mm(FOOTING_DEFAULTS.cBottom + j * FOOTING_DEFAULTS.sV); + for (const sg of [-1, 1]) { + // B10-2: across W at the L faces; its ends turn inward along s. + const sp = f.s + sg * sEdge; + polyline('B10-2', [[sp - sg * hookB10, zj, -nEdge], [sp, zj, -nEdge], [sp, zj, nEdge], [sp - sg * hookB10, zj, nEdge]], NOMINAL_DIAMETER.S19); + // B10-1: along s at the W faces, 19.1 above B10-2; its ends turn inward along n. + const np = sg * nEdge; + const zb = zj + dB10; + polyline('B10-1', [[f.s - sEdge, zb, np - sg * hookB10], [f.s - sEdge, zb, np], [f.s + sEdge, zb, np], [f.s + sEdge, zb, np - sg * hookB10]], NOMINAL_DIAMETER.S19); + } + } + // B11 (across W, spread along L) and B12 (along L, spread across W) at the assembly levels, 4s apart, 2s stagger per level. + const xsL = gridPositions(Lf * 1000); + const ysW = gridPositions(Wf * 1000); + const dAsm = assemblyEndOffset(); + const nAsm = Wf / 2 - mm(d1 + dAsm); + const sAsm = Lf / 2 - mm(d2 + dAsm); + assemblyJs(Hf).forEach((jA, k) => { + const zA = bottom + mm(FOOTING_DEFAULTS.cBottom + jA * FOOTING_DEFAULTS.sV); + const zB = zA + dB10; + const i0 = k % 2 === 0 ? 0 : 2; + for (let iL = i0; iL < xsL.length; iL += 4) { + const x = f.s + mm(xsL[iL]); + polyline('B11', [[x, zA - hookAsm, -nAsm], [x, zA, -nAsm], [x, zA, nAsm], [x, zA - hookAsm, nAsm]], NOMINAL_DIAMETER.S16); + } + for (let iW = i0; iW < ysW.length; iW += 4) { + const n = mm(ysW[iW]); + polyline('B12', [[f.s - sAsm, zB - hookAsm, n], [f.s - sAsm, zB, n], [f.s + sAsm, zB, n], [f.s + sAsm, zB - hookAsm, n]], NOMINAL_DIAMETER.S16); + } + }); } // 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); + const dBar = NOMINAL_DIAMETER.H19; for (const b of m.blocks) { - for (let j = 0; j < 8; j++) { - const off = (j - 3.5) * 0.125; - addBar('S2', b.k, [b.s - 0.5, b.top - 0.05, b.n + off], [b.s + 0.5, b.top - 0.05, b.n + off], NOMINAL_DIAMETER.H19, COLOR.block); - addBar('S1', b.k, [b.s + off, b.top - 0.069, b.n - 0.5], [b.s + off, b.top - 0.069, b.n + 0.5], NOMINAL_DIAMETER.H19, COLOR.block); + const z2 = b.top - BLOCK_REBAR.depthS2 / 1000; + const z1 = b.top - BLOCK_REBAR.depthS1 / 1000; + for (const off of offsets) { + const n2 = b.n + off; + addBar('S2', b.k, [b.s - half, z2, n2], [b.s + half, z2, n2], dBar, COLOR.block); + addBar('S2', b.k, [b.s - half, z2 - legM, n2], [b.s - half, z2, n2], dBar, COLOR.block); + addBar('S2', b.k, [b.s + half, z2, n2], [b.s + half, z2 - legM, n2], dBar, COLOR.block); + const s1 = b.s + off; + addBar('S1', b.k, [s1, z1, b.n - half], [s1, z1, b.n + half], dBar, COLOR.block); + addBar('S1', b.k, [s1, z1 - legM, b.n - half], [s1, z1, b.n - half], dBar, COLOR.block); + addBar('S1', b.k, [s1, z1, b.n + half], [s1, z1 - legM, b.n + half], dBar, COLOR.block); } } diff --git a/tests/rebar-bearing.test.ts b/tests/rebar-bearing.test.ts index 21f3a2c..7493ea4 100644 --- a/tests/rebar-bearing.test.ts +++ b/tests/rebar-bearing.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; import { barsAcross, bearingBlockRebar, blockBars, totals } from '../src/core/rebar/bearing-block'; +import { rebarSummary } from '../src/core/rebar/summary'; import { loadP1 } from './helpers'; describe('받침블럭 철근 (spec §6.3, plan C2)', () => { @@ -16,13 +17,14 @@ describe('받침블럭 철근 (spec §6.3, plan C2)', () => { for (const bar of blockBars(150)) expect(bar.length).toBe(2500); }); - it('coping embedment: S2 600 (front) / 550 (back), S1 619.1 (front) / 569.1 (back), as in the spec', () => { - const front = blockBars(150); - const back = blockBars(200); - expect(front.find((b) => b.name === 'S2')!.embedment).toBeCloseTo(600, 6); - expect(back.find((b) => b.name === 'S2')!.embedment).toBeCloseTo(550, 6); - expect(front.find((b) => b.name === 'S1')!.embedment).toBeCloseTo(619.1, 6); - expect(back.find((b) => b.name === 'S1')!.embedment).toBeCloseTo(569.1, 6); + it('coping embedment from the model block height (block top − coping top: front 100, back 150): S2 650 / 600, S1 669.1 / 619.1', () => { + const m = buildPier(loadP1()); + const rebar = bearingBlockRebar(m); + const emb = (row: '전열' | '후열', name: 'S1' | 'S2') => rebar.find((b) => b.row === row)!.bars.find((r) => r.name === name)!.embedment; + expect(emb('전열', 'S2')).toBeCloseTo(650, 6); + expect(emb('후열', 'S2')).toBeCloseTo(600, 6); + expect(emb('전열', 'S1')).toBeCloseTo(669.1, 6); + expect(emb('후열', 'S1')).toBeCloseTo(619.1, 6); }); it('P1: 10 blocks, 80 bars each of S1 and S2 (spec table: 8 本 per block)', () => { @@ -47,3 +49,12 @@ describe('받침블럭 도면 재현 (spec §6.3)', () => { expect(across.firstEnd).toBeCloseTo(162.5, 6); // 50 + 75 + 37.5, printed as 50 + 113 in the drawing }); }); + +describe('받침블럭 묻힘 검토 (spec §9 RB-S04)', () => { + it('P1: embedments 619–669 mm are shorter than the H19 development length 882, so RB-S04 is raised and the rows show the range', () => { + const { rows, warnings } = rebarSummary(buildPier(loadP1())); + expect(warnings.some((w) => w.code === 'RB-S04')).toBe(true); + expect(rows.find((r) => r.name === 'S1')!.note).toContain('묻힘 619–669'); + expect(rows.find((r) => r.name === 'S2')!.note).toContain('묻힘 600–650'); + }); +}); diff --git a/tests/rebar-column.test.ts b/tests/rebar-column.test.ts index cd607cf..fdff22a 100644 --- a/tests/rebar-column.test.ts +++ b/tests/rebar-column.test.ts @@ -84,3 +84,19 @@ describe('RB-C07 P4 삭제 규칙 (기둥철근.md §6.2)', () => { expect(r.kept).toEqual([-60]); }); }); + +describe('기둥철근 P1·P2 마디와 바닥 (기둥철근.md §3.2)', () => { + it('bottom at −1697.5 (−H_f + z_B7 + (d_B7 + d_P1)/2), P1 pieces 4697.5 + 525 / 3000 ×3 / 3147', () => { + expect(c.zBot).toBeCloseTo(-1697.55, 1); + expect(c.p1Hook).toBe(525); + expect(c.p1Pieces.map((v) => Math.round(v))).toEqual([5223, 3000, 3000, 3000, 3147]); + }); + + it('P2 pieces 4847.5 / 3000 ×3 / 2389', () => { + expect(c.p2Pieces.map((v) => Math.round(v))).toEqual([4848, 3000, 3000, 3000, 2389]); + }); + + it('P6 hook 135 each, so the length is 2071 (1800.9 + 2 × 135)', () => { + expect(c.p6Length).toBeCloseTo(2070.9, 1); + }); +}); diff --git a/tests/rebar-coping.test.ts b/tests/rebar-coping.test.ts index e4d8ac0..3c7d0e0 100644 --- a/tests/rebar-coping.test.ts +++ b/tests/rebar-coping.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { c14Total, COPING_END_GAP, sideRows, shearBars, STEP_BAR_COUNTS } from '../src/core/rebar/coping'; +import { c14Total, COPING_END_GAP, copingSideBars, sideRows, shearBars, STEP_BAR_COUNTS } from '../src/core/rebar/coping'; // P1 coping: L 12000, one column at the centre (T형). const L = 12000; @@ -43,6 +43,17 @@ describe('코핑 철근 (코핑철근.md, plan C3)', () => { expect(r.c13).toEqual([42.538, 42.788]); }); + it('C12 일자 lengths 3717 / 5333 / 6950 / 8567 / 10183 and C13 11746 for L 12000 (코핑철근.md §6.2 table)', () => { + const s = copingSideBars(12000, 41.288, 42.538); + const expected = [3717, 5333, 6950, 8567, 10183]; + s.c12.forEach((r, i) => expect(r.straight).toBeCloseTo(expected[i], 0)); + expect(s.c12[0].xw).toBeCloseTo(4141.7, 1); + s.c13.forEach((r) => { + expect(r.xw).toBe(127); + expect(r.straight).toBe(11746); + }); + }); + it('C14: 38 bars = 2 × 11 upper + 5 + 6 + 3 + 2 lower', () => { expect(c14Total()).toBe(38); }); diff --git a/tests/rebar-footing.test.ts b/tests/rebar-footing.test.ts index 3f99c26..8c832ba 100644 --- a/tests/rebar-footing.test.ts +++ b/tests/rebar-footing.test.ts @@ -52,32 +52,44 @@ describe('확대기초 철근 (확대기초철근 (1).md, plan C5)', () => { }); it('B10-1 18 bars ≈ 10748, B10-2 18 bars ≈ 8494', () => { - const [b101, b102] = sideBars(L, W); + const [b101, b102] = sideBars(L, W, H); 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); + it('B10 levels from the height: H 2000 → m 8 (9 levels), H 2200 → m 9 (10 levels), as in the document', () => { + expect(b10Levels(2000)).toBe(9); + expect(b10Levels(2200)).toBe(10); + expect(assemblyJs(2000)).toEqual([2, 4, 6]); + expect(assemblyJs(2200)).toEqual([2, 4, 6, 8]); }); - it('B9 shear: 16 rows, 20 per row, 320 in all, length ≈ 2128 (l_v 1750 + 2 × 189)', () => { - const b9 = shearB9(L, W, H); + it('B11 60 bars (20 / 20 / 20) ≈ 8359, B12 47 bars (16 / 15 / 16) ≈ 10613', () => { + const [b11, b12] = assemblyBars(L, W, H); + expect(b11.count).toBe(60); + expect(b11.length).toBeCloseTo(8359, 0); + expect(b12.count).toBe(47); + expect(b12.length).toBeCloseTo(10612.5, 1); // the document prints ≈ 10613 + }); + + it('B9 shear: 16 rows, 20 per row on the grid, 320 in all, length ≈ 2128 (l_v 1750 + 2 × 189)', () => { + const b9 = shearB9(L, W, H, []); expect(b9.rows).toBe(16); expect(b9.perRow.every((c) => c === 20)).toBe(true); expect(b9.count).toBe(320); expect(b9.length).toBeCloseTo(2128, 0); }); + + it('B9 column zone: bars whose centre is within D/2 + d/2 of a column axis are left out (P1: 14 bars)', () => { + const b9 = shearB9(L, W, H, [0], 2000); + expect(b9.count).toBe(306); + for (const b of b9.bars) expect(Math.hypot(b.x, b.n)).toBeGreaterThan(1000 + 7.95); + }); }); -import { assemblyHeights, legOffsets } from '../src/core/rebar/footing'; +import { assemblyHeights, assemblyJs, b10Levels, legOffsets } from '../src/core/rebar/footing'; describe('확대기초 배근도 재현 (확대기초철근 (1).md §13)', () => { it('leg offsets: B1 +25.4, B3 +22.25, B5 +28.6, B7 +23.85', () => { @@ -89,7 +101,7 @@ describe('확대기초 배근도 재현 (확대기초철근 (1).md §13)', () => }); it('B11 at 550 / 950 / 1350 and B12 at 569.1 / 969.1 / 1369.1', () => { - const h = assemblyHeights(); + const h = assemblyHeights(H); expect(h.B11).toEqual([550, 950, 1350]); expect(h.B12.map((z) => Math.round(z * 10) / 10)).toEqual([569.1, 969.1, 1369.1]); }); diff --git a/tests/rebar-stairs.test.ts b/tests/rebar-stairs.test.ts new file mode 100644 index 0000000..50e8b1d --- /dev/null +++ b/tests/rebar-stairs.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { c14Bars, c14Length, copingSideBars, stairBars, undersideAt } from '../src/core/rebar/coping'; +import { loadP1 } from './helpers'; + +const m = buildPier(loadP1()); +const L = m.copingL * 1000; +const B = m.copingB * 1000; +const side = copingSideBars(L, 41.288, 42.538); +const rows = [...side.c12, ...side.c13]; +const under = (x: number) => undersideAt(L, x, m.endBottom, m.levelBottom); + +describe('C14 / C15 / C16 (코핑철근.md §7–8)', () => { + it('C14: 2 upper lines of 11 (C2, C3) and lower rows 5 / 6 / 3 / 2 = 38', () => { + const bars = c14Bars(L, m.copingRefTop, rows, under); + const count = (line: number) => bars.filter((b) => b.line === line).length; + expect(count(1)).toBe(11); + expect(count(2)).toBe(11); + expect([3, 4, 5, 6].map(count)).toEqual([5, 6, 3, 2]); + expect(bars.length).toBe(38); + }); + + it('C14 heights: upper 43.251 / 43.194, lower 42.810 / 42.310 / 41.810 / 41.310', () => { + const bars = c14Bars(L, m.copingRefTop, rows, under); + const z = (line: number) => bars.find((b) => b.line === line)!.z; + expect(z(1)).toBeCloseTo(43.251, 3); + expect(z(2)).toBeCloseTo(43.194, 3); + expect([3, 4, 5, 6].map(z)).toEqual([42.81, 42.31, 41.81, 41.31].map((v) => expect.closeTo(v, 2))); + }); + + it('C14 length 3447.5 (2855.5 run + 2 × 296 legs)', () => { + expect(c14Length(B)).toBeCloseTo(3447.5, 0); + }); + + it('C15: 11 bars, z 43.598, length 3852 (2088 + 2 × 882)', () => { + const { c15 } = stairBars(L, B, m.copingRefTop); + expect(c15.length).toBe(11); + expect(c15[0].z).toBeCloseTo(43.598, 3); + expect(c15[0].length).toBeCloseTo(3852, 0); + expect(c15[0].x0).toBeCloseTo(9812, 0); + }); + + it('C16: 22 bars (3·4구간), z 43.436 / 43.507, lengths 4070 / 4069', () => { + const { c16 } = stairBars(L, B, m.copingRefTop); + expect(c16.length).toBe(22); + expect(c16[0].z).toBeCloseTo(43.436, 3); + expect(c16[11].z).toBeCloseTo(43.507, 3); + expect(c16[0].length).toBeCloseTo(4070, 0); + expect(c16[11].length).toBeCloseTo(4069, 0); + }); + + it('C15 / C16 across positions: 137 from the cover line, then @250, 11 bars', () => { + const { c15 } = stairBars(L, B, m.copingRefTop); + expect(c15[0].n).toBeCloseTo(-B / 2 + 237, 0); + expect(c15[10].n - c15[0].n).toBeCloseTo(2500, 0); + }); +}); + +describe('C14 연직 발 (코핑철근.md §8)', () => { + it('upper lines keep their legs; a lower bar keeps its legs only if they end above the underside', () => { + const bars = c14Bars(L, m.copingRefTop, rows, under); + expect(bars.filter((b) => b.line <= 2).every((b) => b.legs)).toBe(true); + // The leg of the C12 5단 bar (line 4) at the ends (x 1 m, 11 m) would end below the sloped underside, so it is left out. + const omitted = bars.filter((b) => !b.legs).map((b) => [b.line, Math.round(b.x / 1000)]); + expect(omitted).toEqual([[4, 1], [4, 11]]); + }); +}); diff --git a/tests/rebar-summary.test.ts b/tests/rebar-summary.test.ts index ead6746..d636cdc 100644 --- a/tests/rebar-summary.test.ts +++ b/tests/rebar-summary.test.ts @@ -12,13 +12,13 @@ describe('③ 철근표 요약 (P1)', () => { 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', () => { + it('coping: C1–C8 12 each, C9 18, C10 9, C11 18, C12 20 (5 rows × 일자 2 + ㄷ 2), C13 8 (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('C12')).toBe(20); + expect(count('C13')).toBe(8); expect(count('C14')).toBe(38); expect(count('C15')).toBe(11); expect(count('C16')).toBe(22); @@ -43,9 +43,9 @@ describe('③ 철근표 요약 (P1)', () => { expect(count('B12')).toBe(47); }); - it('B9: 320 bars; warnings: B2 needs a splice (13646 > 12000)', () => { - expect(count('B9')).toBe(320); - expect(warnings.map((w) => w.code)).toEqual(['I02', 'RB-F08']); // defaults are assumed values (I02) + it('B9: 306 bars (320 on the grid less 14 in the column zone); warnings: B2 needs a splice (13646 > 12000)', () => { + expect(count('B9')).toBe(306); + expect(warnings.map((w) => w.code)).toEqual(['I02', 'RB-S04', 'RB-F08']); // defaults are assumed values (I02); bearing embedment 619–669 < 882 (RB-S04) expect(warnings.find((w) => w.code === 'RB-F08')!.bar).toBe('B2'); // B2 is split into two pieces (추천안): each piece is about half the 13646 bar plus half the lap. expect(rows.find((r) => r.name === 'B2')!.length!).toBeCloseTo(7776, 0); diff --git a/tests/rebar3d.test.ts b/tests/rebar3d.test.ts index dc51b1e..a0ca489 100644 --- a/tests/rebar3d.test.ts +++ b/tests/rebar3d.test.ts @@ -13,11 +13,60 @@ describe('3D 철근 모델 (③)', () => { } }); - it('P1 has one bar per 원주 등분 (46) and P2 likewise', () => { - expect(rb.byName.get('P1')!.length).toBe(46); + it('B1–B4 and B5–B8 all exist in 3D (B2 was missing)', () => { + for (const n of ['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8']) { + expect(rb.byName.get(n)?.length ?? 0, n).toBeGreaterThan(0); + } + }); + + it('B2 legs reach the B1 height (1873 above the footing bottom), B1 legs hang 304.8 below B1', () => { + const f = buildPier(loadP1()).footings[0]; + const box = (name: string) => { + const b = new THREE.Box3(); + for (const mesh of rb.byName.get(name)!) b.expandByObject(mesh); + return b; + }; + expect(box('B2').max.y - f.bottom).toBeCloseTo(1.873, 2); + expect(box('B1').min.y - f.bottom).toBeCloseTo(1.873 - 0.3048, 2); + }); + + it('C12 / C13 in 3D: per row two 일자 and two ㄷ (body + 2 legs each end)', () => { + expect(rb.byName.get('C12')!.length).toBe(5 * (2 + 2 * 3)); + expect(rb.byName.get('C13')!.length).toBe(2 * (2 + 2 * 3)); + }); + + it('C1 legs turn down 472 at the ends (to EL 42.812 at the end face)', () => { + const model = buildPier(loadP1()); + const b = new THREE.Box3(); + for (const mesh of rb.byName.get('C1')!) b.expandByObject(mesh); + expect(b.min.y).toBeCloseTo(model.copingRefTop - 0.127 - 0.472, 2); + }); + + it('C9 / C10 / C11: three 1740-wide 폐합 per position, each a U plus a cap (18 pieces each)', () => { + expect(rb.byName.get('C10')!.length).toBe(9 * 18); + expect(rb.byName.get('C9')!.length).toBe(18 * 18); + expect(rb.byName.get('C11')!.length).toBe(18 * 18); + }); + + it('C7 / C8 follow the underside: lowest at the Level (+127 − r), vertical legs up to the C1 level', () => { + const model = buildPier(loadP1()); + const b = new THREE.Box3(); + for (const n of ['C7', 'C8']) for (const mesh of rb.byName.get(n)!) b.expandByObject(mesh); + const r = 28.6 / 2000; + expect(b.min.y).toBeCloseTo(model.levelBottom + 0.127 - r, 2); + // Vertical legs end flat, so no radius on top. C1 is the top row at copingRefTop − 0.127. + expect(b.max.y).toBeCloseTo(model.copingRefTop - 0.127, 2); + }); + + it('P1 has one bar per 원주 등분 (46) plus its outward hook tail, P2 one per 등분 (46)', () => { + expect(rb.byName.get('P1')!.length).toBe(46 * 2); expect(rb.byName.get('P2')!.length).toBe(46); }); + it('P6 crossties in 3D: 8 levels × 4 bars each with main bar + 2 downward hooks (3 pieces per bar = 96 pieces)', () => { + expect(rb.byName.get('P6')!.length).toBe(8 * 4 * 3); + }); + it('highlight dims the other bars and keeps the chosen one', () => { rb.highlight('P3'); const p3 = (rb.byName.get('P3')![0].material as THREE.MeshStandardMaterial).opacity; @@ -28,3 +77,24 @@ describe('3D 철근 모델 (③)', () => { expect((rb.byName.get('C1')![0].material as THREE.MeshStandardMaterial).opacity).toBe(1); }); }); + +describe('받침블럭 S1 · S2 in 3D (받침블럭철근.md §3–5)', () => { + it('per block: 8 S1 and 8 S2, each a bar with two 700 legs (3 pieces)', () => { + const blocks = buildPier(loadP1()).blocks.length; + expect(rb.byName.get('S1')!.length).toBe(blocks * 8 * 3); + expect(rb.byName.get('S2')!.length).toBe(blocks * 8 * 3); + }); +}); + +describe('확대기초 B9 · B10 · B11 · B12 in 3D (확대기초철근 (1).md §7–9)', () => { + it('B9: 306 vertical bars (column zone removed), each three pieces (bar + two hook tails)', () => { + expect(rb.byName.get('B9')!.length).toBe(306 * 3); + }); + + it('B10-1 / B10-2: 18 each (9 levels × 2 faces), each three pieces; B11 60, B12 47', () => { + expect(rb.byName.get('B10-1')!.length).toBe(18 * 3); + expect(rb.byName.get('B10-2')!.length).toBe(18 * 3); + expect(rb.byName.get('B11')!.length).toBe(60 * 3); + expect(rb.byName.get('B12')!.length).toBe(47 * 3); + }); +});