task(CAD-8): truss upgrade - length presets 50/100/200/300cm, adjustable width (default 29cm), rotatable rendering as direction polygon, boxcorner corner element with bidirectional magnetic docking
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 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<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, 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: genau 50/100/200/300 cm', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
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(6); // L-Geometrie: 6 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user