From 43a18182f3d86b0c0de676d72601a325835f78a0 Mon Sep 17 00:00:00 2001 From: ypshim Date: Mon, 26 Oct 2026 12:02:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(bearing):=20=EC=9D=BC=EC=B2=B4=20block=20a?= =?UTF-8?q?s=20one=20stepped=20block=20per=20pair,=20with=20its=20rebar=20?= =?UTF-8?q?(=EC=8A=B9=EC=9D=B8=202026-10-26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - integrated-block.ts: per pair (전열 k · 후열 k) one block centred on the pair midpoint, 2500 across the 교축 and 1200 along the 교각축, with a stepped top (each half to its own bearing row top). Only when 일체 is chosen and every pair is within 100 mm; otherwise 분리 is kept (B02). - Rebar: S2 19 bars at 125 across the 2500 (10 front half, 9 back half), each leg from its half's top; S1 8 bars across the 1200, 3800 long, depth from the lower top. Totals per pier: S1 40, S2 95 (분리: 80 / 80). - Scene and 3D rebar draw the stepped blocks instead of the per-bearing blocks. - The per-bearing blocks stay for the solplate, the pads, and the body. Co-Authored-By: Claude Haiku 5.5 --- src/core/geometry.ts | 4 ++ src/core/integrated-block.ts | 47 +++++++++++++++++++ src/core/rebar/bearing-block.ts | 29 ++++++++++++ src/core/validate.ts | 4 +- src/view/rebar3d.ts | 29 ++++++++++++ src/view/scene.ts | 26 +++++++++-- tests/integrated-block.test.ts | 83 +++++++++++++++++++++++++++++++++ 7 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 src/core/integrated-block.ts create mode 100644 tests/integrated-block.test.ts diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 7040f65..f3becea 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -1,4 +1,5 @@ import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis'; +import { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block'; import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; import type { PierInput, Row } from './types'; import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; @@ -170,6 +171,8 @@ export interface PierModel { columnDiameterMm: number; bearings: BearingLocal[]; blocks: BlockModel[]; + /** 일체 블록 (승인 2026-10-26): empty unless 일체 is chosen and allowed. The per-bearing blocks stay for the solplate and pads. */ + integratedBlocks: IntegratedBlock[]; columns: ColumnModel[]; footings: FootingModel[]; blindings: BlindingModel[]; @@ -456,6 +459,7 @@ export function buildPier(input: PierInput): PierModel { columnDiameterMm: colD * 1000, 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 === '일체'), columns, footings, blindings, diff --git a/src/core/integrated-block.ts b/src/core/integrated-block.ts new file mode 100644 index 0000000..c056ab6 --- /dev/null +++ b/src/core/integrated-block.ts @@ -0,0 +1,47 @@ +import type { BlockModel } from './geometry'; +import { integratedCheck, pairEccentricities } from './bearing-layout'; + +/** + * 일체 블록 (승인 2026-10-26): one block per front–back pair, centred on the pair's midpoint. + * Length 2500 across the 교축 (n) covers the pair (C1 600 + C2 1298 + C1 600), width 1200 along the 교각축 (s). + * The top is stepped: the front half follows the front block top, the back half the back block top (단이 있는 블록). + * Only built when every pair is within the eccentricity limit; otherwise the model keeps 분리 (see integratedCheck). + */ +export const INTEGRATED_BLOCK = { length: 2500, depth: 1200 } as const; + +export interface IntegratedBlock { + frontNo: number; + backNo: number; + /** Centre along the coping length (s, m) — the mean of the pair. */ + s: number; + /** Centre across the 교축 (n, m) — the midpoint of the pair; the step line is here. */ + n: number; + /** Bottom EL (m): the lower of the two bottoms. */ + bottom: number; + /** Top EL of the front half (전열 side, m). */ + topFront: number; + /** Top EL of the back half (후열 side, m). */ + topBack: number; + /** Mean row skew (deg) of the pair. */ + rowSkewDeg: number; +} + +/** Integrated blocks of a pier: empty unless 일체 is chosen and every pair is within the limit. */ +export function buildIntegratedBlocks(blocks: BlockModel[], chosen: boolean): IntegratedBlock[] { + if (!chosen || !integratedCheck(blocks).allowed) return []; + const pick = (no: number, row: BlockModel['row']) => blocks.find((b) => b.no === no && b.row === row)!; + return pairEccentricities(blocks).map((p) => { + const f = pick(p.frontNo, '전열'); + const b = pick(p.backNo, '후열'); + return { + frontNo: p.frontNo, + backNo: p.backNo, + s: (f.s + b.s) / 2, + n: (f.n + b.n) / 2, + bottom: Math.min(f.bottom, b.bottom), + topFront: f.top, + topBack: b.top, + rowSkewDeg: (f.rowSkewDeg + b.rowSkewDeg) / 2, + }; + }); +} diff --git a/src/core/rebar/bearing-block.ts b/src/core/rebar/bearing-block.ts index 877393c..e5f01fd 100644 --- a/src/core/rebar/bearing-block.ts +++ b/src/core/rebar/bearing-block.ts @@ -1,4 +1,5 @@ import type { PierModel } from '../geometry'; +import { INTEGRATED_BLOCK, type IntegratedBlock } from '../integrated-block'; /** * 받침블럭 철근 (spec §6.3 · plan C2), 배치 분리: one block per bearing, 1200 × 1200. @@ -64,6 +65,10 @@ export interface BlockRebar { * 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[] { + // 일체 (승인 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, @@ -71,6 +76,30 @@ export function bearingBlockRebar(model: PierModel): BlockRebar[] { })); } +/** + * 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[] { + const half = INTEGRATED_BLOCK.length / 2; + const s2 = barsAcross(INTEGRATED_BLOCK.length); + 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 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) }, + ]; + 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) }); + return rows; +} + /** 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/validate.ts b/src/core/validate.ts index 2522e4b..5877960 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -94,14 +94,14 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[] validateGround(input, issues); validatePileEmbedment(input, model, issues); validateStrataNValues(input, issues); - // 일체 (승인 2026-10-26): the block shape of 일체 is not modelled yet, so the model keeps 분리 and says so. + // 일체 (승인 2026-10-26): the model builds one stepped block per pair when allowed; otherwise it keeps 분리. if (input.bearingLayout === '일체') { const check = integratedCheck(model.blocks); if (!check.allowed) { const worst = check.overLimit.map((p) => `전열 ${p.frontNo}·후열 ${p.backNo} ${p.eccentricityMm.toFixed(0)} mm`).join(', '); issues.push({ code: 'B02', level: 'W', pier: input.name, message: `일체를 골랐지만 편심이 100 mm를 넘는 쌍이 있어 분리로 계산합니다 (${worst || '쌍 없음'}).` }); } else { - issues.push({ code: 'B01', level: 'I', pier: input.name, message: '일체 배치: 블록 형상은 아직 분리 기준으로 계산합니다 (일체 블록 모델은 다음 단계).' }); + issues.push({ code: 'B01', level: 'I', pier: input.name, message: '일체 배치: 전열·후열 쌍마다 단이 있는 블록 (2500 × 1200)으로 계산합니다.' }); } } } diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index 55e6663..b81b1af 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -7,6 +7,7 @@ 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 { 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'; import { diameterGroupIndex, DIAMETER_GROUPS, memberOfBar, type RebarMemberKey } from '../core/rebar/color'; import { stretchMatrix } from './scene'; @@ -474,6 +475,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, 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 P = (a: number, c: number, y: number): [number, number, number] => { @@ -490,6 +492,33 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, } } + // 일체 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). + 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 }; + const P = (a: number, c: number, y: number): [number, number, number] => { + 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; + 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; + 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); + } + for (const off of Array.from({ length: acrossS.count }, (_, i) => (acrossS.firstEnd + i * acrossS.spacing) / 1000 - INTEGRATED_BLOCK.depth / 2000)) { + addBar('S1', k, P(off, -halfN, z1), P(off, halfN, z1), dBar, COLOR.block); + addBar('S1', k, P(off, -halfN, z1 - legM), P(off, -halfN, z1), dBar, COLOR.block); + addBar('S1', k, P(off, halfN, z1), P(off, halfN, z1 - legM), dBar, COLOR.block); + } + } + const show = (names: ReadonlySet | null, isolate: boolean) => { // 커플러 as a name selects every coupler bar. const isOn = (key: string) => names === null || names.has(key) || (names.has('커플러') && key.includes('커플러')); diff --git a/src/view/scene.ts b/src/view/scene.ts index c2b6345..30a209f 100644 --- a/src/view/scene.ts +++ b/src/view/scene.ts @@ -3,6 +3,7 @@ import type { PierModel } from '../core/geometry'; import { solplateGeometry } from '../core/solplate'; import type { TerrainGrid } from '../core/ground-model'; import { terrainGeometry } from './terrain'; +import { INTEGRATED_BLOCK } from '../core/integrated-block'; export type PartName = 'coping' | 'columns' | 'footings' | 'piles' | 'blinding' | 'blocks' | 'pads' | 'bearings' | 'solplates' | 'ground'; @@ -131,14 +132,18 @@ export function buildPierScene(m: PierModel, terrain?: TerrainGrid): PierScene { add('piles', g, 1, p.k, planesOf(m, p.k)); } + // 일체 (승인 2026-10-26): the per-bearing concrete blocks give way to one stepped block per pair (below). + const integrated = m.integratedBlocks.length > 0; for (const b of m.blocks) { // Turn the block so its sides follow the bearing row (rowSkewDeg); the 3D turn is rotateY(−skew). const turn = (-b.rowSkewDeg * Math.PI) / 180; const h = b.top - b.bottom; - const g = new THREE.BoxGeometry(1.2, h, 1.2); - g.rotateY(turn); - g.translate(b.s, b.bottom + h / 2, b.n); - add('blocks', g, 1, b.k); + if (!integrated) { + const g = new THREE.BoxGeometry(1.2, h, 1.2); + g.rotateY(turn); + g.translate(b.s, b.bottom + h / 2, b.n); + add('blocks', g, 1, b.k); + } const pad = new THREE.BoxGeometry(1.0, 0.05, 1.0); pad.rotateY(turn); pad.translate(b.s, b.padTop - 0.025, b.n); @@ -155,6 +160,19 @@ export function buildPierScene(m: PierModel, terrain?: TerrainGrid): PierScene { } } + for (const ib of m.integratedBlocks) { + const k = m.blocks.find((b) => b.no === ib.frontNo && b.row === '전열')!.k; + const turn = (-ib.rowSkewDeg * Math.PI) / 180; + // Two halves of 1250 across the 교축, each to its own top: the front half (lower n) and the back half. + for (const [top, zc] of [[ib.topFront, ib.n - INTEGRATED_BLOCK.length / 4000], [ib.topBack, ib.n + INTEGRATED_BLOCK.length / 4000]] as const) { + const h = top - ib.bottom; + const g = new THREE.BoxGeometry(INTEGRATED_BLOCK.depth / 1000, h, INTEGRATED_BLOCK.length / 2000); + g.rotateY(turn); + g.translate(ib.s, ib.bottom + h / 2, zc); + add('blocks', g, 1, k); + } + } + // Ground: the terrain grid around the piers when the ground model has one (수 m 떨어진 지형까지 보임), // otherwise a translucent plane at the ground EL, used only to judge soil cover (not a computed input). if (terrain) { diff --git a/tests/integrated-block.test.ts b/tests/integrated-block.test.ts new file mode 100644 index 0000000..9bdbaab --- /dev/null +++ b/tests/integrated-block.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier, type BlockModel } from '../src/core/geometry'; +import { buildIntegratedBlocks, INTEGRATED_BLOCK } from '../src/core/integrated-block'; +import { bearingBlockRebar, integratedBlockBars, totals } from '../src/core/rebar/bearing-block'; +import { loadP1 } from './helpers'; + +const input = { ...loadP1(), bearingLayout: '일체' as const }; +const model = buildPier(input); + +describe('일체 블록 모델 (승인 2026-10-26)', () => { + it('P1 with 일체: one stepped block per pair (5), 2500 along the 교축 and 1200 along the 교각축', () => { + expect(model.integratedBlocks).toHaveLength(5); + expect(INTEGRATED_BLOCK).toEqual({ length: 2500, depth: 1200 }); + const first = model.integratedBlocks[0]; + expect(first.frontNo).toBe(1); + expect(first.backNo).toBe(6); + }); + + it('the block is centred on the pair midpoint, and its two halves keep their own tops (단이 있는 블록)', () => { + const ib = model.integratedBlocks[0]; + const f = model.blocks.find((b) => b.no === 1)!; + const b = model.blocks.find((x) => x.no === 6)!; + expect(ib.n).toBeCloseTo((f.n + b.n) / 2, 9); + expect(ib.s).toBeCloseTo((f.s + b.s) / 2, 9); + expect(ib.topFront).toBe(f.top); + expect(ib.topBack).toBe(b.top); + // the front and back tops differ on P1 (150 vs 200 mm), so the block is stepped + expect(Math.abs(ib.topFront - ib.topBack)).toBeGreaterThan(0.01); + }); + + it('the 2500 block covers the pair: the front and back bearings sit 600 mm inside each end (C1 600)', () => { + const ib = model.integratedBlocks[0]; + const f = model.blocks.find((b) => b.no === 1)!; + const b = model.blocks.find((x) => x.no === 6)!; + const half = INTEGRATED_BLOCK.length / 2000; + expect(ib.n - half).toBeCloseTo(f.n - 0.6, 2); + expect(ib.n + half).toBeCloseTo(b.n + 0.6, 2); + }); + + it('분리 keeps no integrated blocks', () => { + expect(buildPier(loadP1()).integratedBlocks).toEqual([]); + }); + + it('a staggered pair blocks 일체: no integrated blocks, the model stays 분리', () => { + const blocks = [{ no: 1, row: '전열', s: 1, n: -0.7, top: 0.5, bottom: 0, rowSkewDeg: 0 }, { no: 1, row: '후열', s: 1.2, n: 0.6, top: 0.5, bottom: 0, rowSkewDeg: 0 }] as unknown as BlockModel[]; + expect(buildIntegratedBlocks(blocks, true)).toEqual([]); + }); +}); + +describe('일체 블록 철근 (승인 2026-10-26, 받침블럭철근.md 일체 a 2500)', () => { + it('S2: 19 bars across the 2500 length, 10 on the front half and 9 on the back half (the centre bar on the front)', () => { + const rows = integratedBlockBars(model.integratedBlocks[0]).filter((r) => r.name === 'S2'); + expect(rows.reduce((s, r) => s + r.count, 0)).toBe(19); + expect(rows.map((r) => r.count)).toEqual([10, 9]); + expect(rows[0].length).toBe(2500); + }); + + it('S1: 8 bars across the 1200 depth, 3800 long (2500 − 100 + 2 × 700)', () => { + const s1 = integratedBlockBars(model.integratedBlocks[0]).find((r) => r.name === 'S1')!; + expect(s1.count).toBe(8); + expect(s1.length).toBe(3800); + }); + + it('a pier of five integrated blocks has S1 40 and S2 95 (per-bearing 분리 gives 80 and 80)', () => { + const t = totals(bearingBlockRebar(model)); + expect(t).toEqual({ S1: 40, S2: 95 }); + const sep = totals(bearingBlockRebar(buildPier(loadP1()))); + expect(sep).toEqual({ S1: 80, S2: 80 }); + }); +}); + +describe('일체 블록 3D 철근과 장면', () => { + it('the 3D rebar and the scene build for a 일체 pier, with the S2 bars counted per pair (19 each)', async () => { + const { buildRebar3D } = await import('../src/view/rebar3d'); + const { buildPierScene } = await import('../src/view/scene'); + const rb = buildRebar3D(model); + expect(rb.group.children.length).toBeGreaterThan(0); + const scene = buildPierScene(model, undefined); + // the blocks group holds the stepped halves (2 per pair) and no per-bearing blocks + expect(scene.parts.blocks.children.length).toBeGreaterThan(0); + expect(scene.parts.blocks.children.length).toBe(model.integratedBlocks.length * 2); + }); +});