feat(abut-rebar): 교대 저판 배근 규칙 B1–B10 (AD3) · skew 배근 · 겹침이음 공통 (AD1 · AD2)

- skew-layout: 교대축을 따라 잰 격자, 끝면 피복 환산, 치수 문자열
- splice: l_s = 1.3 l_d (올림 1 mm), 최소 조각 수, ± l_s 엇갈림
- footing: 층 높이, B1–B4 옵션 ① skew / ② 교대축 직각 (가운데 격자 + 끝 skew 철근 B1-S · B2-S),
  B5–B8 (상면 벽체 밑 제외), B9 교차 배치, B10-1 · B10-2, 철근별 간격 수정, 검사 RB-AF01–10
- 교대확대기초철근.md §13 기대값 테스트 (뒷굽 짧음 합성은 §5.2 규칙에 맞춰 뒷굽 150으로 확인)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 11:06:14 +09:00
1 parent 59c2607418
commit e00e43ca61
5 files changed
+598

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
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 { abutFootingGeom, abutFootingRebar, type AbutFootingGeom } from '../src/core/abut/rebar/footing';
import { gridLayout, skewAngle, SQUARE } from '../src/core/abut/rebar/skew-layout';
import { lapLength, splicePlan } from '../src/core/abut/rebar/splice';
// 예제 A1 (교대확대기초철근.md §13): L 12197 · W 4600 · H 1100, 벽체 1000 ~ 3400, θ 95°02′52″.
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 geom = abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg));
const A1 = abutFootingRebar(geom, undefined, undefined, 'A1');
const bar = (r: typeof A1, mark: string) => r.bars.find((b) => b.mark === mark)!;
const codes = (r: typeof A1) => r.issues.map((i) => i.code);
describe('교대 저판 배근 공통 (AD1 skew · AD2 이음)', () => {
it('skew: sin θ 0.99612; the grid of B1–B4 is 93@125 with e 185.8', () => {
expect(skewAngle(rec.skewDeg).sin).toBeCloseTo(0.99612, 5);
const g = gridLayout(geom.L, 100 / geom.angle.sin, 150, 125);
expect(g.A).toBeCloseTo(11996.5, 0);
expect([g.n, g.G, g.positions.length]).toEqual([93, 11625, 94]);
expect(g.e).toBeCloseTo(185.8, 1);
});
it('이음: l_s H16 955 / H19 1147 (fck 27, fy 400); B5·B6 13627 → 2조각 6336 / 8246', () => {
expect(lapLength(15.9, 400, 27)).toBe(955);
expect(lapLength(19.1, 400, 27)).toBe(1147);
const p = splicePlan(13627, 15.9, 400, 27)!;
expect(p.pieces).toBe(2);
expect(p.staggered.map(Math.round)).toEqual([6336, 8246]);
expect(p.over).toBe(false);
expect(splicePlan(11997, 15.9, 400, 27)).toBeNull();
});
});
describe('교대 저판 B1–B10 (교대확대기초철근.md §13, 예제 A1 옵션 ①)', () => {
it('geometry from the generated abutment: W 4600, H 1100, 벽체 1000 ~ 3400', () => {
expect([geom.W, geom.H, geom.xb, geom.xf]).toEqual([4600, 1100, 1000, 3400]);
expect(geom.L).toBeCloseTo(12197, 0);
});
it('높이: B1·B3 1000, B5·B7 984.1, B6·B8 169.05, B2·B4 150', () => {
expect(A1.z.B13).toBe(1000);
expect(A1.z.B57).toBeCloseTo(984.1, 6);
expect(A1.z.B68).toBeCloseTo(169.05, 6);
expect(A1.z.B24).toBe(150);
});
it('B1–B4: 93@125, 각 47; B1·B2 6117, B3 3212, B4 4417; 옆붙임 B1 +19.05, B5 +15.9', () => {
expect(A1.main.text).toBe('100.4 · 185.8 · 93@125=11.625 · 185.8 · 100.4');
for (const m of ['B1', 'B2', 'B3', 'B4']) expect(bar(A1, m).count).toBe(47);
expect([bar(A1, 'B1').length, bar(A1, 'B2').length, bar(A1, 'B3').length, bar(A1, 'B4').length]).toEqual([6117, 6117, 3212, 4417]);
expect(A1.sideShift.B1).toBeCloseTo(19.05, 6);
expect(A1.sideShift.B5).toBeCloseTo(15.9, 6);
});
it('B5–B8: 27@150, e 175; 하면 B6 14 / B8 14, 상면 B5 6 / B7 7; B5·B6 13627 → RB-AF08, B7·B8 11997', () => {
expect(A1.dist.bottomText).toBe('100 · 175 · 27@150=4.050 · 175 · 100');
expect(A1.dist.topText).toBe('100 · 175 · 5@150=750 · 2.400 · 6@150=900 · 175 · 100');
expect(['B5', 'B7', 'B6', 'B8'].map((m) => bar(A1, m).count)).toEqual([6, 7, 14, 14]);
expect([bar(A1, 'B5').length, bar(A1, 'B6').length, bar(A1, 'B7').length, bar(A1, 'B8').length]).toEqual([13627, 13627, 11997, 11997]);
expect(bar(A1, 'B5').splice?.staggered.map(Math.round)).toEqual([6336, 8246]);
expect(codes(A1)).toContain('RB-AF08');
});
it('B10: 4단 3@200, 위 여유 250, 깊이 120.65 / 117.5, 다리 296; B10-1 8 × 12553 (2조각 5703 / 7997), B10-2 8 × 4968', () => {
expect(A1.side.text).toBe('150 · 3@200=600');
expect(A1.side.levels).toEqual([150, 350, 550, 750]);
expect(A1.side.topGap).toBe(250);
expect(A1.side.depth1).toBeCloseTo(120.65, 6);
expect(A1.side.depth2).toBeCloseTo(117.5, 6);
expect(A1.side.hook).toBe(296);
expect([bar(A1, 'B10-1').count, bar(A1, 'B10-1').length]).toEqual([8, 12553]);
expect(bar(A1, 'B10-1').splice?.staggered.map(Math.round)).toEqual([5703, 7997]);
expect([bar(A1, 'B10-2').count, bar(A1, 'B10-2').length]).toEqual([8, 4968]);
});
it('B9: 6행 × 47 = 282, 1228, rows 0 2 4 / 22 24 26 of the B5–B8 grid, staggered by s_L', () => {
expect([bar(A1, 'B9').count, bar(A1, 'B9').length]).toEqual([282, 1228]);
expect(A1.shear.rows.map((r) => r.x)).toEqual([275, 575, 875, 3575, 3875, 4175]);
expect(A1.shear.rows[1].s[0] - A1.shear.rows[0].s[0]).toBeCloseTo(125, 6);
});
it('A1 has no warning: only RB-AF08 (정보) for B5 · B6 · B10-1', () => {
expect(A1.issues.filter((i) => i.level !== 'I')).toEqual([]);
expect(A1.issues.filter((i) => i.code === 'RB-AF08').length).toBe(3);
});
});
describe('옵션 ② 교대축 직각 · 직사각 · 합성 (§13)', () => {
const opt2 = abutFootingRebar(geom, { dir: 'perp' });
it('가운데 격자 92@125, e 45.5, 위치 93 (47 / 46); B1·B2 6100, B4 4400, B3 3200; RB-AF03', () => {
expect(opt2.main.positions).toHaveLength(93);
expect(opt2.main.positions[1] - opt2.main.positions[0]).toBeCloseTo(125, 9);
expect(opt2.main.text).toMatch(/^100 · 45\.\d · 92@125=11\.500/);
expect(['B1', 'B3', 'B2', 'B4'].map((m) => bar(opt2, m).count)).toEqual([47, 46, 47, 46]);
expect([bar(opt2, 'B1').length, bar(opt2, 'B4').length, bar(opt2, 'B3').length]).toEqual([6100, 4400, 3200]);
expect(codes(opt2)).toContain('RB-AF03');
});
it('끝 skew 철근: D 451.9, k 3, r 76.9 → 100 · 2@101 · 125, 한쪽 4개; B1-S H16 8 / B2-S H22 8, 각 4417; RB-AF05', () => {
expect(opt2.skewEnd!.D).toBeCloseTo(451.9, 0);
expect(opt2.skewEnd!.k).toBe(3);
expect(opt2.skewEnd!.r).toBeCloseTo(76.9, 0);
expect(opt2.skewEnd!.text).toBe('100 · 2@101 · 125');
expect([bar(opt2, 'B1-S').size, bar(opt2, 'B1-S').count, bar(opt2, 'B1-S').length]).toEqual(['H16', 8, 4417]);
expect([bar(opt2, 'B2-S').size, bar(opt2, 'B2-S').count, bar(opt2, 'B2-S').length]).toEqual(['H22', 8, 4417]);
expect(codes(opt2)).toContain('RB-AF05');
});
it('삽도 재현: L 12188 → cycle당 46@250, e 40.8, D 447.2 → 2@98.6 · 125', () => {
const r = abutFootingRebar({ ...geom, L: 12188 }, { dir: 'perp' });
expect(r.main.positions).toHaveLength(93);
expect(r.skewEnd!.D).toBeCloseTo(447.2, 0);
expect((125 + r.skewEnd!.r) / 2).toBeCloseTo(98.6, 0);
expect(r.skewEnd!.text).toBe('100 · 2@99 · 125');
});
it('r = 0: D a multiple of s gives k bars at s', () => {
// A synthetic skew with W·cot = 450, and L so that e = 50 (< s/2) and D = e + 450 = 4·125 exactly.
const g: AbutFootingGeom = { ...geom, angle: { sin: 0.995, cotAbs: 450 / geom.W } };
const L = 200 + 450 + 92 * 125 + 2 * 50;
const r = abutFootingRebar({ ...g, L }, { dir: 'perp' });
expect(r.skewEnd!.r).toBeCloseTo(0, 6);
expect(r.skewEnd!.k).toBe(4);
expect(r.skewEnd!.offsets).toHaveLength(4);
});
it('skew 90° 합성: 피복 100, l_0 4400; B1·B2 6100 / B3·B4 3200 · 4400, the same for both options', () => {
const sq = { ...geom, angle: SQUARE };
for (const dir of ['skew', 'perp'] as const) {
const r = abutFootingRebar(sq, { dir });
expect([bar(r, 'B1').length, bar(r, 'B3').length, bar(r, 'B4').length]).toEqual([6100, 3200, 4400]);
expect(r.skewEnd).toBeNull();
}
expect(abutFootingRebar(sq).main.text.startsWith('100 · 186.')).toBe(true);
});
it('직사각 평면: 옵션 ② 고정, 끝 skew 철근 0 → RB-AF04', () => {
const r = abutFootingRebar({ ...geom, plan: 'rect', angle: SQUARE }, { dir: 'skew' });
expect(r.dir).toBe('perp');
expect(r.skewEnd).toBeNull();
expect(codes(r)).toContain('RB-AF04');
});
it('뒷굽 짧음: 상면 뒷굽 철근 0 → RB-AF02', () => {
// The spec (§13) says 뒷굽 300, but its own rule (§5.2: keep x ≤ x_b + s_W/2) keeps the bar at x 275 ≤ 375 there.
// A 뒷굽 of 150 leaves none (275 > 225).
expect(abutFootingRebar({ ...geom, xb: 300, xf: 2700 }).dist.topKept[0]).toBe(true);
const r = abutFootingRebar({ ...geom, xb: 150, xf: 2550 });
expect(codes(r)).toContain('RB-AF02');
});
it('기본간격 연동: s_L 150 / s_W 200 / s_v 250 → every count and text recomputed, B9 on the new grid', () => {
const r = abutFootingRebar(geom, { sL: 150, sW: 200, sV: 250 });
expect(r.main.text).toContain('@150=');
expect(r.dist.bottomText).toContain('@200=');
expect(r.side.text).toBe('150 · 2@250=500');
expect(bar(r, 'B9').spacing).toBe('300 × 400');
expect(r.shear.rows[1].s[0] - r.shear.rows[0].s[0]).toBeCloseTo(150, 6);
expect(bar(r, 'B1').count).not.toBe(47);
});
it('철근별 수정: B5–B8 only at 200 → B1–B4 and B10 unchanged, RB-AF10, modified badge; removing it links back', () => {
const r = abutFootingRebar(geom, { sDist: 200 });
expect(bar(r, 'B6').count).not.toBe(14);
expect(bar(r, 'B6').modified).toBe(true);
expect(bar(r, 'B1').modified).toBe(false);
expect(bar(r, 'B1').count).toBe(47);
expect(bar(r, 'B10-1').count).toBe(8);
expect(codes(r)).toContain('RB-AF10');
expect(codes(abutFootingRebar(geom, {}))).not.toContain('RB-AF10');
});
it('부재 독립: the footing result does not read any other member spacing', () => {
expect(abutFootingRebar(geom, {}).bars).toEqual(A1.bars);
});
});