- 사용자 도면(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>
194 lines
11 KiB
TypeScript
194 lines
11 KiB
TypeScript
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"');
|
||
});
|
||
});
|