Files
Pier_v1/tests/abut-rebar-parapet.test.ts
T
ypshimandClaude Haiku 5.5 5e64a4f2ed feat(abut): 벽체 높이 H_w = 받침 ① 교좌면 ~ 흉벽 상단 연직 (기본 3326)
- 사용자 도면(2026-11-04): 받침 ① 위치에서 받침 ① 교좌면부터 흉벽 상단까지 연직 거리. 모든 교대 기본값 3326 (QA-4 가정 2500 대체)
- 입력 이름 "벽체 높이 · 받침 ① 기준 연직", ② 정면도에 받침 ① 기준선과 H_w 치수
- ③ 흉벽 철근표 대표 단면 = 받침 ① 위치 (P1–P3는 그 단면에서 정확히 계산)
- 테스트: 흉벽 상단 IP1 48.006 / IP3 47.661 등 + 0.826 반영, 교대흉벽철근.md §11 재현은 2500을 넣어 유지, 3326 기본 확인 (P1 3945)

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

194 lines
11 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, 벽체 높이 2500 (받침 ① 기준) 단면.
// 기본 벽체 높이는 3326 (사용자 도면 2026-11-04)이라, 기획서 재현은 2500을 따로 넣는다.
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 }, { parapetHeightMm: 2500 });
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, model.bearings[0].s, {
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에서 11.057 m)', () => {
expect([geom.Hp, geom.bracketH, geom.parapetBack]).toEqual([2500, 850, -500]);
expect(Math.abs(geom.slab!.B - 11253)).toBeLessThanOrEqual(1);
expect(A1.uRef).toBeCloseTo(model.bearings[0].s * 1000, 3);
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, 3); // 받침 ① 위치에서 교좌면 ~ 흉벽 상단 = 벽체 높이
});
it('기본 벽체 높이 3326 (받침 ① 교좌면 기준 연직): 흉벽 상단 받침 ① 47.693, P1 735 + 3326 − 100 − 15.9 = 3945', () => {
const m = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
expect(m.shape.parapetHeightM).toBe(3.326);
const wg = abutWallGeom(model.length, m, angle);
const g = abutParapetGeom(wg, m.shape, model.bearings[0].s, geom.slab);
const r = abutParapetRebar(g, abutWallRebar(wg, footing, undefined, fck27), undefined, fck27);
const topAtB1 = m.parapetTopStartEl + ((m.parapetTopEndEl - m.parapetTopStartEl) * model.bearings[0].s) / model.length;
expect(topAtB1).toBeCloseTo(44.367 + 3.326, 6);
expect(bar(r, 'P1').length).toBe(3945);
});
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"');
});
});