Files
Pier_v1/tests/abut-rebar-footing.test.ts
T
ypshimandClaude Opus 5.5 d7714f7ef2 feat(abut-rebar): ③ 교대 부재 목록 단순화 · 철근표 선택 / 전체 선택 / 선택만 보기
- 부재 목록: 교각처럼 이름 + 경고 수 배지만. 배근 타입 · 번호 · 다음 단계는 툴팁과 카드 설명 줄로
- 철근표: 선택 칸, 행 클릭 단독 선택, 전체 선택/해제, 선택만 보기 (교각과 같은 rebarSelection · rebarIsolate)
- 삽도: 여러 철근 강조, 선택만 보기면 나머지 숨김. B10 풍선은 B10-1 · B10-2를 함께 선택

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:56:57 +09:00

219 lines
12 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 { abutFootingGeom, abutFootingRebar, type AbutFootingGeom } from '../src/core/abut/rebar/footing';
import { gridLayout, skewAngle, SQUARE } from '../src/core/abut/rebar/skew-layout';
import { lapLength, splicePlan } from '../src/core/abut/rebar/splice';
// 예제 A1 (교대확대기초철근.md §13): L 12197 · W 4600 · H 1100, 벽체 1000 ~ 3400, θ 95°02′52″.
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 geom = abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg));
const A1 = abutFootingRebar(geom, undefined, undefined, 'A1');
const bar = (r: typeof A1, mark: string) => r.bars.find((b) => b.mark === mark)!;
const codes = (r: typeof A1) => r.issues.map((i) => i.code);
describe('교대 저판 배근 공통 (AD1 skew · AD2 이음)', () => {
it('skew: sin θ 0.99612; the grid of B1–B4 is 93@125 with e 185.8', () => {
expect(skewAngle(rec.skewDeg).sin).toBeCloseTo(0.99612, 5);
const g = gridLayout(geom.L, 100 / geom.angle.sin, 150, 125);
expect(g.A).toBeCloseTo(11996.5, 0);
expect([g.n, g.G, g.positions.length]).toEqual([93, 11625, 94]);
expect(g.e).toBeCloseTo(185.8, 1);
});
it('이음: l_s H16 955 / H19 1147 (fck 27, fy 400); B5·B6 13627 → 2조각 6336 / 8246', () => {
expect(lapLength(15.9, 400, 27)).toBe(955);
expect(lapLength(19.1, 400, 27)).toBe(1147);
const p = splicePlan(13627, 15.9, 400, 27)!;
expect(p.pieces).toBe(2);
expect(p.staggered.map(Math.round)).toEqual([6336, 8246]);
expect(p.over).toBe(false);
expect(splicePlan(11997, 15.9, 400, 27)).toBeNull();
});
});
describe('교대 저판 B1–B10 (교대확대기초철근.md §13, 예제 A1 옵션 ①)', () => {
it('geometry from the generated abutment: W 4600, H 1100, 벽체 1000 ~ 3400', () => {
expect([geom.W, geom.H, geom.xb, geom.xf]).toEqual([4600, 1100, 1000, 3400]);
expect(geom.L).toBeCloseTo(12197, 0);
});
it('높이: B1·B3 1000, B5·B7 984.1, B6·B8 169.05, B2·B4 150', () => {
expect(A1.z.B13).toBe(1000);
expect(A1.z.B57).toBeCloseTo(984.1, 6);
expect(A1.z.B68).toBeCloseTo(169.05, 6);
expect(A1.z.B24).toBe(150);
});
it('B1–B4: 93@125, 각 47; B1·B2 6117, B3 3212, B4 4417; 옆붙임 B1 +19.05, B5 +15.9', () => {
expect(A1.main.text).toBe('100.4 · 185.8 · 93@125=11.625 · 185.8 · 100.4');
for (const m of ['B1', 'B2', 'B3', 'B4']) expect(bar(A1, m).count).toBe(47);
expect([bar(A1, 'B1').length, bar(A1, 'B2').length, bar(A1, 'B3').length, bar(A1, 'B4').length]).toEqual([6117, 6117, 3212, 4417]);
expect(A1.sideShift.B1).toBeCloseTo(19.05, 6);
expect(A1.sideShift.B5).toBeCloseTo(15.9, 6);
});
it('B5–B8: 27@150, e 175; 하면 B6 14 / B8 14, 상면 B5 6 / B7 7; B5·B6 13627 → RB-AF08, B7·B8 11997', () => {
expect(A1.dist.bottomText).toBe('100 · 175 · 27@150=4.050 · 175 · 100');
expect(A1.dist.topText).toBe('100 · 175 · 5@150=750 · 2.400 · 6@150=900 · 175 · 100');
expect(['B5', 'B7', 'B6', 'B8'].map((m) => bar(A1, m).count)).toEqual([6, 7, 14, 14]);
expect([bar(A1, 'B5').length, bar(A1, 'B6').length, bar(A1, 'B7').length, bar(A1, 'B8').length]).toEqual([13627, 13627, 11997, 11997]);
expect(bar(A1, 'B5').splice?.staggered.map(Math.round)).toEqual([6336, 8246]);
expect(codes(A1)).toContain('RB-AF08');
});
it('B10: 4단 3@200, 위 여유 250, 깊이 120.65 / 117.5, 다리 296; B10-1 8 × 12553 (2조각 5703 / 7997), B10-2 8 × 4968', () => {
expect(A1.side.text).toBe('150 · 3@200=600');
expect(A1.side.levels).toEqual([150, 350, 550, 750]);
expect(A1.side.topGap).toBe(250);
expect(A1.side.depth1).toBeCloseTo(120.65, 6);
expect(A1.side.depth2).toBeCloseTo(117.5, 6);
expect(A1.side.hook).toBe(296);
expect([bar(A1, 'B10-1').count, bar(A1, 'B10-1').length]).toEqual([8, 12553]);
expect(bar(A1, 'B10-1').splice?.staggered.map(Math.round)).toEqual([5703, 7997]);
expect([bar(A1, 'B10-2').count, bar(A1, 'B10-2').length]).toEqual([8, 4968]);
});
it('B9: 6행 × 47 = 282, 1228, rows 0 2 4 / 22 24 26 of the B5–B8 grid, staggered by s_L', () => {
expect([bar(A1, 'B9').count, bar(A1, 'B9').length]).toEqual([282, 1228]);
expect(A1.shear.rows.map((r) => r.x)).toEqual([275, 575, 875, 3575, 3875, 4175]);
expect(A1.shear.rows[1].s[0] - A1.shear.rows[0].s[0]).toBeCloseTo(125, 6);
});
it('A1 has no warning: only RB-AF08 (정보) for B5 · B6 · B10-1', () => {
expect(A1.issues.filter((i) => i.level !== 'I')).toEqual([]);
expect(A1.issues.filter((i) => i.code === 'RB-AF08').length).toBe(3);
});
});
describe('옵션 ② 교대축 직각 · 직사각 · 합성 (§13)', () => {
const opt2 = abutFootingRebar(geom, { dir: 'perp' });
it('가운데 격자 92@125, e 45.5, 위치 93 (47 / 46); B1·B2 6100, B4 4400, B3 3200; RB-AF03', () => {
expect(opt2.main.positions).toHaveLength(93);
expect(opt2.main.positions[1] - opt2.main.positions[0]).toBeCloseTo(125, 9);
expect(opt2.main.text).toMatch(/^100 · 45\.\d · 92@125=11\.500/);
expect(['B1', 'B3', 'B2', 'B4'].map((m) => bar(opt2, m).count)).toEqual([47, 46, 47, 46]);
expect([bar(opt2, 'B1').length, bar(opt2, 'B4').length, bar(opt2, 'B3').length]).toEqual([6100, 4400, 3200]);
expect(codes(opt2)).toContain('RB-AF03');
});
it('끝 skew 철근: D 451.9, k 3, r 76.9 → 100 · 2@101 · 125, 한쪽 4개; B1-S H16 8 / B2-S H22 8, 각 4417; RB-AF05', () => {
expect(opt2.skewEnd!.D).toBeCloseTo(451.9, 0);
expect(opt2.skewEnd!.k).toBe(3);
expect(opt2.skewEnd!.r).toBeCloseTo(76.9, 0);
expect(opt2.skewEnd!.text).toBe('100 · 2@101 · 125');
expect([bar(opt2, 'B1-S').size, bar(opt2, 'B1-S').count, bar(opt2, 'B1-S').length]).toEqual(['H16', 8, 4417]);
expect([bar(opt2, 'B2-S').size, bar(opt2, 'B2-S').count, bar(opt2, 'B2-S').length]).toEqual(['H22', 8, 4417]);
expect(codes(opt2)).toContain('RB-AF05');
});
it('삽도 재현: L 12188 → cycle당 46@250, e 40.8, D 447.2 → 2@98.6 · 125', () => {
const r = abutFootingRebar({ ...geom, L: 12188 }, { dir: 'perp' });
expect(r.main.positions).toHaveLength(93);
expect(r.skewEnd!.D).toBeCloseTo(447.2, 0);
expect((125 + r.skewEnd!.r) / 2).toBeCloseTo(98.6, 0);
expect(r.skewEnd!.text).toBe('100 · 2@99 · 125');
});
it('r = 0: D a multiple of s gives k bars at s', () => {
// A synthetic skew with W·cot = 450, and L so that e = 50 (< s/2) and D = e + 450 = 4·125 exactly.
const g: AbutFootingGeom = { ...geom, angle: { sin: 0.995, cotAbs: 450 / geom.W } };
const L = 200 + 450 + 92 * 125 + 2 * 50;
const r = abutFootingRebar({ ...g, L }, { dir: 'perp' });
expect(r.skewEnd!.r).toBeCloseTo(0, 6);
expect(r.skewEnd!.k).toBe(4);
expect(r.skewEnd!.offsets).toHaveLength(4);
});
it('skew 90° 합성: 피복 100, l_0 4400; B1·B2 6100 / B3·B4 3200 · 4400, the same for both options', () => {
const sq = { ...geom, angle: SQUARE };
for (const dir of ['skew', 'perp'] as const) {
const r = abutFootingRebar(sq, { dir });
expect([bar(r, 'B1').length, bar(r, 'B3').length, bar(r, 'B4').length]).toEqual([6100, 3200, 4400]);
expect(r.skewEnd).toBeNull();
}
expect(abutFootingRebar(sq).main.text.startsWith('100 · 186.')).toBe(true);
});
it('직사각 평면: 옵션 ② 고정, 끝 skew 철근 0 → RB-AF04', () => {
const r = abutFootingRebar({ ...geom, plan: 'rect', angle: SQUARE }, { dir: 'skew' });
expect(r.dir).toBe('perp');
expect(r.skewEnd).toBeNull();
expect(codes(r)).toContain('RB-AF04');
});
it('뒷굽 짧음: 상면 뒷굽 철근 0 → RB-AF02', () => {
// The spec (§13) says 뒷굽 300, but its own rule (§5.2: keep x ≤ x_b + s_W/2) keeps the bar at x 275 ≤ 375 there.
// A 뒷굽 of 150 leaves none (275 > 225).
expect(abutFootingRebar({ ...geom, xb: 300, xf: 2700 }).dist.topKept[0]).toBe(true);
const r = abutFootingRebar({ ...geom, xb: 150, xf: 2550 });
expect(codes(r)).toContain('RB-AF02');
});
it('기본간격 연동: s_L 150 / s_W 200 / s_v 250 → every count and text recomputed, B9 on the new grid', () => {
const r = abutFootingRebar(geom, { sL: 150, sW: 200, sV: 250 });
expect(r.main.text).toContain('@150=');
expect(r.dist.bottomText).toContain('@200=');
expect(r.side.text).toBe('150 · 2@250=500');
expect(bar(r, 'B9').spacing).toBe('300 × 400');
expect(r.shear.rows[1].s[0] - r.shear.rows[0].s[0]).toBeCloseTo(150, 6);
expect(bar(r, 'B1').count).not.toBe(47);
});
it('철근별 수정: B5–B8 only at 200 → B1–B4 and B10 unchanged, RB-AF10, modified badge; removing it links back', () => {
const r = abutFootingRebar(geom, { sDist: 200 });
expect(bar(r, 'B6').count).not.toBe(14);
expect(bar(r, 'B6').modified).toBe(true);
expect(bar(r, 'B1').modified).toBe(false);
expect(bar(r, 'B1').count).toBe(47);
expect(bar(r, 'B10-1').count).toBe(8);
expect(codes(r)).toContain('RB-AF10');
expect(codes(abutFootingRebar(geom, {}))).not.toContain('RB-AF10');
});
it('부재 독립: the footing result does not read any other member spacing', () => {
expect(abutFootingRebar(geom, {}).bars).toEqual(A1.bars);
});
});
import { applyFootingRebar } from '../src/core/abut/edit';
import { checkStoredAbutments } from '../src/core/project';
import { abutFootingRebarPlanSvg, abutFootingSectionLSvg, abutFootingSectionWSvg } from '../src/view/abut-footing-rebar-svg';
describe('③ 저판 배근 입력 저장 · 삽도', () => {
it('stores only the changed values; undefined links a bar group back; refuses bad values', () => {
const a = applyFootingRebar(rec, { sDist: 200, dir: 'perp' });
expect(a.rebar?.footing).toEqual({ sDist: 200, dir: 'perp' });
expect(applyFootingRebar(a, { sDist: undefined }).rebar?.footing).toEqual({ dir: 'perp' });
expect(() => applyFootingRebar(rec, { k9L: 1.5 })).toThrow('B9 배수');
expect(() => applyFootingRebar(rec, { sL: 0 })).toThrow();
});
it('the project file keeps the 배근 input and refuses a wrong direction', () => {
expect(() => checkStoredAbutments([applyFootingRebar(rec, { sW: 200, dir: 'perp' })])).not.toThrow();
expect(() => checkStoredAbutments([{ ...rec, rebar: { footing: { dir: 'diag' } } as never }])).toThrow('저판 배근');
});
it('삽도: the dimension strings and a balloon per bar; a pick dims the others', () => {
const w = abutFootingSectionWSvg(geom, A1);
for (const s of ['100 · 175 · 27@150=4.050 · 175 · 100', '100 · 175 · 5@150=750 · 2.400 · 6@150=900 · 175 · 100', '150 · 3@200=600']) expect(w).toContain(s);
for (const m of ['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B9', 'B10']) expect(w).toContain(`data-pick="${m}"`);
expect(w).not.toContain('opacity="0.15"');
expect(abutFootingSectionWSvg(geom, A1, { marks: new Set(['B5']), only: false })).toContain('opacity="0.15"');
expect(abutFootingSectionWSvg(geom, A1, { marks: new Set(['B5']), only: true })).toContain('opacity="0"');
expect(abutFootingSectionLSvg(geom, A1)).toContain('100.4 · 185.8 · 93@125=11.625 · 185.8 · 100.4');
expect(abutFootingSectionLSvg(geom, A1)).toContain('B5·B6 이음 l_s 955');
const opt2 = abutFootingRebar(geom, { dir: 'perp' });
expect(abutFootingRebarPlanSvg(geom, opt2, -0.088)).toContain('data-pick="B1-S"');
expect(abutFootingRebarPlanSvg(geom, A1, -0.088)).not.toContain('data-pick="B1-S"');
});
});