- 사용자 도면(2026-11-04): 끝마다 이웃 높낮이로 정함 — 높은 이웃 쪽은 경계 너머 정착장만큼 직선 겹침, 낮은 이웃 · 끝면 쪽은 경계 − c에서 연직 갈고리 정착장 - 번호는 모양으로: 양끝 갈고리 W9 (ㄷ), 한쪽 갈고리 W10 (ㄴ). A1: W9 6 (IP1 끝 구간), W10 24 - 벽체 카드에 "W9 · W10 정착장 (mm)" 입력 (기본 330, fck 연동 없음), 철근표 · 삽도 · 안내 문구 반영 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
199 lines
10 KiB
TypeScript
199 lines
10 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 { abutFootingGeom, abutFootingRebar } from '../src/core/abut/rebar/footing';
|
||
import { skewAngle } from '../src/core/abut/rebar/skew-layout';
|
||
import { abutWallGeom, abutWallRebar, type AbutWallGeom } from '../src/core/abut/rebar/wall';
|
||
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
|
||
|
||
// 예제 A1 (교대벽체철근.md §11). 기획서 기대값은 fck 27 · fy 400.
|
||
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 angle = skewAngle(rec.skewDeg);
|
||
const fGeom = abutFootingGeom(model.length, members.shape, angle);
|
||
const footing = abutFootingRebar(fGeom);
|
||
const geom = abutWallGeom(model.length, members, angle);
|
||
const fck27 = { ...DEFAULT_MATERIAL, fck: 27 };
|
||
const A1 = abutWallRebar(geom, footing, undefined, fck27, 'A1');
|
||
const bar = (r: typeof A1, mark: string) => r.bars.find((b) => b.mark === mark)!;
|
||
|
||
describe('교대 벽체 배근 A1 (AD4, 교대벽체철근.md §11)', () => {
|
||
it('geometry: 5 steps, 구간 1 (IP3 끝) H 2372, 구간 5 H 2656, 벽체 −800 ~ +1600', () => {
|
||
expect(geom.segments.length).toBe(5);
|
||
expect([geom.xb, geom.xf, geom.HF]).toEqual([-800, 1600, 1100]);
|
||
const byNo = (no: number) => geom.segments.find((s) => s.no === no)!;
|
||
expect(byNo(1).H).toBeCloseTo(2372, 0);
|
||
expect(byNo(5).H).toBeCloseTo(2656, 0);
|
||
});
|
||
|
||
it('위치: W5 1500, W1 1480.9, W3 · W4 −700, W6 −682.5, W9 첫 줄 1463.4 · 폭 6개', () => {
|
||
expect(A1.x.W5).toBeCloseTo(1500, 6);
|
||
expect(A1.x.W1).toBeCloseTo(1480.9, 6);
|
||
expect(A1.x.W34).toBeCloseTo(-700, 6);
|
||
expect(A1.x.W6).toBeCloseTo(-682.5, 6);
|
||
expect(A1.x.W9[0]).toBeCloseTo(1463.4, 6);
|
||
expect(A1.x.W9.length).toBe(6);
|
||
});
|
||
|
||
it('교대축 배치: 93@125, e 185.8, 저판 격자 − 17.5 → W1 47, W3 47, W4 47', () => {
|
||
expect([A1.vertical.layout.n, A1.vertical.layout.positions.length]).toEqual([93, 94]);
|
||
expect(A1.vertical.layout.e).toBeCloseTo(185.8, 1);
|
||
expect(A1.vertical.shift).toBeCloseTo(17.5, 6);
|
||
expect(A1.vertical.positions[0]).toBeCloseTo(footing.main.positions[0] - 17.5, 6);
|
||
expect(['W1', 'W3', 'W4'].map((m) => bar(A1, m).count)).toEqual([47, 47, 47]);
|
||
});
|
||
|
||
it('높이 (구간 1): z_hook 186.55, W1 · W4 3481, W3 4464', () => {
|
||
expect(A1.zHook.H19).toBeCloseTo(186.55, 2);
|
||
expect([bar(A1, 'W1').length, bar(A1, 'W4').length, bar(A1, 'W3').length]).toEqual([3481, 3481, 4464]);
|
||
});
|
||
|
||
it('W2: 2189.4 + 246 + 735 = 3170, W1 위치 +17.5', () => {
|
||
expect(bar(A1, 'W2').length).toBe(3170);
|
||
const w1 = A1.paths.find((p) => p.mark === 'W1')!.pts[0][0];
|
||
const w2 = A1.paths.find((p) => p.mark === 'W2')!.pts[0][0];
|
||
expect(w2 - w1).toBeCloseTo(17.5, 6);
|
||
});
|
||
|
||
it('수평 단: 구간 1 17@125=2.125 · 147 (18단), 구간 5 19@125=2.375 · 181 (20단)', () => {
|
||
const lv = (no: number) => A1.levels.find((l) => l.no === no)!;
|
||
expect([lv(1).count, lv(1).text]).toEqual([18, '17@125=2.125 · 147']);
|
||
expect([lv(5).count, lv(5).text]).toEqual([20, '19@125=2.375 · 181']);
|
||
});
|
||
|
||
it('W9 · W10 (A1, IP1 쪽으로 높아지는 5구간): 가장 높은 IP1 끝 구간만 ㄷ (W9 6개), 나머지 4구간은 ㄴ (W10 24개)', () => {
|
||
expect(bar(A1, 'W9').count).toBe(6);
|
||
expect(bar(A1, 'W10').count).toBe(24);
|
||
const withSegs = (n: number): AbutWallGeom => {
|
||
const step = geom.L / n;
|
||
return { ...geom, segments: Array.from({ length: n }, (_, i) => ({ from: i * step, to: (i + 1) * step, H: 2372 + 50 * i, no: n - i })) };
|
||
};
|
||
const count = (n: number, mark: string) => abutWallRebar(withSegs(n), footing, undefined, fck27).bars.find((b) => b.mark === mark)?.count ?? 0;
|
||
expect([count(1, 'W9'), count(1, 'W10')]).toEqual([6, 0]); // 계단 없음: 양끝 끝면 → ㄷ
|
||
expect([count(2, 'W9'), count(2, 'W10')]).toEqual([6, 6]);
|
||
expect([count(4, 'W9'), count(4, 'W10')]).toEqual([6, 18]);
|
||
});
|
||
|
||
it('W9 ㄷ: 양끝 연직 갈고리 330 (끝면 쪽 W11 안쪽 · 낮은 쪽 경계 − 100); W10 ㄴ: 낮은 쪽 갈고리 330 · 높은 쪽 경계 너머 330 직선 겹침 (사용자 도면 2026-11-04)', () => {
|
||
const sg0 = geom.segments[0]; // IP1 끝, 가장 높음
|
||
const z0 = 1100 + sg0.H - 100 - 15.9;
|
||
const w9 = A1.paths.find((q) => q.mark === 'W9')!;
|
||
expect(w9.pts.length).toBe(4);
|
||
expect(w9.pts[1][0]).toBeCloseTo((100 + 15.9) / angle.sin, 6);
|
||
expect(w9.pts[2][0]).toBeCloseTo(sg0.to - 100, 6);
|
||
expect(w9.pts[0][2]).toBeCloseTo(z0 - 330, 6);
|
||
expect(w9.pts[3][2]).toBeCloseTo(z0 - 330, 6);
|
||
const k = 2; // 중간 구간: IP1 쪽 이웃이 높고, IP3 쪽 이웃이 낮음
|
||
const sg = geom.segments[k];
|
||
const z = 1100 + sg.H - 100 - 15.9;
|
||
const p = A1.paths.find((q) => q.mark === 'W10' && Math.abs(q.pts[0][2] - z) < 1e-6)!;
|
||
expect(p.pts.length).toBe(3); // ㄴ: 직선 겹침 끝 + 갈고리 끝
|
||
expect(p.pts[0][0]).toBeCloseTo(sg.from - 330, 6);
|
||
expect(p.pts[1][0]).toBeCloseTo(sg.to - 100, 6);
|
||
expect(p.pts[2][2]).toBeCloseTo(z - 330, 6);
|
||
expect(bar(A1, 'W10').count).toBe(24);
|
||
const w10 = A1.bars.find((b) => b.mark === 'W10')!;
|
||
expect([w10.length, w10.lengthMax].every((v) => v > 0)).toBe(true);
|
||
});
|
||
|
||
it('정착장 입력: 400으로 바꾸면 갈고리 · 겹침이 함께 400, RB-AB04 "수정됨"', () => {
|
||
const r = abutWallRebar(geom, footing, { la: 400 }, fck27);
|
||
const p = r.paths.find((q) => q.mark === 'W9')!;
|
||
expect(p.pts[1][2] - p.pts[0][2]).toBeCloseTo(400, 6);
|
||
expect(r.issues.map((i) => i.code)).toContain('RB-AB04');
|
||
expect(bar(r, 'W9').modified).toBe(true);
|
||
});
|
||
|
||
it('W7: 12 × 5 = 60, 2200 / sin θ + 2 × 197 = 2603 (skew, 사용자 결정 2026-11-04)', () => {
|
||
expect([bar(A1, 'W7').count, bar(A1, 'W7').length]).toEqual([60, 2603]);
|
||
});
|
||
|
||
it('W8: 3383, 낮은 끝 18 + 높은 끝 20 = 38, 끝면에서 114.3', () => {
|
||
expect([bar(A1, 'W8').count, bar(A1, 'W8').length]).toEqual([38, 3383]);
|
||
const w8 = A1.paths.find((p) => p.mark === 'W8')!;
|
||
expect(w8.pts[1][0] * angle.sin).toBeCloseTo(114.3, 6);
|
||
});
|
||
|
||
it('W11: 끝면당 13 + 3 = 16, 양끝 32, ㄷ형 (위 갈고리 246 추가) 길이 3679 / 3963', () => {
|
||
const w = bar(A1, 'W11');
|
||
expect([w.count, w.length, w.lengthMax]).toEqual([32, 3433 + 246, 3717 + 246]);
|
||
const p = A1.paths.find((q) => q.mark === 'W11')!;
|
||
expect(p.pts.length).toBe(4);
|
||
expect(p.pts[0][0]).toBeCloseTo(p.pts[3][0], 6); // both hooks turn the same way (inward)
|
||
expect(p.pts[3][0]).toBeGreaterThan(p.pts[1][0]);
|
||
const xs = A1.paths.filter((p) => p.mark === 'W11').slice(0, 16).map((p) => Math.round(p.pts[1][1]));
|
||
expect(xs).toEqual([-700, -400, -100, ...Array.from({ length: 13 }, (_, i) => i * 125)]);
|
||
});
|
||
|
||
it('A1 has no warning (RB-AB02 · RB-AB03 없음, W9 · W10은 이웃 구간으로 넘어가지 않음)', () => {
|
||
expect(A1.issues.filter((i) => i.level !== 'I').map((i) => i.code)).toEqual([]);
|
||
});
|
||
|
||
it('l_d 연동: fck 30 → W2 앞 다리 · W3 위 끝이 짧아짐', () => {
|
||
const r30 = abutWallRebar(geom, footing, undefined, { ...DEFAULT_MATERIAL, fck: 30 });
|
||
expect(r30.ld.H16).toBeLessThan(A1.ld.H16);
|
||
expect(bar(r30, 'W2').length).toBe(3170 - (A1.ld.H16 - r30.ld.H16));
|
||
expect(bar(r30, 'W3').length).toBe(4464 - (A1.ld.H19 - r30.ld.H19));
|
||
expect(bar(r30, 'W1').length).toBe(3481);
|
||
});
|
||
|
||
it('기본간격 연동: 벽체 s_L 150 → 벽체만 다시 계산, 저판 그대로, RB-AB03 검사', () => {
|
||
const r = abutWallRebar(geom, footing, { sL: 150 }, fck27);
|
||
expect(r.vertical.layout.s).toBe(150);
|
||
expect(footing.main.layout!.s).toBe(125);
|
||
expect(r.issues.map((i) => i.code)).toContain('RB-AB03');
|
||
});
|
||
|
||
it('묶음 수정 · W7 배수 → RB-AB04 "수정됨"', () => {
|
||
const r = abutWallRebar(geom, footing, { sHoriz: 150, k7L: 6 }, fck27);
|
||
expect(r.issues.map((i) => i.code)).toContain('RB-AB04');
|
||
expect(bar(r, 'W5').modified).toBe(true);
|
||
expect(bar(r, 'W7').modified).toBe(true);
|
||
expect(bar(r, 'W1').modified).toBe(false);
|
||
});
|
||
});
|
||
|
||
import { applyFootingRebar, 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 = applyWallRebar(applyFootingRebar(rec, { sW: 200 }), { sHoriz: 150, k7L: 6 });
|
||
expect(a.rebar).toEqual({ footing: { sW: 200 }, wall: { sHoriz: 150, k7L: 6 } });
|
||
expect(applyWallRebar(a, { sHoriz: undefined }).rebar?.wall).toEqual({ k7L: 6 });
|
||
expect(() => applyWallRebar(rec, { k7V: 2.5 })).toThrow('W7 배수');
|
||
expect(() => applyWallRebar(rec, { c: 0 })).toThrow();
|
||
});
|
||
|
||
it('the project file keeps the 벽체 input and refuses a non-number', () => {
|
||
expect(() => checkStoredAbutments([applyWallRebar(rec, { sL: 150, eMin: 200 })])).not.toThrow();
|
||
expect(() => checkStoredAbutments([{ ...rec, rebar: { wall: { sL: 'x' } } as never }])).toThrow('벽체 배근');
|
||
});
|
||
});
|
||
|
||
import { abutWallElevationSvg, abutWallSectionSvg } from '../src/view/abut-wall-rebar-svg';
|
||
|
||
describe('③ 벽체 삽도', () => {
|
||
it('단면: 구간 1 단 치수 "100 · 17@125=2.125 · 147", W9 · W10 간격, 번호 풍선', () => {
|
||
const svg = abutWallSectionSvg(geom, A1);
|
||
expect(svg).toContain('100 · 17@125=2.125 · 147');
|
||
expect(svg).toContain('W9 · W10 5@250');
|
||
for (const m of ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W9', 'W10']) expect(svg).toContain(`data-pick="${m}"`);
|
||
});
|
||
|
||
it('계단 단면: 구간별 높이, 양끝 단 치수, W8 · W11 풍선; 선택하면 나머지는 흐리게 / 숨김', () => {
|
||
const svg = abutWallElevationSvg(geom, A1);
|
||
expect(svg).toContain('구간 1 · 2372');
|
||
expect(svg).toContain('19@125=2.375 · 181');
|
||
for (const m of ['W8', 'W11']) expect(svg).toContain(`data-pick="${m}"`);
|
||
expect(abutWallElevationSvg(geom, A1, { marks: new Set(['W9']), only: false })).toContain('opacity="0.15"');
|
||
expect(abutWallElevationSvg(geom, A1, { marks: new Set(['W9']), only: true })).toContain('opacity="0"');
|
||
});
|
||
});
|