feat(abut-rebar): 교대 접속슬래브 배근 규칙 R1–R8 (AD8)

- R1 · R3 (교대축 배치, 하면은 상면 구간 절반, 결정 A), R2 · R4 (길이 배치, 측면 피복 c / sin), 모따기 자름 (결정 E), R7 모서리 상 · 하, R8 엇갈림 (모따기 안 제외)
- ② 슬래브 평면 측면선 · 단면을 그대로 따라감 (상면 철근은 상면, 하면 철근은 하면). 노출형도 같은 규칙 (사용자 결정 2026-11-05)
- P10 다웰과의 간섭은 보지 않음 (사용자 결정 2026-11-05). k_8 편집 가능
- 테스트: 교대접속슬래브철근.md §11 (fck 27), 삽도 폭 11670 재현, 날개벽 형식별 R7 · 폭, 기본간격 연동, 노출형

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-05 09:23:42 +09:00
1 parent bb2af4bdf5
commit fa7d0032c3
2 files changed
+418

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
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 { resolveSlab, slabGradePct, slabSection } from '../src/core/abut/slab';
import { abutSlabGeom, abutSlabRebar, type AbutSlabGeom } from '../src/core/abut/rebar/slab';
import { abutPlanOutline } from '../src/view/abut-scene';
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
// 예제 A1 (교대접속슬래브철근.md §11), fck 27.
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 geomFor = (forms?: { left: 'general' | 'short' | 'none'; right: 'general' | 'short' | 'none' }, slabIn?: Parameters<typeof resolveSlab>[0]): AbutSlabGeom => {
const wings = buildWings(model, members, forms);
const slab = resolveSlab(slabIn);
const grade = slabGradePct(slab, model.wing?.slopePct ?? 0);
const ch = (sEnd: number) => (wings.find((w) => Math.abs(w.sEnd - sEnd) < 1e-9)?.haunchM ?? 0) * 1000;
return abutSlabGeom(
slab.form,
abutPlanOutline(model, members, wings, slab).slab,
slabSection(slab, grade, members.shape),
{ startEl: members.parapetTopStartEl, endEl: members.parapetTopEndEl, L: model.length, bottomEl: members.footingBottomEl! },
[ch(0), ch(model.length)],
slab.lengthMm,
grade,
);
};
const geom = geomFor();
const fck27 = { ...DEFAULT_MATERIAL, fck: 27 };
const A1 = abutSlabRebar(geom, undefined, fck27, 'A1');
const bar = (r: typeof A1, m: string) => r.bars.find((b) => b.mark === m);
describe('교대 접속슬래브 배근 A1 (AD8, 교대접속슬래브철근.md §11)', () => {
it('geometry: 폭 ≈ 11253 (② 평면 측면선 사이 11257.3 — 날개벽 두께를 교대축 방향으로 띄운 skew 차이), 길이 7000, 모따기 양측 300', () => {
expect(Math.abs(geom.B - 11253)).toBeLessThan(5);
expect(geom.near - geom.far).toBeCloseTo(7000, 6);
expect([geom.chamfer0, geom.chamferL]).toEqual([300, 300]);
});
it('A1: R1 36 · R3 71 (35@300, e ≈ 276.5), R2 22 · R4 43 (21@300, e 250)', () => {
expect(['R1', 'R3', 'R2', 'R4'].map((m) => bar(A1, m)?.count)).toEqual([36, 71, 22, 43]);
expect(A1.across.e).toBeCloseTo((geom.B - 200 - 10500) / 2, 6);
expect(A1.along.text).toBe('100 · 250 · 21@300=6.300 · 250 · 100');
});
it('삽도 재현 (B 11670): 100 · 185 · 37@300=11.100 · 185 · 100, 양끝 첫 R1 · R3이 모따기에 걸려 짧아짐 (RB-AS02)', () => {
const g = { ...geom, sideL: { ...geom.sideL, s: geom.side0.s + 11670 }, B: 11670 };
const r = abutSlabRebar(g, undefined, fck27);
expect(r.across.text).toBe('100 · 185 · 37@300=11.100 · 185 · 100');
expect(r.issues.map((i) => i.code)).toContain('RB-AS02');
});
it('높이: 상면 R1 = 상면 − 80, R2 = 상면 − 97.45, 하면 R4 = 하면 + 97.45, R3 = 하면 + 80 (t_s 450 → 370 / 352.55 / 97.45 / 80)', () => {
expect([A1.z.R1, A1.z.R2, A1.z.R4, A1.z.R3]).toEqual([80, expect.closeTo(97.45, 6), expect.closeTo(97.45, 6), 80]);
const r1 = A1.paths.find((p) => p.mark === 'R1')!;
const r3 = A1.paths.find((p) => p.mark === 'R3' && Math.abs(p.pts[0][0] - r1.pts[0][0]) < 1)!;
expect(r1.pts[0][2] - r3.pts[0][2]).toBeCloseTo(370 - 80, 6);
});
it('길이: R1 · R3 = 6800 (측면 skew와 경사만큼 김), R2 · R4 = B − 2c / sin θ ≈ 11052', () => {
const r1 = bar(A1, 'R1')!.length;
expect(r1).toBeGreaterThanOrEqual(6800);
expect(r1).toBeLessThan(6800 * 1.006);
// 3D 길이: 흉벽 상단이 교대축을 따라 기울어 (345 / 12.2 m) 평면 길이보다 약 4.5 김.
expect(Math.abs(bar(A1, 'R2')!.length - (geom.B - 200 / geom.sin))).toBeLessThan(6);
});
it('모따기: A1은 짧아지는 철근 없음', () => {
expect(A1.trimmed).toBe(0);
});
it('R8 54개 · 627 (= 290 + (22.2 + 22.2)/2 … 135° 갈고리 151 × 2)', () => {
expect(bar(A1, 'R8')!.count).toBe(54);
});
it('R7: 양측 날개벽 4 · 한쪽 없음 2 · 양측 없음 0, 길이 2·l_d + 341 = 1515 (skew로 대각이 조금 길어 +25 이내)', () => {
expect(bar(A1, 'R7')!.count).toBe(4);
expect(bar(A1, 'R7')!.length - 1515).toBeGreaterThanOrEqual(0);
expect(bar(A1, 'R7')!.length - 1515).toBeLessThan(25);
const one = abutSlabRebar(geomFor({ left: 'general', right: 'none' }), undefined, fck27);
expect(bar(one, 'R7')!.count).toBe(2);
const none = abutSlabRebar(geomFor({ left: 'none', right: 'none' }), undefined, fck27);
expect(bar(none, 'R7')).toBeUndefined();
});
it('날개벽 형식: 한쪽 없음 B ≈ 11725 · 양측 없음 B ≈ 12197 → 개수 다시 계산', () => {
expect(Math.abs(geomFor({ left: 'general', right: 'none' }).B - 11725)).toBeLessThan(5);
expect(Math.abs(geomFor({ left: 'none', right: 'none' }).B - 12197)).toBeLessThan(5);
});
it('기본간격 연동: s_a 200 → R1 @400, R3 @200, R8 @1600', () => {
const r = abutSlabRebar(geom, { sA: 200 }, fck27);
expect([bar(r, 'R1')!.spacing, bar(r, 'R3')!.spacing, bar(r, 'R8')!.spacing.split(' × ')[0]]).toEqual(['400', '200', '1.600']);
});
it('노출형도 같은 규칙: 상면 철근은 상면, 하면 철근은 하면을 따라 (사용자 결정 2026-11-05)', () => {
const ex = abutSlabRebar(geomFor(undefined, { form: 'exposed' }), undefined, fck27);
expect(['R1', 'R3', 'R2', 'R4'].map((m) => bar(ex, m)?.count)).toEqual([36, 71, 22, 43]);
const r3 = ex.paths.find((p) => p.mark === 'R3')!;
expect(r3.pts.length).toBeGreaterThan(2); // 헌치 · 벽체 배면에서 꺾임
});
});