/** * Task CAD-8 - Traversen-Upgrade: * 1. Laengen-Presets 50/100/200/300 cm * 2. Breite einstellbar (Standard 29 cm) * 3. Rotierbares Rendering (Koerper als ROTIERTES Polygon) * 4. Boxcorner (Eck-Traverse) mit magnetischem Andocken in BEIDE * Richtungen: Ecke dockt an Traversen/Ecken, Traversen docken * an Ecken-Armenden. */ import { describe, it, expect, beforeEach } from 'vitest'; import { trussTool, boxCornerTool, trussEndpoints, boxcornerEndpoints, snapTrussToDock, cornerDockToDock, } from '../src/plugins/builtin/event-tools'; import { CADDocument } from '../src/kernel/document/CADDocument'; import { createInMemoryDoc } from './helpers/inMemoryDoc'; import { elementToPrimitives } from '../src/render/pixi/elementDrawers'; import type { CADElement, Pt } from '../src/types/cad.types'; function mkCtx(elements: CADElement[] = [], options: Record = {}) { 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, rotationDeg = 0, trussWidth?: number): CADElement { return { id, type: 'truss', layerId: 'layer-0', x, y, width: length, height: trussWidth ?? 29, properties: { length, rotation: rotationDeg, truss: true, ...(trussWidth !== undefined ? { trussWidth } : {}), }, } as unknown as CADElement; } function makeCorner(id: string, x: number, y: number, armLength = 50, rotationDeg = 0, width = 29): CADElement { return { id, type: 'boxcorner', layerId: 'layer-0', x, y, width: 80, height: 80, properties: { armLength, rotation: rotationDeg, trussWidth: width, boxcorner: true }, } as unknown as CADElement; } beforeEach(() => { trussTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); boxCornerTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); }); // ─── 1: Laengen-Presets + Breite ────────────────────────────── describe('CAD-8: Laengen-Presets + Breite', () => { 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('number'); expect(lenField!.min).toBe(10); expect(lenField!.max).toBe(1000); }); it('Breiten-Option trussWidth (number) vorhanden', () => { const wField = trussTool.optionsSchema?.find((f) => f.key === 'trussWidth'); expect(wField).toBeDefined(); expect(wField!.type).toBe('number'); }); it('platzierte Traverse: trussWidth=29 Standard, length=200 Standard', () => { const { ctx, doc } = mkCtx(); trussTool.handlers.down!(pe(300, 300), ctx); trussTool.handlers.up!(pe(400, 300), ctx); const el = doc.getAllElements()[0]; expect(el.properties.trussWidth).toBe(29); expect(el.properties.length).toBe(200); }); it('trussWidth=40 und length=300 aus Optionen uebernommen', () => { const { ctx, doc } = mkCtx([], { trussWidth: 40, length: 300 }); trussTool.handlers.down!(pe(100, 100), ctx); trussTool.handlers.up!(pe(300, 100), ctx); const el = doc.getAllElements()[0]; expect(el.properties.trussWidth).toBe(40); expect(el.properties.length).toBe(300); }); }); // ─── 2: Rotierbares Truss-Rendering ─────────────────────────── describe('CAD-8: Rotierbares Truss-Rendering (Polygon-Koerper)', () => { it('90-Grad-Traverse: Koerperpolygon vertikal (y-Ausdehnung = Laenge, x = Breite)', () => { const el = makeTruss('t1', 100, 100, 200, 90, 29); const prims = elementToPrimitives(el); const body = prims.find((p) => p.kind === 'polyline') as { points: Pt[]; close: boolean } | undefined; expect(body).toBeDefined(); expect(body!.close).toBe(true); expect(body!.points).toHaveLength(4); const ys = body!.points.map((p) => p.y); const xs = body!.points.map((p) => p.x); expect(Math.max(...ys) - Math.min(...ys)).toBeCloseTo(200, 0); expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(29, 0); }); it('0-Grad-Traverse: Koerperpolygon horizontal', () => { const el = makeTruss('t1', 100, 100, 200, 0, 29); const body = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] }; const xs = body.points.map((p) => p.x); const ys = body.points.map((p) => p.y); expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(200, 0); expect(Math.max(...ys) - Math.min(...ys)).toBeCloseTo(29, 0); }); it('DREHBAR: Rotation aendert die Geometrie sichtbar (45 Grad diagonal)', () => { const el = makeTruss('t1', 100, 100, 200, 45, 29); const body = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] }; const xs = body.points.map((p) => p.x); const expected = 200 * Math.cos(Math.PI / 4) + 29 * Math.sin(Math.PI / 4); expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(expected, 0); }); it('Breite im Rendering: trussWidth=40 ergibt 40 Einheiten Quer-Ausdehnung', () => { const el = makeTruss('t1', 100, 100, 200, 0, 40); const body = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] }; const ys = body.points.map((p) => p.y); expect(Math.max(...ys) - Math.min(...ys)).toBeCloseTo(40, 0); }); }); // ─── 3: Boxcorner-Element ───────────────────────────────────── describe('CAD-8: Boxcorner-Element (Eck-Traverse)', () => { it('Manifest: id=boxcorner, Optionen armLength + trussWidth', () => { expect(boxCornerTool.manifest.id).toBe('boxcorner'); const keys = (boxCornerTool.optionsSchema ?? []).map((f) => f.key); expect(keys).toContain('armLength'); expect(keys).toContain('trussWidth'); }); it('Drag-Platzierung: boxcorner mit armLength=50/trussWidth=29 Standard, Rotation aus Drag', () => { const { ctx, doc } = mkCtx(); boxCornerTool.handlers.down!(pe(100, 100), ctx); boxCornerTool.handlers.up!(pe(200, 100), ctx); const els = doc.getAllElements(); expect(els).toHaveLength(1); const el = els[0]; expect(el.type).toBe('boxcorner'); expect(el.properties.armLength).toBe(50); expect(el.properties.trussWidth).toBe(29); expect(el.properties.rotation).toBeCloseTo(0, 5); }); it('Drag nach unten: Rotation ~90', () => { const { ctx, doc } = mkCtx(); boxCornerTool.handlers.down!(pe(100, 100), ctx); boxCornerTool.handlers.up!(pe(100, 200), ctx); expect(doc.getAllElements()[0].properties.rotation).toBeCloseTo(90, 5); }); it('Optionen uebernommen: armLength=100, trussWidth=40', () => { const { ctx, doc } = mkCtx([], { armLength: 100, trussWidth: 40 }); boxCornerTool.handlers.down!(pe(100, 100), ctx); boxCornerTool.handlers.up!(pe(200, 100), ctx); const el = doc.getAllElements()[0]; expect(el.properties.armLength).toBe(100); expect(el.properties.trussWidth).toBe(40); }); it('boxcornerEndpoints: Arme bei rot und rot+90 vom Eckpunkt', () => { const el = makeCorner('c1', 100, 100, 100, 0); const [a1, a2] = boxcornerEndpoints(el); expect(a1.x).toBeCloseTo(200, 5); expect(a1.y).toBeCloseTo(100, 5); expect(a2.x).toBeCloseTo(100, 5); expect(a2.y).toBeCloseTo(200, 5); }); it('DREHBAR: Rotation 90 dreht die Arm-Endpunkte mit', () => { const el = makeCorner('c1', 100, 100, 100, 90); const [a1, a2] = boxcornerEndpoints(el); expect(a1.x).toBeCloseTo(100, 5); expect(a1.y).toBeCloseTo(200, 5); expect(a2.x).toBeCloseTo(0, 5); expect(a2.y).toBeCloseTo(100, 5); }); it('1 Undo entfernt die Boxcorner (EINE Transaktion)', () => { const { ctx, doc } = mkCtx(); boxCornerTool.handlers.down!(pe(100, 100), ctx); boxCornerTool.handlers.up!(pe(200, 100), ctx); expect(doc.getAllElements()).toHaveLength(1); doc.undo(); expect(doc.getAllElements()).toHaveLength(0); }); it('cancel verwirft die Platzierung', () => { const { ctx, doc } = mkCtx(); boxCornerTool.handlers.down!(pe(100, 100), ctx); boxCornerTool.handlers.cancel?.(ctx); boxCornerTool.handlers.up!(pe(200, 100), ctx); expect(doc.getAllElements()).toHaveLength(0); }); }); // ─── 4: Boxcorner-Magnetismus (beide Richtungen) ───────────── describe('CAD-8: Boxcorner-Magnetismus', () => { it('cornerDockToDock: Ecke dockt an Traversen-Ende, Rotation = Weg-Richtung', () => { const t1 = makeTruss('t1', 100, 100, 200, 0); const dock = cornerDockToDock([t1], { x: 210, y: 105 }, 25); expect(dock).not.toBeNull(); expect(dock!.vertex.x).toBeCloseTo(200, 5); expect(dock!.vertex.y).toBeCloseTo(100, 5); expect(dock!.rotationDeg).toBeCloseTo(0, 5); expect(dock!.dockedTo).toBe('t1'); }); it('am Start-Ende der Traverse: Rotation 180 (weg von der Traverse)', () => { const t1 = makeTruss('t1', 100, 100, 200, 0); const dock = cornerDockToDock([t1], { x: -10, y: 98 }, 25); expect(dock!.vertex.x).toBeCloseTo(0, 5); expect(dock!.vertex.y).toBeCloseTo(100, 5); expect(dock!.rotationDeg).toBeCloseTo(180, 5); }); it('ausserhalb der Toleranz: kein Dock (null)', () => { const t1 = makeTruss('t1', 100, 100, 200, 0); expect(cornerDockToDock([t1], { x: 500, y: 500 }, 25)).toBeNull(); }); it('Traverse dockt an Boxcorner-Armende (snapTrussToDock kennt Ecken)', () => { const c1 = makeCorner('c1', 100, 100, 50, 0); const dock = snapTrussToDock([c1], { x: 150, y: 100 }, 200, 25); expect(dock).not.toBeNull(); expect(dock!.rotationDeg).toBeCloseTo(0, 5); expect(dock!.center.x).toBeCloseTo(250, 3); expect(dock!.center.y).toBeCloseTo(100, 3); expect(dock!.dockedTo).toBe('c1'); }); it('Ecke dockt an Ecken-Armende', () => { const c1 = makeCorner('c1', 100, 100, 50, 0); const dock = cornerDockToDock([c1], { x: 152, y: 102 }, 25); expect(dock!.vertex.x).toBeCloseTo(150, 5); expect(dock!.vertex.y).toBeCloseTo(100, 5); expect(dock!.rotationDeg).toBeCloseTo(0, 5); expect(dock!.dockedTo).toBe('c1'); }); it('Integration: platzierte Ecke wird vom trussTool magnetisch gefunden', () => { const { ctx, doc } = mkCtx(); boxCornerTool.handlers.down!(pe(100, 100), ctx); boxCornerTool.handlers.up!(pe(200, 100), ctx); trussTool.handlers.down!(pe(150, 100), ctx); trussTool.handlers.up!(pe(250, 100), ctx); const els = doc.getAllElements(); expect(els).toHaveLength(2); const truss = els.find((e) => e.type === 'truss')!; expect(truss.properties.rotation).toBeCloseTo(0, 5); expect(truss.x).toBeCloseTo(250, 3); expect(truss.y).toBeCloseTo(100, 3); }); it('Integration: boxCornerTool dockt an bestehender Traverse', () => { const t1 = makeTruss('t1', 100, 100, 200, 0); const { ctx, doc } = mkCtx([t1]); boxCornerTool.handlers.down!(pe(210, 105), ctx); boxCornerTool.handlers.up!(pe(300, 100), ctx); const corner = doc.getAllElements().find((e) => e.type === 'boxcorner')!; expect(corner).toBeDefined(); expect(corner.x).toBeCloseTo(200, 3); expect(corner.y).toBeCloseTo(100, 3); expect(corner.properties.rotation).toBeCloseTo(0, 5); }); }); // ─── 5: Boxcorner-Rendering ───────────────────────────────── describe('CAD-8: Boxcorner-Rendering (L-Form)', () => { it('L-foermiges Polygon: 8 Punkte, geschlossen, am Eckpunkt', () => { const el = makeCorner('c1', 100, 100, 50, 0, 29); const prims = elementToPrimitives(el); 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(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); }); it('Rotation 90: L-Form zeigt nach unten-links statt rechts-unten', () => { const el = makeCorner('c1', 100, 100, 50, 90, 29); const poly = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] }; const xs = poly.points.map((p) => p.x); const ys = poly.points.map((p) => p.y); // Bei rot 90: Arme zeigen nach unten (y+) und links (x-) expect(Math.max(...ys)).toBeGreaterThan(140); expect(Math.min(...xs)).toBeLessThan(60); }); });