Files
Pier_v1/tests/abut-rebar-parapet.test.ts
T
ypshimandClaude Haiku 5.5 25a4a84138 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>
2026-11-04 17:01:32 +09:00

143 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});