Files
Pier_v1/tests/abut-slab.test.ts
ypshimandClaude Haiku 5.5 9835fb42b3 feat(abut): 접속슬래브 경사는 형식이 정함, 단면 · 접합 상세를 파단선 단면 하나로 합침, 흉벽 높이 입력
- 경사 선택 삭제: 매립형 = 하향 4 %, 노출형 = 종단경사 따라 (노트 표기). 예전 파일의 slope 값은 읽고 무시
- 단면 + 접합 상세: 흉벽 쪽과 먼 끝만 그리고 중간 생략, 부속재 · 치수(7000 · 450 · 850 · 295 · 헌치) · EL을 한 그림에
- 흉벽 높이 (브라켓 상면 ~ 흉벽 상단) 입력 = 벽체·흉벽 카드 브라켓 높이와 같은 값
- 자동 값: 브라켓 얹힘 길이 = 브라켓 폭 − 흉벽과 간격 (295)

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

150 lines
9.1 KiB
TypeScript
Raw Permalink 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 * as THREE from 'three';
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 { applyShapeEdit, applySlabEdit, setSlabForm } from '../src/core/abut/edit';
import { abutMemberRows } from '../src/core/abut/member-list';
import { resolveSlab, slabChangedKeys, slabGradePct, slabIssues, slabSection, slabSlopeLabel } from '../src/core/abut/slab';
import { checkStoredAbutments } from '../src/core/project';
import { abutPlanOutline, buildAbutGroup } from '../src/view/abut-scene';
import { abutSlabPlanSvg, abutSlabSectionSvg } from '../src/view/abut-slab-svg';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const build = (r = rec) => {
const model = buildAbut(toAbutInput(r));
const members = buildMembers(model, { bottomEl: r.foundationBottomEl, pileLengthMm: r.pileLengthMm }, r.shape);
const wings = buildWings(model, members, r.wings, r.wingDims);
return { model, members, wings };
};
const SHAPE = { parapetBackM: -0.5, wallBackM: -0.8, bracketHeightM: 0.85 };
describe('접속슬래브 입력 (2026-11-04, spec_abut §4.8, 시안 ② 접속슬래브 카드)', () => {
it('defaults: 매립형 · 7000 × 450 · 간격 5 / 20; the form sets the slope, an old stored slope is dropped', () => {
expect(resolveSlab()).toEqual({ form: 'buried', lengthMm: 7000, thicknessMm: 450, parapetGapMm: 5, wingGapMm: 20, haunchLengthMm: 2000 });
const old = { form: 'exposed', slope: 'down4' } as unknown as Parameters<typeof resolveSlab>[0];
expect(resolveSlab(old)).toEqual({ ...resolveSlab(), form: 'exposed' });
expect(setSlabForm({ ...rec, slab: old }, 'exposed').slab).toEqual({ form: 'exposed' });
expect(slabGradePct(resolveSlab(old), 1.91)).toBe(1.91); // 노출형 = 종단경사, whatever was stored
expect(slabChangedKeys({ form: 'exposed' })).toEqual(['form']);
expect(slabChangedKeys(undefined)).toEqual([]);
});
it('refuses a slab that does not fit under the 흉벽 상단, and a 헌치 as long as the slab', () => {
expect(slabIssues(resolveSlab({ thicknessMm: 900 }), 850).join(' ')).toContain('브라켓 높이');
expect(slabIssues(resolveSlab({ form: 'exposed', haunchLengthMm: 7000 }), 850).join(' ')).toContain('헌치 길이');
expect(() => applySlabEdit(rec, 'lengthMm', 0)).toThrow('길이');
expect(() => applyShapeEdit({ ...rec, slab: { thicknessMm: 600 } }, 'bracketHeightMm', 500)).toThrow('접속슬래브 두께');
});
it('slope text and value: 매립형 하향 4 % = −4; 노출형 종단경사 = the wing slope (A1 = −i)', () => {
const { model } = build();
expect(slabGradePct(resolveSlab(), model.wing!.slopePct)).toBe(-4);
expect(slabGradePct(resolveSlab({ form: 'exposed' }), 1.91)).toBe(1.91);
expect(slabSlopeLabel(resolveSlab({ form: 'exposed' }), 1.91)).toBe('종단경사 +1.91 %');
});
it('매립형 section: on the 브라켓 상면, flat to the 벽체 배면, then falling 4 %; 450 thick everywhere', () => {
const st = slabSection(resolveSlab(), -4, SHAPE);
expect(st.map((p) => p.t)).toEqual([-0.505, -0.8, -7.505]);
for (const p of st) expect(p.top - p.bottom).toBeCloseTo(0.45, 9);
expect(st[0].bottom).toBeCloseTo(-0.85, 9);
expect(st[1].bottom).toBeCloseTo(-0.85, 9);
expect(st[2].bottom).toBeCloseTo(-0.85 - 0.04 * 6.705, 9);
});
it('노출형 section (삽도): top = 흉벽 상단 at the near end, end on the 브라켓 (850 thick), 헌치 2000 down to 450', () => {
const st = slabSection(resolveSlab({ form: 'exposed' }), 1.91, SHAPE);
expect(st.map((p) => p.t)).toEqual([-0.505, -0.8, -2.8, -7.505]);
expect(st[0].top).toBeCloseTo(0, 9);
expect(st[0].bottom).toBeCloseTo(-0.85, 9);
expect(st[1].bottom).toBeCloseTo(-0.85, 9);
expect(st[2].top - st[2].bottom).toBeCloseTo(0.45, 9);
expect(st[3].top - st[3].bottom).toBeCloseTo(0.45, 9);
expect(st[3].top).toBeCloseTo(0.0191 * 7, 9); // rises with the grade
});
it('a wider 날개벽과 간격 narrows the slab by twice the change', () => {
const base = build().model.wing!.slabWidthMm;
const wider = build({ ...rec, slab: { wingGapMm: 70 } }).model.wing!.slabWidthMm;
expect(base - wider).toBeCloseTo(100, 0);
});
it('plan: 모따기 at both 흉벽 corners (6 corners); none when the wing haunch is 0', () => {
const { model, members, wings } = build();
expect(abutPlanOutline(model, members, wings).slab).toHaveLength(6);
const flat = build({ ...rec, wingDims: { left: { haunchMm: 0 }, right: { haunchMm: 0 } } });
expect(abutPlanOutline(flat.model, flat.members, flat.wings).slab).toHaveLength(4);
const long = abutPlanOutline(model, members, wings, resolveSlab({ lengthMm: 8000, parapetGapMm: 10 })).slab;
expect(Math.max(...long.map(([, t]) => t))).toBeCloseTo(members.shape.parapetBackM - 0.01, 9);
expect(Math.min(...long.map(([, t]) => t))).toBeCloseTo(members.shape.parapetBackM - 8.01, 9);
});
it('3D 노출형: the slab top reaches the 흉벽 상단 and its bottom stays on the 브라켓 over the back strip', () => {
const { model, members, wings } = build();
const group = buildAbutGroup(model, members, wings, undefined, undefined, undefined, { form: 'exposed' });
const mesh = (group.getObjectByName('slab') as THREE.Group).children[0] as THREE.Mesh;
const pos = mesh.geometry.getAttribute('position');
const top = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length;
let nearTop = -Infinity;
for (let i = 0; i < pos.count; i++) {
const s = pos.getX(i);
const t = pos.getZ(i);
if (t > members.shape.wallBackM + 1e-6) expect(pos.getY(i)).toBeGreaterThanOrEqual(top(s) - members.shape.bracketHeightM - 1e-4);
if (Math.abs(t - (members.shape.parapetBackM - 0.005)) < 1e-6) nearTop = Math.max(nearTop, pos.getY(i) - top(s));
}
expect(nearTop).toBeCloseTo(0, 4);
const box = new THREE.Box3().setFromObject(mesh);
expect(box.min.z).toBeCloseTo(members.shape.parapetBackM - 0.005 - 7, 4);
});
it('the member row shows the form, slope and width, and marks a change', () => {
const row = abutMemberRows({ ...rec, slab: { form: 'exposed' } }, '11.253 m').find((r) => r.id === 'slab')!;
expect(row.summary).toBe('노출형 · 종단경사 · 폭 11.253 m');
expect(row.changed).toBe(true);
});
it('the project file keeps the slab input and refuses a wrong form', () => {
expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'exposed', lengthMm: 8000 } }])).not.toThrow();
expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'exposed', slope: 'down4' } as never }])).not.toThrow(); // earlier files
expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'open' } }])).toThrow('접속슬래브');
expect(() => checkStoredAbutments([{ ...rec, slab: { lengthMm: 'x' } }])).toThrow('접속슬래브');
});
});
describe('접속슬래브 삽도 (평면, 단면 + 접합 상세)', () => {
const { model, members, wings } = build();
const sh = members.shape;
const sectionOf = (form: 'buried' | 'exposed') => {
const slab = resolveSlab({ form });
return abutSlabSectionSvg({
form, slopeLabel: slabSlopeLabel(slab, model.wing!.slopePct), section: slabSection(slab, slabGradePct(slab, model.wing!.slopePct), sh),
parapetTopEl: 46.867, bracketHeightM: sh.bracketHeightM, parapetBackM: sh.parapetBackM, wallBackM: sh.wallBackM,
lengthMm: slab.lengthMm, thicknessMm: slab.thicknessMm, parapetGapMm: slab.parapetGapMm, haunchLengthMm: slab.haunchLengthMm,
});
};
it('평면: width, length and both chamfers', () => {
const svg = abutSlabPlanSvg({
outline: abutPlanOutline(model, members, wings), leftSEnd: wings.find((w) => w.side === 'left')!.sEnd,
widthM: model.wing!.slabWidthMm / 1000, lengthMm: 7000, chamferMm: { left: 300, right: 300 },
});
for (const s of [`폭 ${(model.wing!.slabWidthMm / 1000).toFixed(3)} m`, '>7000<', '모따기 300 × 300', '접속슬래브']) expect(svg).toContain(s);
});
it('단면 + 접합 상세 (중간 생략): 7000, 450, 흉벽 높이 850, 얹힘 295, accessories and EL; 노출형 adds 헌치 2000', () => {
const buried = sectionOf('buried');
for (const s of ['7000 (중간 생략)', '>450<', '>850<', '>295<', '경사 하향 4 %', '흉벽 상단 EL.46.867', '브라켓 상면 EL.46.017',
'간격 5 · 아스팔트 충진재', '스티로폼', '고무판 150 × 150 × 15', '타르페이퍼 T = 15', '다웰 D25 @400 · 강관 Φ60 L300']) expect(buried).toContain(s);
expect(buried).not.toContain('헌치');
expect(buried.match(/<polygon /g)).toHaveLength(3); // 흉벽 · 벽체, slab far piece, slab near piece
const exposed = sectionOf('exposed');
for (const s of ['헌치 2000', '경사 종단경사 +', '>295<']) expect(exposed).toContain(s);
});
});