feat(abut-rebar): 교대 흉벽 · 브라켓 배근 규칙 P1–P11 (AD5)

- P1 · P2 · P3 (벽체 연직 격자 −17.5 / −1.6 / −35), P4 · P5 (상단 경사와 나란함, 계단에서 끊음, P5 브라켓 상면 위 · 아래), P6 · P7 (한 칸 배수, 반 칸 엇갈림, 1/sin θ)
- P8 · P9 끝면 ㄷ형, P11 끝면 연직, P10 다웰 (슬래브 측면선 가운데 맞춤, P3 수평 다리 비켜감 Q-P5 → RB-AP06, 모따기 RB-AP05)
- 대표 단면 = 흉벽 높이가 입력값(2500)인 곳 (흉벽 상단선이 받침 ① · 마지막 받침으로 정해져 IP3 끝은 2468)
- D25 규격 추가. 테스트: 교대흉벽철근.md §11 (fck 27)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 17:01:32 +09:00
1 parent 74de932eb9
commit 25a4a84138
3 files changed
+520

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { buildAbut } from '../src/core/abut/geometry';
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import { buildWings } from '../src/core/abut/wings';
import { abutFootingGeom, abutFootingRebar } from '../src/core/abut/rebar/footing';
import { skewAngle } from '../src/core/abut/rebar/skew-layout';
import { abutWallGeom, abutWallRebar } from '../src/core/abut/rebar/wall';
import { abutParapetGeom, abutParapetRebar, slabSpanAt, type AbutParapetGeom } from '../src/core/abut/rebar/parapet';
import { resolveSlab } from '../src/core/abut/slab';
import { abutPlanOutline, abutSkewK } from '../src/view/abut-scene';
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
// 예제 A1 (교대흉벽철근.md §11). 기획서 기대값은 fck 27 · fy 400, 구간 1 끝 (IP3 끝) 단면.
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
const wings = buildWings(model, members);
const angle = skewAngle(rec.skewDeg);
const footing = abutFootingRebar(abutFootingGeom(model.length, members.shape, angle));
const fck27 = { ...DEFAULT_MATERIAL, fck: 27 };
const wGeom = abutWallGeom(model.length, members, angle);
const wall = abutWallRebar(wGeom, footing, undefined, fck27);
const k = abutSkewK(model);
const xP10 = (wGeom.xb - 500) / 2;
const slab = resolveSlab();
const span = slabSpanAt(abutPlanOutline(model, members, wings, slab).slab, xP10 / 1000, k)!;
const chamfer = (sEnd: number) => (wings.find((w) => w.sEnd === sEnd)?.haunchM ?? 0) * 1000;
const geom: AbutParapetGeom = abutParapetGeom(wGeom, members.shape, {
B: model.wing!.slabWidthMm,
a: span.a,
b: span.b,
nearT: members.shape.parapetBackM * 1000 - slab.parapetGapMm,
chamferA: chamfer(0),
chamferB: chamfer(model.length),
});
const A1 = abutParapetRebar(geom, wall, undefined, fck27, 'A1');
const bar = (r: typeof A1, mark: string) => r.bars.find((b) => b.mark === mark)!;
describe('교대 흉벽 · 브라켓 배근 A1 (AD5, 교대흉벽철근.md §11)', () => {
it('geometry: 흉벽 높이 입력 2500, 브라켓 850, 흉벽 배면 −500, 슬래브 폭 11253 (반올림 11254). 상단선은 받침 ① · 마지막 받침이 정해 IP1 2529 · IP3 2468', () => {
expect([geom.Hp, geom.bracketH, geom.parapetBack]).toEqual([2500, 850, -500]);
expect(Math.abs(geom.slab!.B - 11253)).toBeLessThanOrEqual(1);
const hAt = (u: number) => geom.top[0] + ((geom.top[1] - geom.top[0]) * u) / wGeom.L - (wGeom.HF + wGeom.segments.find((s) => u < s.to)!.H);
expect(hAt(A1.uRef)).toBeCloseTo(2500, 6); // 대표 단면 = 흉벽 높이 2500인 곳
});
it('위치: P4 −100, P1 −115.9, P2 뒤 다리 −400, P5 −384.1 / −684.1, P3 −700, P10 −650', () => {
expect([A1.x.P4, A1.x.P2back, A1.x.P3, A1.x.P10]).toEqual([-100, -400, -700, -650]);
expect(A1.x.P1).toBeCloseTo(-115.9, 6);
expect(A1.x.P5up).toBeCloseTo(-384.1, 6);
expect(A1.x.P5low).toBeCloseTo(-684.1, 6);
});
it('격자: P1 = 저판 − 17.5 (47), P2 − 1.6 (94), P3 − 35 (94)', () => {
expect(A1.offsets.P1).toBeCloseTo(-17.5, 6);
expect(A1.offsets.P2).toBeCloseTo(-1.6, 6);
expect(A1.offsets.P3).toBeCloseTo(-35, 6);
expect(['P1', 'P2', 'P3'].map((m) => bar(A1, m).count)).toEqual([47, 94, 94]);
});
it('길이 (대표 단면, 흉벽 높이 2500): P1 3119, P2 2504, P3 2885 (ㄱ형)', () => {
expect(['P1', 'P2', 'P3'].map((m) => bar(A1, m).length)).toEqual([3119, 2504, 2885]);
});
it('수평 단 (대표 단면): P4 19단, P5 7 + 12단 (1650 단은 위 그룹)', () => {
expect([A1.levels.P4, A1.levels.P5up, A1.levels.P5low]).toEqual([19, 7, 12]);
});
it('P6 · P7: 대표 단면 3단 / 2단, 교대축 12개, 길이 600 / 300 (1/sin θ) + 2 × 197 = 996 / 695', () => {
expect([A1.levels.P6, A1.levels.P7]).toEqual([3, 2]);
expect(bar(A1, 'P7').count).toBe(24);
expect([bar(A1, 'P6').length, bar(A1, 'P7').length]).toEqual([996, 695]);
});
it('P8 · P9: 12단 1776 / 7단 1475, 각 × 2면, 끝면에서 114.3', () => {
expect([bar(A1, 'P8').count, bar(A1, 'P8').length]).toEqual([24, 1776]);
expect([bar(A1, 'P9').count, bar(A1, 'P9').length]).toEqual([14, 1475]);
const p8 = A1.paths.find((p) => p.mark === 'P8')!;
expect(p8.pts[1][0] * angle.sin).toBeCloseTo(114.3, 6);
});
it('P11: 끝면당 3개 (x −100 · −400 · −700), l_d + 흉벽 높이 − c / l_d + 흉벽 높이 − 850 − c (높이 2500이면 3135 / 3135 / 2285), 양끝 6개', () => {
expect(bar(A1, 'P11').count).toBe(6);
const low = A1.paths.filter((p) => p.mark === 'P11' && p.pts[0][0] > wGeom.L / 2);
const u = low[0].pts[0][0]; // 끝면 − c / sin θ
const H = geom.top[0] + ((geom.top[1] - geom.top[0]) * u) / wGeom.L - (wGeom.HF + 2372); // IP3 끝의 실제 흉벽 높이 (약 2468)
expect(low.map((p) => p.pts[1][2] - p.pts[0][2])).toEqual([735 + H - 100, 735 + H - 100, 735 + H - 850 - 100].map((v) => expect.closeTo(v, 6)));
const flat = abutParapetRebar({ ...geom, top: [wGeom.HF + 2372 + 2500, wGeom.HF + 2372 + 2500] }, wall, undefined, fck27);
const lowFlat = flat.paths.filter((p) => p.mark === 'P11' && p.pts[0][0] > wGeom.L / 2);
expect(lowFlat.map((p) => Math.round(p.pts[1][2] - p.pts[0][2]))).toEqual([3135, 3135, 2285]);
});
it('P10: B 11253 → 28개, e 126.5; 한쪽 없음 11725 → 29, 양측 없음 12197 → 30; 모따기 검사 통과', () => {
expect(A1.dowel.n).toBe(28);
expect(abutParapetRebar({ ...geom, slab: { ...geom.slab!, B: 11253 } }, wall, undefined, fck27).dowel.e).toBe(126.5);
// 슬래브 가운데 맞춤: 양끝 다웰이 측면선에서 거의 같은 거리 (c + e, Q-P5로 옮긴 만큼만 다름).
const s = geom.slab!;
expect(Math.abs(A1.dowel.positions[0] - s.a - (s.b - A1.dowel.positions[27]))).toBeLessThan(2 * 20.65 + 1);
const withB = (B: number) => abutParapetRebar({ ...geom, slab: { ...geom.slab!, B } }, wall, undefined, fck27).dowel.n;
expect([withB(11725), withB(12197)]).toEqual([29, 30]);
expect(A1.issues.map((i) => i.code)).not.toContain('RB-AP05');
});
it('Q-P5: every dowel clears the P3 수평 다리 by (25.4 + 15.9)/2', () => {
const p3 = A1.paths.filter((p) => p.mark === 'P3').map((p) => p.pts[0][0]);
for (const u of A1.dowel.positions) expect(Math.min(...p3.map((q) => Math.abs(u - q)))).toBeGreaterThanOrEqual(20.65 - 1e-6);
expect(A1.issues.map((i) => i.code)).not.toContain('RB-AP06');
});
it('경사: 흉벽 상단이 기울어 P1 길이가 위치마다 다름 (범위), 수평 단이 경사와 나란함', () => {
const p1 = bar(A1, 'P1');
expect(p1.lengthMax).toBeGreaterThan(p1.lengthMin);
const p4 = A1.paths.find((p) => p.mark === 'P4')!;
const slope = (geom.top[1] - geom.top[0]) / wGeom.L;
expect((p4.pts[1][2] - p4.pts[0][2]) / (p4.pts[1][0] - p4.pts[0][0])).toBeCloseTo(slope, 9);
});
it('A1 has no warning', () => {
expect(A1.issues.filter((i) => i.level !== 'I').map((i) => i.code)).toEqual([]);
});
it('l_d 연동: fck 30 → P1 · P2 · P3 길이 함께 짧아짐', () => {
const r30 = abutParapetRebar(geom, wall, undefined, { ...DEFAULT_MATERIAL, fck: 30 });
const dl = A1.ld.H16 - r30.ld.H16;
expect(bar(r30, 'P1').length).toBe(3119 - dl);
expect(bar(r30, 'P2').length).toBe(2504 - 2 * dl);
expect(bar(r30, 'P3').length).toBe(2885 - dl);
});
it('묶음 수정 · 배수 → RB-AP03 "수정됨"', () => {
const r = abutParapetRebar(geom, wall, { sHoriz: 150, k67L: 6 }, fck27);
expect(r.issues.map((i) => i.code)).toContain('RB-AP03');
expect(bar(r, 'P4').modified).toBe(true);
expect(bar(r, 'P6').modified).toBe(true);
expect(bar(r, 'P1').modified).toBe(false);
});
});