Files
Pier_v1/tests/abut-slab.test.ts
T
ypshimandClaude Haiku 5.5 4b8b80ae97 feat(abut): 접속슬래브 카드 — 매립형 / 노출형 형식, 경사, 치수 입력, 삽도 3개, 모따기 3D 반영
- 노출형 (삽도 기준, QA-8 가정): 상면 = 흉벽 상단, 끝은 브라켓 위(끝 두께 = 브라켓 높이), 헌치 길이 기본 2000
- 형식을 고르면 경사가 기본값으로 바뀜 (매립형 하향 4 %, 노출형 종단경사), 경사 타일로 변경 가능
- 길이 · 두께 · 흉벽과 간격 · 날개벽과 간격 입력이 3D, 폭, 날개벽 평면에 반영
- 슬래브 모따기 = 날개벽 헌치 (3D와 평면), 날개벽 없는 측은 skew 교좌면 끝선
- 3D 슬래브를 단면 기준 입체로 다시 만듦, 프로젝트 파일 저장 · 검증

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

156 lines
9.5 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 * 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, setSlabSlope } 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 { abutSlabJointSvg, 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: 매립형 · 하향 4 % · 7000 × 450 · 간격 5 / 20; the slope follows the form unless set', () => {
expect(resolveSlab()).toEqual({ form: 'buried', slope: 'down4', lengthMm: 7000, thicknessMm: 450, parapetGapMm: 5, wingGapMm: 20, haunchLengthMm: 2000 });
expect(resolveSlab({ form: 'exposed' }).slope).toBe('grade');
const exposed = setSlabForm({ ...rec, slab: { slope: 'down4' } }, 'exposed');
expect(exposed.slab).toEqual({ form: 'exposed' }); // picking a form resets the slope to the form's own
expect(setSlabSlope(exposed, 'down4').slab).toEqual({ form: 'exposed', slope: 'down4' });
expect(setSlabSlope(exposed, 'grade').slab).toEqual({ form: 'exposed' });
expect(slabChangedKeys(exposed.slab)).toEqual(['form', 'slope']);
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: '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, 하향 4 %, EL labels; 노출형 adds 헌치 2000 and 끝 850', () => {
const buried = sectionOf('buried');
for (const s of ['>7000<', '>450<', '경사 하향 4 %', '흉벽 상단 EL.46.867', '브라켓 상면 EL.46.017', '간격 5', '다웰 D25 @400']) expect(buried).toContain(s);
expect(buried).not.toContain('헌치');
const exposed = sectionOf('exposed');
for (const s of ['헌치 2000', '끝 850', '경사 종단경사 +']) expect(exposed).toContain(s);
});
it('접합 상세: the accessories and the EL labels', () => {
const svg = abutSlabJointSvg({ form: 'buried', gradePct: -4, parapetTopEl: 46.867, bracketHeightM: 0.85, parapetGapMm: 5, thicknessMm: 450 });
for (const s of ['간격 5 · 아스팔트 충진재', '스티로폼', '고무판 150 × 150 × 15', '타르페이퍼 T = 15', '다웰 D25 · 강관 Φ60 L300', 'S = 4.00 % (하향)', '흉벽 상단 EL.46.867', '브라켓 상면 EL.46.017', '접속슬래브 450']) {
expect(svg).toContain(s);
}
expect(abutSlabJointSvg({ form: 'exposed', gradePct: 1.91, parapetTopEl: 46.867, bracketHeightM: 0.85, parapetGapMm: 5, thicknessMm: 450 })).toContain('노출형 · 끝 850');
});
});