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:
1 parent
b75f3d4855
commit
a2d0d6057f
2 files changed
+240
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user