feat(abut-rebar): 교대 받침블럭 배근 규칙 S1 · S2 (AD6)

- 받침마다 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 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-05 09:36:53 +09:00
1 parent b75f3d4855
commit a2d0d6057f
2 files changed
+240

No files matched your search

+188
View File
@@ -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<string, BarSize>;
/** 몰탈패드 높이 (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>): 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<AbutBlockSettings>,
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 };
}
+52
View File
@@ -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);
});
});