diff --git a/src/core/rebar/footing.ts b/src/core/rebar/footing.ts index 80ed026..8528c9e 100644 --- a/src/core/rebar/footing.ts +++ b/src/core/rebar/footing.ts @@ -138,19 +138,28 @@ export function assemblyEndOffset(): number { } /** B11 (along W, between the B10-2 levels) and B12 (along L), 4s apart with a 2s stagger per level (§8). */ +/** + * Grid indices of the assembly bars (B11 or B12) on one level, centred in the grid (승인 2026-10-26, 추천안). + * Bars every k steps; the level's 2-step stagger alternates (even levels start at e0, odd levels at e0 + 2), and e0 centres + * the even group so the left and right gaps differ by at most one step. n = positions − 1; idx = level index (0, 1, …). + */ +export function assemblyGridIndices(n: number, k: number, idx: number): number[] { + const cEven = Math.floor(n / k) + 1; + const cOdd = Math.floor((n - 2) / k) + 1; + const e0 = Math.max(0, Math.floor((n - (cEven - 1) * k - 2) / 2)); + const even = idx % 2 === 0; + const start = even ? e0 : e0 + 2; + const count = even ? cEven : cOdd; + return Array.from({ length: count }, (_, m) => start + m * k).filter((i) => i <= n); +} + export function assemblyBars(L: number, W: number, H: number, f: FootingRebarSettings = DEFAULT_FOOTING_REBAR): FootingBar[] { const levelsJ = assemblyJs(H, f); - // Bars every k grid steps (승인 2026-10-26: k·s); the levels alternate a 2-step stagger (엇갈림 2s). - const perLevel = (n: number, k: number) => - levelsJ.map((_, idx) => { - const i0 = idx % 2 === 0 ? 0 : 2; - return Math.floor((n - i0) / k) + 1; - }); // B11 is spread along L (교각축 직각 grid) and B12 along W (교각축 grid), per 확대기초철근 (1).md §8. const nW = grid(W, f, 'sAxis').positions - 1; const nL = grid(L, f, 'sPerp').positions - 1; - const b11 = perLevel(nL, f.kB11).reduce((a, b) => a + b, 0); - const b12 = perLevel(nW, f.kB12).reduce((a, b) => a + b, 0); + const b11 = levelsJ.reduce((a, _, idx) => a + assemblyGridIndices(nL, f.kB11, idx).length, 0); + const b12 = levelsJ.reduce((a, _, idx) => a + assemblyGridIndices(nW, f.kB12, idx).length, 0); 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, f); const depth2 = sideDepth(NOMINAL_DIAMETER.S25, NOMINAL_DIAMETER.S19, f); diff --git a/src/view/footing-section-svg.ts b/src/view/footing-section-svg.ts index e372bd8..9787cbe 100644 --- a/src/view/footing-section-svg.ts +++ b/src/view/footing-section-svg.ts @@ -1,6 +1,6 @@ import type { PierModel } from '../core/geometry'; import { NOMINAL_DIAMETER } from '../core/rebar/common'; -import { assemblyJs, b10Levels, b9Bars, grid, gridPositions, layers, sideDepth } from '../core/rebar/footing'; +import { assemblyGridIndices, assemblyJs, b10Levels, b9Bars, grid, gridPositions, layers, sideDepth } from '../core/rebar/footing'; import { DEFAULT_FOOTING_REBAR } from '../core/rebar/footing-settings'; /** @@ -83,9 +83,8 @@ export function footingSectionSvg(model: PierModel): string { js.forEach((j, idx) => { const zA = fs.cBottom + j * fs.sV; parts.push(line('b11', 'B11', xa, xb, y(zA), COLOR.b11, 1.2)); - const i0 = idx % 2 === 0 ? 0 : 2; const xsB12 = gridPositions(W, fs, 'sAxis'); - for (let i = i0; i < xsB12.length; i += fs.kB12) parts.push(dot('b12', 'B12', x(xsB12[i]), y(zA + dB10), COLOR.b12, 2.2)); + for (const i of assemblyGridIndices(xsB12.length - 1, fs.kB12, idx)) parts.push(dot('b12', 'B12', x(xsB12[i]), y(zA + dB10), COLOR.b12, 2.2)); }); // Dimensions: width, grid, covers, and the B10 levels. diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index 4a8f6d0..58bfb5c 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -1,6 +1,6 @@ import * as THREE from 'three'; import type { PierModel } from '../core/geometry'; -import { assemblyEndOffset, assemblyJs, b10Levels, b9Bars, gridPositions, grid, layers, legLength, legOffsets, sideDepth } from '../core/rebar/footing'; +import { assemblyEndOffset, assemblyGridIndices, assemblyJs, b10Levels, b9Bars, gridPositions, grid, layers, legLength, legOffsets, sideDepth } from '../core/rebar/footing'; import { bottomPiece, MAIN, topSplicePieces, 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'; @@ -478,12 +478,11 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, assemblyJs(Hf, fs).forEach((jA, k) => { const zA = bottom + mm(fs.cBottom + jA * fs.sV); const zB = zA + dB10; - const i0 = k % 2 === 0 ? 0 : 2; - for (let iL = i0; iL < xsL.length; iL += fs.kB11) { + for (const iL of assemblyGridIndices(xsL.length - 1, fs.kB11, k)) { 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 += fs.kB12) { + for (const iW of assemblyGridIndices(ysW.length - 1, fs.kB12, k)) { 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); } diff --git a/tests/footing-section-svg.test.ts b/tests/footing-section-svg.test.ts index 0277f9d..05315e3 100644 --- a/tests/footing-section-svg.test.ts +++ b/tests/footing-section-svg.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; -import { assemblyBars, assemblyJs, b9Bars, bottomBars, crossBars, sideBars } from '../src/core/rebar/footing'; +import { assemblyBars, assemblyGridIndices, assemblyJs, b9Bars, bottomBars, crossBars, sideBars } from '../src/core/rebar/footing'; import { DEFAULT_FOOTING_REBAR } from '../src/core/rebar/footing-settings'; import { footingSectionSvg } from '../src/view/footing-section-svg'; import { loadP1 } from './helpers'; @@ -38,4 +38,15 @@ describe('확대기초 교각축 직각 단면 (폭 W) (승인 2026-10-26, 추 it('has one bubble per highlighted bar family', () => { for (const name of ['B1', 'B2', 'B3', 'B4', 'B5', 'B11', 'B10-1']) expect(svg).toContain(`data-bubble="${name}"`); }); + + it('조립 철근은 격자 가운데에 놓임: 양끝 여유 차이 ≤ 1칸 (B11 n 79, B12 n 61, k 8, 엇갈림 2칸 유지)', () => { + const levels = assemblyJs(H, fs).length; + for (const [n, k] of [[79, 8], [61, 8]] as const) { + const all = Array.from({ length: levels }, (_, idx) => assemblyGridIndices(n, k, idx)).flat(); + const left = Math.min(...all); + const right = n - Math.max(...all); + expect(Math.abs(left - right)).toBeLessThanOrEqual(1); + } + expect(assemblyGridIndices(61, 8, 1)[0] - assemblyGridIndices(61, 8, 0)[0]).toBe(2); + }); });