Files
Pier_v1/tests/abut-rebar-footing.test.ts
T
ypshimandClaude Haiku 5.5 b06bf368b6 feat(abut-rebar): ③ 교대 화면 틀과 확대기초 배근 카드
- ③에서 교대를 고르면 부재 · 교대 목록(받침블럭 → 벽체 → 흉벽·브라켓 → 확대기초 → 날개벽 → 접속슬래브)과 카드를 보여 줌.
  확대기초만 규칙이 있고 나머지는 '다음 단계'. 선택한 교대만 3D에 그림
- 확대기초 카드: 보기 탭 3개(교대축 직각 단면 · 교대축 단면 · 상면 평면)와 번호 풍선 강조, 범례,
  기본간격 · 배근 방향 ① / ② · 피복 · 여유 · B9 배수 입력, 자동 값(cycle 간격 · B10 위 여유 · l_s · 옆붙임),
  철근표(묶음별 실간격 수정 · 수정됨 · 되돌리기, 비고 펼치기), 근거 줄, RB-AF 알림
- 입력은 AbutRecord.rebar.footing에 바꾼 값만 저장 · 프로젝트 파일 검증. fck는 공통 설정의 기초부 값

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

218 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, 'B5')).toContain('opacity="0.15"');
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"');
});
});