From a2d0d6057f282b98ebf2f9e65edcb0ea2492e0af Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 5 Nov 2026 09:36:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut-rebar):=20=EA=B5=90=EB=8C=80=20?= =?UTF-8?q?=EB=B0=9B=EC=B9=A8=EB=B8=94=EB=9F=AD=20=EB=B0=B0=EA=B7=BC=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20S1=20=C2=B7=20S2=20(AD6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 받침마다 1개 블럭 (1200 × 1200, 높이 125 − 패드 50 = 콘크리트 75). 본수 · 다리 · 여유는 교각 규칙(barsAcross · barLength) 재사용, 깊이만 교대 H13으로 계산 - 묻힘 S2 675 · S1 688, 길이 2500, 블럭당 8본 (40 · 40). 블럭 방위각 회전 - 간섭 (Q-S4, 결정 2026-11-05): S1 다리가 W2 층을 지나면 RB-AK01 경고만 - 테스트: 교대 기획서 §10 값, 간섭 경고, 간격 수정, 회전 Co-Authored-By: Claude Haiku 5.5 --- src/core/abut/rebar/block.ts | 188 +++++++++++++++++++++++++++++++++ tests/abut-rebar-block.test.ts | 52 +++++++++ 2 files changed, 240 insertions(+) create mode 100644 src/core/abut/rebar/block.ts create mode 100644 tests/abut-rebar-block.test.ts diff --git a/src/core/abut/rebar/block.ts b/src/core/abut/rebar/block.ts new file mode 100644 index 0000000..3109576 --- /dev/null +++ b/src/core/abut/rebar/block.ts @@ -0,0 +1,188 @@ +/** + * 교대 받침블럭 배근 · AD6 (2026-11-05, 받침블럭철근.md §6.6 · 교대 기획서 §10, 사용자 결정 2026-11-05). 순수 함수, 길이는 mm. + * + * - 배치: 받침마다 블럭 1개 (분리). 블럭 평면 1200 × 1200, 몰탈패드 50 포함 높이 125 → 콘크리트 75. + * - S1 (아래, 교대축 직각 간격, 교대축 방향으로 뻗음), S2 (위, 교대축 간격, 교대축 직각으로 뻗음). 규격 H13 (기획서 §6.6). + * - 본수 · 다리 · 여유는 교각 받침블럭 규칙(`bearing-block.ts`의 barsAcross · barLength)을 그대로 쓴다. 교각과 달리 깊이만 + * 교대 규격(H13)으로 계산한다: S2 = 상면 피복, S1 = 상면 피복 + S1/2 + S2/2 (H13 두 개 = 62.7). + * - 다리 길이 700은 철근 중심에서 아래로. 묻힘 = 다리 − (블럭 콘크리트 높이 − 깊이): S2 675, S1 688. + * - 경고만 (사용자 결정 2026-11-05): S1 다리가 벽체 W2 층(교좌면 − c)을 지나면 RB-AK01 (Q-S4, 확인 후 결정 기록). + * - 좌표: 교대 (s, t, z) mm — s 교대축, t 전면 +, z 저판 하면에서. 블럭은 받침 방위각으로 돌려 놓는다 (abut-scene과 같은 회전). + */ +import { barLength, barsAcross, DEFAULT_BLOCK_REBAR, type BarRow, type BlockRebarSettings } from '../../rebar/bearing-block'; +import { NOMINAL_DIAMETER, type BarSize } from '../../rebar/common'; +import type { Issue } from '../../types'; + +export type AbutBlockSettings = BlockRebarSettings; + +/** 교대 블럭 규격 (기획서 §6.6): S1 · S2 모두 H13. */ +export const ABUT_BLOCK_SIZES = { S1: 'H13', S2: 'H13' } as const satisfies Record; + +/** 몰탈패드 높이 (mm): 블럭 높이 125에 포함, 콘크리트 높이 = 블럭 높이 − 50. */ +export const ABUT_BLOCK_PAD_MM = 50; + +/** 기본값 = 교각 P1 값 (받침블럭철근.md): 상면 피복 50 · 측면 50 · 여유 75 · @125 · 다리 700. */ +export const ABUT_BLOCK_REBAR_DEFAULT: AbutBlockSettings = DEFAULT_BLOCK_REBAR; + +export function resolveBlockRebar(changes?: Partial): AbutBlockSettings { + return { ...ABUT_BLOCK_REBAR_DEFAULT, ...(changes ?? {}) }; +} + +export function blockRebarIssues(s: AbutBlockSettings): string[] { + const out: string[] = []; + for (const key of ['coverTop', 'coverSide', 'firstGap', 'spacingS1', 'spacingS2', 'legLength'] as const) { + if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`); + } + return out; +} + +/** 블럭 상면 ~ 철근 중심 (mm): S2 = 상면 피복, S1 = 상면 피복 + S1/2 + S2/2 (교대 H13). */ +export function abutBarDepths(s: AbutBlockSettings): { S1: number; S2: number } { + const h = NOMINAL_DIAMETER[ABUT_BLOCK_SIZES.S1]; + return { S2: s.coverTop, S1: s.coverTop + h / 2 + h / 2 }; +} + +/** 한 받침 블럭의 배근 (블럭 콘크리트 높이 mm). */ +export function abutBlockBars(concreteH: number, s: AbutBlockSettings = ABUT_BLOCK_REBAR_DEFAULT): BarRow[] { + const depth = abutBarDepths(s); + const length = barLength(1200, s); + return [ + { name: 'S1', ...barsAcross(1200, s.spacingS1, s), length, embedment: s.legLength - (concreteH - depth.S1) }, + { name: 'S2', ...barsAcross(1200, s.spacingS2, s), length, embedment: s.legLength - (concreteH - depth.S2) }, + ]; +} + +export interface AbutBlockInput { + no: number; + /** 교대축 위치 (m, IP1 기준), 받침 전면 쪽 t (m), 방위각 회전 (deg), 교좌면 EL (m), 블럭 높이 (mm, 패드 포함). */ + s: number; + t: number; + rotDeg: number; + seatEl: number; + blockHeightMm: number; +} + +export interface AbutBlockBar { + mark: 'S1' | 'S2'; + size: BarSize; + role: '주철근' | '보조'; + spacing: string; + actual: string; + shape: string; + length: number; + count: number; + embedment: number; + position: string; + note: string; + basis: '규칙' | '가정'; + modified: boolean; +} + +/** One bar as a polyline in (s, t, z) mm from the 저판 하면 (z = seatEl − bottom + local height). */ +export interface AbutBlockPath { + mark: 'S1' | 'S2'; + block: number; + pts: [number, number, number][]; +} + +export interface AbutBlockRebar { + settings: AbutBlockSettings; + concreteH: number; + depth: { S1: number; S2: number }; + embedment: { S1: number; S2: number }; + paths: AbutBlockPath[]; + bars: AbutBlockBar[]; + issues: Issue[]; +} + +/** W2 층 (교좌면 − c, H16 철근 중심 깊이): 간섭 판정에 쓴다 (wall.ts W2, 교대벽체철근.md §5.1). */ +export const W2_LAYER_DEPTH_MM = (wallCover: number) => wallCover + NOMINAL_DIAMETER.H16 / 2; + +export function abutBlockRebar( + blocks: AbutBlockInput[], + bottomEl: number, + changes?: Partial, + wallCover = 100, + who = '', +): AbutBlockRebar { + const s = resolveBlockRebar(changes); + const issues: Issue[] = []; + const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message }); + if (s.spacingS1 !== ABUT_BLOCK_REBAR_DEFAULT.spacingS1 || s.spacingS2 !== ABUT_BLOCK_REBAR_DEFAULT.spacingS2) { + add('RB-AK03', 'I', '블럭 철근 간격을 기본간격과 따로 고쳤습니다. [되돌리기]로 다시 연동합니다.'); + } + const concreteH = (blocks[0]?.blockHeightMm ?? 125) - ABUT_BLOCK_PAD_MM; + const rows = abutBlockBars(concreteH, s); + const depth = abutBarDepths(s); + const embedment = { S1: rows[0].embedment, S2: rows[1].embedment }; + const paths: AbutBlockPath[] = []; + const half = 1200 / 2; + const straight = 1200 - 2 * s.coverSide; + const h = straight / 2; + const yS1 = concreteH - depth.S1; // 블럭 밑면 위 높이 + const yS2 = concreteH - depth.S2; + for (const b of blocks) { + const cos = Math.cos((b.rotDeg * Math.PI) / 180); + const sin = Math.sin((b.rotDeg * Math.PI) / 180); + const sb = b.s * 1000; + const tb = b.t * 1000; + const zb = (b.seatEl - bottomEl) * 1000; + // 블럭 지역 (x 교대축, zt 교대축 직각, y 높이) → 교대 (s, t, z): abut-scene의 rotateY와 같은 회전. + const toAbut = (x: number, y: number, zt: number): [number, number, number] => [sb + x * cos + zt * sin, tb - x * sin + zt * cos, zb + y]; + const side = (key: 'S1' | 'S2') => (key === 'S1' ? rows[0] : rows[1]); + // S1: 교대축 방향으로 뻗고, 교대축 직각 간격으로 놓인다. + const r1 = side('S1'); + for (let i = 0; i < r1.count; i++) { + const zc = r1.firstEnd + i * r1.spacing - half; // 블럭 가운데 기준 (firstEnd는 블럭 모서리부터) + paths.push({ mark: 'S1', block: b.no, pts: [toAbut(-h, yS1 - s.legLength, zc), toAbut(-h, yS1, zc), toAbut(h, yS1, zc), toAbut(h, yS1 - s.legLength, zc)] }); + } + // S2: 교대축 직각으로 뻗고, 교대축 간격으로 놓인다 (위 층). + const r2 = side('S2'); + for (let i = 0; i < r2.count; i++) { + const x = r2.firstEnd + i * r2.spacing - half; + paths.push({ mark: 'S2', block: b.no, pts: [toAbut(x, yS2 - s.legLength, -h), toAbut(x, yS2, -h), toAbut(x, yS2, h), toAbut(x, yS2 - s.legLength, h)] }); + } + } + + // 간섭 (Q-S4, 결정 2026-11-05 경고만): S1 다리 아래끝이 W2 층 (교좌면 − c − 철근 중심)보다 아래로 내려가면 경고. + const w2 = W2_LAYER_DEPTH_MM(wallCover); + if (embedment.S1 > w2) { + add('RB-AK01', 'W', `블럭 S1 다리(묻힘 ${Math.round(embedment.S1)})가 벽체 W2 층(교좌면 − ${Math.round(w2)})을 지납니다 (Q-S4, 경고만 — 확인 후 결정을 기록).`); + } + + const bars: AbutBlockBar[] = []; + const count = (mark: 'S1' | 'S2') => blocks.length * (mark === 'S1' ? rows[0].count : rows[1].count); + const spacingText = (mark: 'S1' | 'S2') => `${(mark === 'S1' ? s.spacingS1 : s.spacingS2)}`; + const shape = (mark: 'S1' | 'S2') => `직선 ${Math.round(straight)} + 아래 다리 ${s.legLength} × 2 (${mark === 'S1' ? '교대축 방향' : '교대축 직각'})`; + bars.push({ + mark: 'S1', + size: ABUT_BLOCK_SIZES.S1, + role: '주철근', + spacing: `${spacingText('S1')}`, + actual: spacingText('S1'), + shape: shape('S1'), + length: rows[0].length, + count: count('S1'), + embedment: embedment.S1, + position: `블럭 아래 (상면 ${Math.round(depth.S1)}) · 교대축 직각 간격 · 블럭 ${blocks.length}개 × ${rows[0].count}`, + note: `묻힘 ${Math.round(embedment.S1)} (교좌면 아래) · 블럭 콘크리트 ${concreteH}`, + basis: '규칙', + modified: s.spacingS1 !== ABUT_BLOCK_REBAR_DEFAULT.spacingS1, + }); + bars.push({ + mark: 'S2', + size: ABUT_BLOCK_SIZES.S2, + role: '보조', + spacing: `${spacingText('S2')}`, + actual: spacingText('S2'), + shape: shape('S2'), + length: rows[1].length, + count: count('S2'), + embedment: embedment.S2, + position: `블럭 위 (상면 ${Math.round(depth.S2)}) · 교대축 간격 · 블럭 ${blocks.length}개 × ${rows[1].count}`, + note: `묻힘 ${Math.round(embedment.S2)} · 블럭 상면 쪽 (교좌면 아래)`, + basis: '규칙', + modified: s.spacingS2 !== ABUT_BLOCK_REBAR_DEFAULT.spacingS2, + }); + return { settings: s, concreteH, depth, embedment, paths, bars, issues }; +} diff --git a/tests/abut-rebar-block.test.ts b/tests/abut-rebar-block.test.ts new file mode 100644 index 0000000..3eebb0d --- /dev/null +++ b/tests/abut-rebar-block.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { abutBlockRebar, abutBarDepths } from '../src/core/abut/rebar/block'; + +// 교대 받침블럭 (교대 기획서 §10 · 받침블럭철근.md §6.6): 블럭 5개, 1200 × 1200, 높이 125 (패드 50 포함, 콘크리트 75). +const blocks = [1, 2, 3, 4, 5].map((no, i) => ({ no, s: 1 + i * 2, t: 0.2, rotDeg: 0, seatEl: 44.367, blockHeightMm: 125 })); +const bottom = 40.895; +const A1 = abutBlockRebar(blocks, bottom); +const bar = (r: typeof A1, m: 'S1' | 'S2') => r.bars.find((b) => b.mark === m)!; + +describe('교대 받침블럭 배근 A1 (AD6, 교대 기획서 §10)', () => { + it('블럭 콘크리트 75 (블럭 125 − 패드 50), 깊이: S2 50 · S1 62.7 (H13 두 개)', () => { + expect(A1.concreteH).toBe(75); + expect(abutBarDepths(A1.settings)).toEqual({ S2: 50, S1: 50 + 6.35 + 6.35 }); + }); + + it('본수: 블럭마다 8본 (7@125, 끝 162.5), 5개 → S1 40 · S2 40', () => { + expect(bar(A1, 'S1').count).toBe(40); + expect(bar(A1, 'S2').count).toBe(40); + expect(A1.paths.filter((p) => p.mark === 'S1' && p.block === 1).length).toBe(8); + // 블럭 1 (s = 1000 mm): S2 본은 블럭 모서리에서 162.5, 125 간격 → 첫 본 s = 1000 − 600 + 162.5 = 562.5. + const s2 = A1.paths.filter((p) => p.mark === 'S2' && p.block === 1).map((p) => p.pts[0][0]).sort((a, b) => a - b); + expect(s2[0]).toBeCloseTo(562.5, 6); + expect(s2[1] - s2[0]).toBeCloseTo(125, 6); + expect(s2.length).toBe(8); + }); + + it('길이 2500 (1100 직선 + 다리 700 × 2), 묻힘: S2 675 · S1 688', () => { + expect([bar(A1, 'S1').length, bar(A1, 'S2').length]).toEqual([2500, 2500]); + expect(Math.round(A1.embedment.S2)).toBe(675); + expect(Math.round(A1.embedment.S1)).toBe(688); + }); + + it('간섭 (Q-S4, 경고만): S1 다리가 W2 층을 지남 → RB-AK01 (W), 다른 오류 없음', () => { + const codes = A1.issues.map((i) => `${i.code}:${i.level}`); + expect(codes).toContain('RB-AK01:W'); + expect(A1.issues.filter((i) => i.level === 'E')).toEqual([]); + }); + + it('간격 수정 → RB-AK03 "수정됨"', () => { + const r = abutBlockRebar(blocks, bottom, { spacingS1: 150 }); + expect(r.issues.map((i) => i.code)).toContain('RB-AK03'); + expect(bar(r, 'S1').modified).toBe(true); + expect(bar(r, 'S2').modified).toBe(false); + }); + + it('회전 (방위각): 블럭 축이 돌면 경로 점도 같이 돈다 (교대축 방향 길이 보존)', () => { + const r = abutBlockRebar([{ ...blocks[0], rotDeg: 30 }], bottom); + const p = r.paths.find((q) => q.mark === 'S1')!; + const len = Math.hypot(p.pts[2][0] - p.pts[1][0], p.pts[2][1] - p.pts[1][1]); + expect(len).toBeCloseTo(1100, 6); + }); +});