Files
Pier_v1/tests/abut-rebar-parapet.test.ts
T
ypshimandClaude Haiku 5.5 914e3dbda8 feat(abut-rebar): 교대 흉벽 · 브라켓 배근 삽도 (단면 · 교대축 단면 · P10 다웰 평면)
- 대표 단면(흉벽 높이 = 입력값): P1–P3 · P6 · P7 · P10 선, P4 · P5 점, 두께 · 단 수 치수
- 교대축 단면: 상단 경사와 나란한 P4, P1, 끝면 P11 · P8 · P9
- P10 평면: 슬래브 측면선, 다웰 n개, P3 다리 위치와 비켜 옮긴 다웰
- verify/dump: 흉벽 삽도 덤프

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 17:03:52 +09:00

181 lines
10 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);
});
});
import { applyParapetRebar, applyWallRebar } from '../src/core/abut/edit';
import { checkStoredAbutments } from '../src/core/project';
describe('③ 흉벽 배근 입력 저장', () => {
it('stores only the changed values next to the 벽체 ones; undefined links a group back; refuses bad values', () => {
const a = applyParapetRebar(applyWallRebar(rec, { sL: 150 }), { sHoriz: 150, s10: 350 });
expect(a.rebar).toEqual({ wall: { sL: 150 }, parapet: { sHoriz: 150, s10: 350 } });
expect(applyParapetRebar(a, { sHoriz: undefined }).rebar?.parapet).toEqual({ s10: 350 });
expect(() => applyParapetRebar(rec, { k67V: 0 })).toThrow('P6 · P7 배수');
expect(() => applyParapetRebar(rec, { L10: -1 })).toThrow();
});
it('the project file keeps the 흉벽 input and refuses a non-number', () => {
expect(() => checkStoredAbutments([applyParapetRebar(rec, { c: 80 })])).not.toThrow();
expect(() => checkStoredAbutments([{ ...rec, rebar: { parapet: { c: 'x' } } as never }])).toThrow('흉벽 배근');
});
});
import { abutParapetDowelSvg, abutParapetElevationSvg, abutParapetSectionSvg } from '../src/view/abut-parapet-rebar-svg';
describe('③ 흉벽 삽도', () => {
it('단면: 두께 치수 "100 · 300 · 100", 단 수, 번호 풍선 P1–P7 · P10', () => {
const svg = abutParapetSectionSvg(geom, A1);
expect(svg).toContain('100 · 300 · 100');
expect(svg).toContain('P4 19단 · P5 7 + 12단');
for (const m of ['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P10']) expect(svg).toContain(`data-pick="${m}"`);
});
it('교대축 단면 · 다웰 평면: 끝면 P8 · P9 · P11 풍선, 다웰 n과 옮긴 개수; 선택하면 나머지 흐리게 / 숨김', () => {
const el = abutParapetElevationSvg(geom, A1);
for (const m of ['P8', 'P9', 'P11', 'P4']) expect(el).toContain(`data-pick="${m}"`);
const dw = abutParapetDowelSvg(geom, A1);
expect(dw).toContain('n = 28');
expect(dw).toContain(`${A1.dowel.moved}개 P3 비켜 옮김`);
expect(abutParapetDowelSvg(geom, A1, { marks: new Set(['P10']), only: true })).toContain('opacity="0"');
});
});