- 부재 목록: 교각처럼 이름 + 경고 수 배지만. 배근 타입 · 번호 · 다음 단계는 툴팁과 카드 설명 줄로 - 철근표: 선택 칸, 행 클릭 단독 선택, 전체 선택/해제, 선택만 보기 (교각과 같은 rebarSelection · rebarIsolate) - 삽도: 여러 철근 강조, 선택만 보기면 나머지 숨김. B10 풍선은 B10-1 · B10-2를 함께 선택 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
12 KiB
TypeScript
219 lines
12 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, 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"');
|
||
});
|
||
});
|