task(CAD-11): truss professional upgrade - chordCount (1-4 belt tubes) with adjustable rendering, free length input (10-1000cm), variable angle corners (15-165), circle truss tool with diameter option, BOM with chordCount grouping + Pi*d circumference
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Task CAD-11 - Truss Professional Upgrade:
|
||||
* 1. chordCount (Anzahl Gurtrohre, 1-4) einstellbar
|
||||
* 2. Laenge als freier Zahleneintrag (nicht nur Presets)
|
||||
* 3. Corner mit variablem Winkel (nicht nur 90 Grad)
|
||||
* 4. Kreis-Traverse als eigenes Tool
|
||||
* 5. BOM beruecksichtigt chordCount und Kreistraversen
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
trussTool,
|
||||
boxCornerTool,
|
||||
circleTrussTool,
|
||||
trussEndpoints,
|
||||
boxcornerEndpoints,
|
||||
} from '../src/plugins/builtin/event-tools';
|
||||
import { buildBOM } from '../src/services/bomService';
|
||||
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx(elements: CADElement[] = [], options: Record<string, unknown> = {}) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
for (const el of elements) doc.addElement(el);
|
||||
const statuses: string[] = [];
|
||||
const previews: (CADElement | null)[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options,
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (el: CADElement | null) => previews.push(el),
|
||||
};
|
||||
return { ctx, doc, statuses, previews };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number): { world: Pt } {
|
||||
return { world: { x, y } } as never;
|
||||
}
|
||||
|
||||
function makeTruss(id: string, x: number, y: number, length: number, rotation: number, width = 29, chordCount = 2): CADElement {
|
||||
return {
|
||||
id, type: 'truss', layerId: 'l', x, y, width: length, height: width,
|
||||
properties: { length, rotation, trussWidth: width, chordCount, truss: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function makeCorner(id: string, x: number, y: number, armLength = 50, rotation = 0, angle = 90, width = 29): CADElement {
|
||||
return {
|
||||
id, type: 'boxcorner', layerId: 'l', x, y, width: 80, height: 80,
|
||||
properties: { armLength, rotation, angle, trussWidth: width, boxcorner: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function makeCircleTruss(id: string, x: number, y: number, diameter: number, width = 29, chordCount = 2): CADElement {
|
||||
return {
|
||||
id, type: 'circletruss', layerId: 'l', x, y, width: diameter, height: diameter,
|
||||
properties: { diameter, trussWidth: width, chordCount, circletruss: true },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const t of [trussTool, boxCornerTool, circleTrussTool]) {
|
||||
t.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 1: chordCount (Gurtrohre) ──────────────────────────────
|
||||
|
||||
describe('CAD-11: chordCount', () => {
|
||||
it('trussTool hat chordCount-Option (number, 1-4, Default 2)', () => {
|
||||
const field = trussTool.optionsSchema?.find((f) => f.key === 'chordCount');
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe('number');
|
||||
});
|
||||
|
||||
it('platzierte Traverse: chordCount=2 Standard', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.chordCount).toBe(2);
|
||||
});
|
||||
|
||||
it('chordCount=3 wird uebernommen', () => {
|
||||
const { ctx, doc } = mkCtx([], { chordCount: 3 });
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.chordCount).toBe(3);
|
||||
});
|
||||
|
||||
it('Rendering: chordCount=2 zeigt 2 Gurtlinien, chordCount=4 zeigt 4', () => {
|
||||
const t2 = makeTruss('t2', 100, 100, 200, 0, 29, 2);
|
||||
const t4 = makeTruss('t4', 100, 100, 200, 0, 29, 4);
|
||||
const prims2 = elementToPrimitives(t2).filter((p) => p.kind === 'line' && p.stroke === '#4b5563');
|
||||
const prims4 = elementToPrimitives(t4).filter((p) => p.kind === 'line' && p.stroke === '#4b5563');
|
||||
expect(prims2.length).toBe(2);
|
||||
expect(prims4.length).toBe(4);
|
||||
});
|
||||
|
||||
it('chordCount=1: eine Mittellinie', () => {
|
||||
const t1 = makeTruss('t1', 100, 100, 200, 0, 29, 1);
|
||||
const lines = elementToPrimitives(t1).filter((p) => p.kind === 'line' && p.stroke === '#4b5563');
|
||||
expect(lines.length).toBe(1);
|
||||
const line = lines[0] as { from: Pt; to: Pt };
|
||||
expect(line.from.y).toBeCloseTo(100, 0); // Mittellinie auf y=100
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2: Freie Laengeneingabe ─────────────────────────────────
|
||||
|
||||
describe('CAD-11: Laenge als freier Zahleneintrag', () => {
|
||||
it('length ist number (nicht select) — Presets als separate Quick-Buttons', () => {
|
||||
const field = trussTool.optionsSchema?.find((f) => f.key === 'length');
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe('number');
|
||||
});
|
||||
|
||||
it('Custom-Laenge 175 wird uebernommen', () => {
|
||||
const { ctx, doc } = mkCtx([], { length: 175 });
|
||||
trussTool.handlers.down!(pe(100, 100), ctx);
|
||||
trussTool.handlers.up!(pe(300, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.length).toBe(175);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3: Corner mit variablem Winkel ─────────────────────────
|
||||
|
||||
describe('CAD-11: Corner variablem Winkel', () => {
|
||||
it('boxCornerTool hat angle-Option (number, Default 90)', () => {
|
||||
const field = boxCornerTool.optionsSchema?.find((f) => f.key === 'angle');
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe('number');
|
||||
});
|
||||
|
||||
it('boxcornerEndpoints: 90-Grad-Ecke = Arm2 bei rot+90', () => {
|
||||
const c = makeCorner('c1', 100, 100, 50, 0, 90);
|
||||
const [a1, a2] = boxcornerEndpoints(c);
|
||||
expect(a1.x).toBeCloseTo(150, 0);
|
||||
expect(a1.y).toBeCloseTo(100, 0);
|
||||
expect(a2.x).toBeCloseTo(100, 0);
|
||||
expect(a2.y).toBeCloseTo(150, 0);
|
||||
});
|
||||
|
||||
it('boxcornerEndpoints: 45-Grad-Ecke = Arm2 bei rot+45', () => {
|
||||
const c = makeCorner('c1', 100, 100, 50, 0, 45);
|
||||
const [a1, a2] = boxcornerEndpoints(c);
|
||||
expect(a1.x).toBeCloseTo(150, 0);
|
||||
expect(a1.y).toBeCloseTo(100, 0);
|
||||
const halfDiag = 50 * Math.cos(Math.PI / 4);
|
||||
expect(a2.x).toBeCloseTo(100 + halfDiag, 0);
|
||||
expect(a2.y).toBeCloseTo(100 + halfDiag, 0);
|
||||
});
|
||||
|
||||
it('platzierte 30-Grad-Ecke: angle=30 in properties', () => {
|
||||
const { ctx, doc } = mkCtx([], { angle: 30 });
|
||||
boxCornerTool.handlers.down!(pe(100, 100), ctx);
|
||||
boxCornerTool.handlers.up!(pe(200, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.angle).toBe(30);
|
||||
});
|
||||
|
||||
it('Default-Winkel ist 90 (abwaertskompatibel)', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
boxCornerTool.handlers.down!(pe(100, 100), ctx);
|
||||
boxCornerTool.handlers.up!(pe(200, 100), ctx);
|
||||
expect(doc.getAllElements()[0].properties.angle).toBe(90);
|
||||
});
|
||||
|
||||
it('Rendering: 90-Grad = L-Form, 45-Grad = spitzeres V', () => {
|
||||
const c90 = makeCorner('c90', 100, 100, 50, 0, 90, 29);
|
||||
const c45 = makeCorner('c45', 100, 100, 50, 0, 45, 29);
|
||||
const p90 = elementToPrimitives(c90).find((p) => p.kind === 'polyline') as { points: Pt[] };
|
||||
const p45 = elementToPrimitives(c45).find((p) => p.kind === 'polyline') as { points: Pt[] };
|
||||
// Beide sind Polygone mit >= 6 Punkten
|
||||
expect(p90.points.length).toBeGreaterThanOrEqual(6);
|
||||
expect(p45.points.length).toBeGreaterThanOrEqual(6);
|
||||
// 45-Grad-Ecke: zweiter Arm diagonal → Y-Ausdehnung kleiner als bei 90°
|
||||
const maxY90 = Math.max(...p90.points.map((p) => p.y));
|
||||
const maxY45 = Math.max(...p45.points.map((p) => p.y));
|
||||
expect(maxY45).toBeLessThan(maxY90);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4: Kreis-Traverse ──────────────────────────────────────
|
||||
|
||||
describe('CAD-11: Kreis-Traverse', () => {
|
||||
it('circleTrussTool existiert mit Manifest', () => {
|
||||
expect(circleTrussTool.manifest.id).toBe('circle-truss');
|
||||
const keys = (circleTrussTool.optionsSchema ?? []).map((f) => f.key);
|
||||
expect(keys).toContain('diameter');
|
||||
expect(keys).toContain('trussWidth');
|
||||
});
|
||||
|
||||
it('Klick platziert Kreistraverse mit diameter/trussWidth/chordCount', () => {
|
||||
const { ctx, doc } = mkCtx([], { diameter: 300, trussWidth: 29, chordCount: 2 });
|
||||
circleTrussTool.handlers.down!(pe(200, 200), ctx);
|
||||
const els = doc.getAllElements();
|
||||
expect(els).toHaveLength(1);
|
||||
expect(els[0].type).toBe('circletruss');
|
||||
expect(els[0].properties.diameter).toBe(300);
|
||||
expect(els[0].properties.trussWidth).toBe(29);
|
||||
expect(els[0].properties.chordCount).toBe(2);
|
||||
});
|
||||
|
||||
it('Kreis-Traverse: Rendering zeigt Aussen- und Innenkreis + Gurtrohr-Kreise', () => {
|
||||
const ct = makeCircleTruss('ct1', 200, 200, 300, 29, 2);
|
||||
const prims = elementToPrimitives(ct);
|
||||
const circles = prims.filter((p) => p.kind === 'circle');
|
||||
// Aussen + Innen + chordCount-Kreise = mindestens 4
|
||||
expect(circles.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('1 Undo entfernt die Kreistraverse', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
circleTrussTool.handlers.down!(pe(200, 200), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(1);
|
||||
doc.undo();
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('diameter-Default ist 200', () => {
|
||||
const { ctx, doc } = mkCtx();
|
||||
circleTrussTool.handlers.down!(pe(200, 200), ctx);
|
||||
expect(doc.getAllElements()[0].properties.diameter).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5: BOM-Integration ─────────────────────────────────────
|
||||
|
||||
describe('CAD-11: BOM mit chordCount + Kreistraversen', () => {
|
||||
it('BOM gruppiert nach chordCount (2-rohr vs 4-rohr getrennt)', () => {
|
||||
const els = [
|
||||
makeTruss('t1', 0, 0, 200, 0, 29, 2),
|
||||
makeTruss('t2', 0, 0, 200, 0, 29, 4),
|
||||
];
|
||||
const bom = buildBOM(els);
|
||||
expect(bom.rows.filter((r) => r.type === 'Traverse')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('BOM enthaelt Kreistraversen mit Durchmesser', () => {
|
||||
const els = [makeCircleTruss('ct1', 200, 200, 300, 29, 2)];
|
||||
const bom = buildBOM(els);
|
||||
expect(bom.rows).toHaveLength(1);
|
||||
expect(bom.rows[0].label).toContain('Kreis');
|
||||
expect(bom.rows[0].label).toContain('300');
|
||||
});
|
||||
|
||||
it('Kreistraverse-Umfang zaehlt in Gesamtlaenge (Pi * d)', () => {
|
||||
const els = [makeCircleTruss('ct1', 200, 200, 300, 29, 2)];
|
||||
const bom = buildBOM(els);
|
||||
const expected = (Math.PI * 300) / 100; // ~9.42m
|
||||
expect(bom.totalLengthM).toBeCloseTo(expected, 1);
|
||||
});
|
||||
});
|
||||
@@ -67,11 +67,12 @@ beforeEach(() => {
|
||||
// ─── 1: Laengen-Presets + Breite ──────────────────────────────
|
||||
|
||||
describe('CAD-8: Laengen-Presets + Breite', () => {
|
||||
it('Laengen-Select: genau 50/100/200/300 cm', () => {
|
||||
it('Laengen-Select: freie Eingabe mit 50-300 Bereich (CAD-11: number)', () => {
|
||||
const lenField = trussTool.optionsSchema?.find((f) => f.key === 'length');
|
||||
expect(lenField).toBeDefined();
|
||||
expect(lenField!.type).toBe('select');
|
||||
expect((lenField!.options ?? []).map((o) => o.value)).toEqual(['50', '100', '200', '300']);
|
||||
expect(lenField!.type).toBe('number');
|
||||
expect(lenField!.min).toBe(10);
|
||||
expect(lenField!.max).toBe(1000);
|
||||
});
|
||||
|
||||
it('Breiten-Option trussWidth (number) vorhanden', () => {
|
||||
@@ -296,7 +297,7 @@ describe('CAD-8: Boxcorner-Rendering (L-Form)', () => {
|
||||
const poly = prims.find((p) => p.kind === 'polyline') as { points: Pt[]; close: boolean };
|
||||
expect(poly).toBeDefined();
|
||||
expect(poly.close).toBe(true);
|
||||
expect(poly.points).toHaveLength(6); // L-Geometrie: 6 Punkte
|
||||
expect(poly.points).toHaveLength(8); // CAD-11: variable-angle Geometrie hat 8 Punkte
|
||||
const minDist = Math.min(...poly.points.map((p) => Math.hypot(p.x - 100, p.y - 100)));
|
||||
expect(minDist).toBeLessThanOrEqual(29);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user