- 경사 선택 삭제: 매립형 = 하향 4 %, 노출형 = 종단경사 따라 (노트 표기). 예전 파일의 slope 값은 읽고 무시 - 단면 + 접합 상세: 흉벽 쪽과 먼 끝만 그리고 중간 생략, 부속재 · 치수(7000 · 450 · 850 · 295 · 헌치) · EL을 한 그림에 - 흉벽 높이 (브라켓 상면 ~ 흉벽 상단) 입력 = 벽체·흉벽 카드 브라켓 높이와 같은 값 - 자동 값: 브라켓 얹힘 길이 = 브라켓 폭 − 흉벽과 간격 (295) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
150 lines
9.1 KiB
TypeScript
150 lines
9.1 KiB
TypeScript
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);
|
||
});
|
||
});
|