diff --git a/frontend/src/plugins/builtin/event-tools/index.ts b/frontend/src/plugins/builtin/event-tools/index.ts index a5f27f5..8dcfb1e 100644 --- a/frontend/src/plugins/builtin/event-tools/index.ts +++ b/frontend/src/plugins/builtin/event-tools/index.ts @@ -421,6 +421,182 @@ export const barrierTool: ToolExtensionV2 = { }, }; +// ───────────────────────────────────────────────────────────── +// Task CAD-7: Magnetisches Traversen-Tool (Truss). +// Rechteckiger Balken (Laengen-Option 100/200/290cm), Drag +// definiert die Richtung. MAGNETISCH: Nahe dem Ende einer +// bestehenden Traverse dockt die neue exakt an (Position UND +// Rotation uebernommen) - Traversen-Reihen bauen sich selbst. +// ───────────────────────────────────────────────────────────── + +/** Dock-Ergebnis des magnetischen Andockens. */ +export interface TrussDock { + center: Pt; + rotationDeg: number; + dockedTo: string; +} + +/** + * Endpunkte einer Traverse in Weltkoordinaten (CAD-7, rein). + * Rotation in Grad um das Zentrum (el.x/el.y). + */ +export function trussEndpoints(el: CADElement): [Pt, Pt] { + const length = (el.properties.length as number | undefined) ?? el.width ?? 0; + const rot = (((el.properties.rotation as number | undefined) ?? 0) * Math.PI) / 180; + const half = length / 2; + const dx = Math.cos(rot) * half; + const dy = Math.sin(rot) * half; + return [ + { x: el.x - dx, y: el.y - dy }, + { x: el.x + dx, y: el.y + dy }, + ]; +} + +/** + * Magnetisches Andocken (CAD-7, rein): Sucht unter allen Traversen + * den Endpunkt in Dock-Toleranz und liefert die Geometrie der + * neuen Traverse (Zentrum + Rotation), die exakt dort andockt. + * dockedTo = ID der Anker-Traverse; null = kein Dock in Reichweite. + */ +export function snapTrussToDock( + existing: CADElement[], + cursor: Pt, + newLength: number, + tolerance: number, +): TrussDock | null { + let best: { dock: TrussDock; dist: number } | null = null; + for (const el of existing) { + if (String(el.type) !== 'truss' || el.properties.truss !== true) continue; + const rotDeg = (el.properties.rotation as number | undefined) ?? 0; + const rot = (rotDeg * Math.PI) / 180; + const dx = Math.cos(rot); + const dy = Math.sin(rot); + const half = newLength / 2; + const ends = trussEndpoints(el); + for (let endIdx = 0; endIdx < ends.length; endIdx++) { + const end = ends[endIdx]; + const dist = Math.hypot(cursor.x - end.x, cursor.y - end.y); + if (dist > tolerance) continue; + if (best && dist >= best.dist) continue; + // Neue Traverse: fuehrt VOM Dock-Ende WEG von der Anker-Traverse. + // Endpunkt 0 (Start) -> Gegenrichtung (rot+180), Endpunkt 1 -> vorwaerts. + const dir = endIdx === 0 ? -1 : 1; + const centerX = end.x + dx * half * dir; + const centerY = end.y + dy * half * dir; + const newRot = endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg; + best = { + dist, + dock: { + center: { x: centerX, y: centerY }, + rotationDeg: newRot, + dockedTo: el.id, + }, + }; + } + } + return best ? best.dock : null; +} + +/** Traversen-Drag-Sitzung (CAD-7). */ +let trussDragStart: Pt | null = null; + +function makeTrussElement( + center: Pt, + length: number, + rotationDeg: number, + layerId: string, +): CADElement { + const rad = (rotationDeg * Math.PI) / 180; + const w = Math.abs(length * Math.cos(rad)) || length; + const h = Math.abs(length * Math.sin(rad)) || 20; + return { + id: `truss_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`, + type: 'truss', + layerId, + x: center.x, + y: center.y, + width: Math.max(w, 20), + height: 20, + properties: { length, rotation: rotationDeg, truss: true, stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280' }, + } as unknown as CADElement; +} + +const DOCK_TOLERANCE = 25; + +export const trussTool: ToolExtensionV2 = { + manifest: { + id: 'truss', + label: 'Traverse (magnetisch)', + icon: '\u2550', + ribbonTab: 'insert', + tags: ['basic'], + description: 'Traverse platzieren: Drag = Richtung, dockt magnetisch an bestehenden Traversen an (CAD-7)', + }, + optionsSchema: [ + { key: 'length', label: 'Länge (cm)', type: 'select', options: [ + { value: '100', label: '100 cm' }, + { value: '200', label: '200 cm' }, + { value: '290', label: '290 cm' }, + ] }, + ], + handlers: { + down(e, ctx) { + trussDragStart = e.world; + ctx.setStatus('Traverse: Richtung ziehen (dockt automatisch an Traversen an)'); + }, + move(e, ctx) { + const c = ctx as FullToolContext; + if (!trussDragStart) return; + const length = Number((c.options.length as string | undefined) ?? 200) || 200; + // Magnet-Vorschau: dockt der Cursor an ein Traversen-Ende? + const existing = c.doc ? (c.doc.getAllElements() as CADElement[]).filter( + (el) => String(el.type) === 'truss', + ) : []; + const dock = snapTrussToDock(existing, trussDragStart, length, DOCK_TOLERANCE); + if (dock) { + ctx.setPreview?.(makeTrussElement(dock.center, length, dock.rotationDeg, 'layer-0')); + return; + } + // Frei: Richtung aus Drag + const ang = (Math.atan2(e.world.y - trussDragStart.y, e.world.x - trussDragStart.x) * 180) / Math.PI; + const cx = (trussDragStart.x + e.world.x) / 2; + const cy = (trussDragStart.y + e.world.y) / 2; + ctx.setPreview?.(makeTrussElement({ x: cx, y: cy }, length, ang, 'layer-0')); + }, + up(e, ctx) { + const c = ctx as FullToolContext; + const start = trussDragStart; + trussDragStart = null; + ctx.setPreview?.(null); + if (!start || !c.doc) return; + const length = Number((c.options.length as string | undefined) ?? 200) || 200; + const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; + // MAGNETISCH: Dock-Pruefung am Drag-Startpunkt + const existing = (c.doc.getAllElements() as CADElement[]).filter( + (el) => String(el.type) === 'truss', + ); + const dock = snapTrussToDock(existing, start, length, DOCK_TOLERANCE); + let el: CADElement; + if (dock) { + el = makeTrussElement(dock.center, length, dock.rotationDeg, layerId); + ctx.setStatus(`Traverse angedockt an ${dock.dockedTo} (${length}cm)`); + } else { + const ang = (Math.atan2(e.world.y - start.y, e.world.x - start.x) * 180) / Math.PI; + const cx = (start.x + e.world.x) / 2; + const cy = (start.y + e.world.y) / 2; + el = makeTrussElement({ x: cx, y: cy }, length, ang, layerId); + ctx.setStatus(`Traverse platziert (${length}cm)`); + } + c.doc.transact(() => { c.doc!.addElement(el); }); + }, + cancel(ctx) { + trussDragStart = null; + ctx.setPreview?.(null); + ctx.setStatus('Traverse abgebrochen'); + }, + }, +}; + export const eventToolsV2Plugin: PluginV2 = { manifest: { id: 'event-tools', @@ -431,5 +607,5 @@ export const eventToolsV2Plugin: PluginV2 = { category: 'elements', enabledByDefault: true, }, - tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool], + tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool, trussTool], }; diff --git a/frontend/src/render/pixi/elementDrawers.ts b/frontend/src/render/pixi/elementDrawers.ts index b0b5a6c..acceb18 100644 --- a/frontend/src/render/pixi/elementDrawers.ts +++ b/frontend/src/render/pixi/elementDrawers.ts @@ -399,6 +399,42 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri return []; } + case 'truss': { + // Traverse (CAD-7): Balken als Rechteck + 2 Gurtlinien entlang + // der Rotation (wie ein Fachwerk-Profil) + const length = (props.length as number | undefined) ?? el.width; + const rotDeg = (props.rotation as number | undefined) ?? 0; + const rad = (rotDeg * Math.PI) / 180; + const dx = Math.cos(rad) * (length / 2); + const dy = Math.sin(rad) * (length / 2); + const nx = -Math.sin(rad) * 10; // Normalen-Offset (halbe Hoehe) + const ny = Math.cos(rad) * 10; + const c = { x: el.x, y: el.y }; + return [ + { + kind: 'rect', + x: c.x - dx, y: c.y - dy, + w: length, h: 20, + fill: fillOrNull(props.fill) ?? '#6b7280', + stroke: (props.stroke as string | undefined) ?? '#9aa0a6', + strokeWidth: 2, + }, + // Gurtlinien oben/unten entlang der Traverse + { + kind: 'line', + from: { x: c.x - dx + nx, y: c.y - dy + ny }, + to: { x: c.x + dx + nx, y: c.y + dy + ny }, + stroke: '#4b5563', strokeWidth: 1, + }, + { + kind: 'line', + from: { x: c.x - dx - nx, y: c.y - dy - ny }, + to: { x: c.x + dx - nx, y: c.y + dy - ny }, + stroke: '#4b5563', strokeWidth: 1, + }, + ]; + } + case 'barrier': { // Absperrung (Task E7): Kette aus Kreis-Paaren (properties.circles). const circles = Array.isArray(props.circles) diff --git a/frontend/src/types/cad.types.ts b/frontend/src/types/cad.types.ts index c0cccdb..b535b37 100644 --- a/frontend/src/types/cad.types.ts +++ b/frontend/src/types/cad.types.ts @@ -7,7 +7,7 @@ export type ElementType = | 'polyline' | 'text' | 'dimension' | 'block_instance' | 'chair' | 'seating-row' | 'seating-block' | 'table' | 'stage' | 'leader' | 'revcloud' | 'image' - | 'curtain' | 'spotlight' | 'barrier'; + | 'curtain' | 'spotlight' | 'barrier' | 'truss'; export type LineType = 'solid' | 'dashed' | 'dotted'; diff --git a/frontend/tests/trussTool.test.ts b/frontend/tests/trussTool.test.ts new file mode 100644 index 0000000..6f20486 --- /dev/null +++ b/frontend/tests/trussTool.test.ts @@ -0,0 +1,219 @@ +/** + * Task CAD-7 - Magnetisches Traversen-Tool. + * + * Traversen als rechteckige Balken (Laengen-Option 100/200/290cm, + * Standard 200). MAGNETISCH: Nahe dem Ende einer bestehenden + * Traverse dockt die neue exakt an (Position + Rotation uebernommen) + * - trussEndpoints (Endpunkte in Weltkoordinaten, rotationsbewusst) + * - snapTrussToDock (Dock-Suche + Andock-Geometrie, rein) + * - trussTool: Klick platziert (gedockt oder frei), Drag = Richtung. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + trussTool, + trussEndpoints, + snapTrussToDock, + type TrussDock, +} from '../src/plugins/builtin/event-tools'; +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 = {}) { + 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, mods: { shift?: boolean } = {}): { world: Pt; shift: boolean } { + return { world: { x, y }, shift: mods.shift ?? false } as never; +} + +function makeTruss(id: string, x: number, y: number, length: number, rotationDeg = 0): CADElement { + return { + id, type: 'truss', layerId: 'layer-0', + x, y, + width: length, height: 20, + properties: { length, rotation: rotationDeg, truss: true }, + } as unknown as CADElement; +} + +beforeEach(() => { + trussTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); +}); + +// ─── trussEndpoints (reine Funktion) ────────────────────────── + +describe('CAD-7: trussEndpoints', () => { + it('horizontale Traverse: Endpunkte links/rechts vom Zentrum', () => { + const el = makeTruss('t1', 100, 100, 200, 0); + const [a, b] = trussEndpoints(el); + expect(a).toEqual({ x: 0, y: 100 }); + expect(b).toEqual({ x: 200, y: 100 }); + }); + + it('90-Grad-Traverse: Endpunkte vertikal', () => { + const el = makeTruss('t1', 100, 100, 200, 90); + const [a, b] = trussEndpoints(el); + expect(a.x).toBeCloseTo(100, 5); + expect(a.y).toBeCloseTo(0, 5); + expect(b.x).toBeCloseTo(100, 5); + expect(b.y).toBeCloseTo(200, 5); + }); + + it('45-Grad-Traverse: diagonale Endpunkte', () => { + const el = makeTruss('t1', 100, 100, 200, 45); + const [a, b] = trussEndpoints(el); + const half = 100; + const cos45 = Math.cos(Math.PI / 4) * half; + expect(a.x).toBeCloseTo(100 - cos45, 3); + expect(a.y).toBeCloseTo(100 - cos45, 3); + expect(b.x).toBeCloseTo(100 + cos45, 3); + }); +}); + +// ─── snapTrussToDock (magnetisches Andocken, rein) ────────── + +describe('CAD-7: snapTrussToDock (magnetisch)', () => { + const existing = makeTruss('t1', 100, 100, 200, 0); // Enden (0,100) und (200,100) + const tol = 25; + + it('Cursor nahe Endpunkt (200,100): dockt exakt an mit Rotation 0', () => { + const result = snapTrussToDock([existing], { x: 210, y: 105 }, 200, tol); + expect(result).not.toBeNull(); + expect(result!.rotationDeg).toBeCloseTo(0, 5); + // Neue Traverse startet am Dock (200,100) → Zentrum bei (200+100, 100) + expect(result!.center.x).toBeCloseTo(300, 3); + expect(result!.center.y).toBeCloseTo(100, 3); + expect(result!.dockedTo).toBe('t1'); + }); + + it('Cursor nahe Startpunkt (0,100): dockt in GEGENRICHTUNG (Rotation 180)', () => { + const result = snapTrussToDock([existing], { x: -10, y: 98 }, 200, tol); + expect(result).not.toBeNull(); + expect(result!.rotationDeg).toBeCloseTo(180, 5); + expect(result!.center.x).toBeCloseTo(-100, 3); + }); + + it('Cursor weiter weg als Toleranz: kein Dock (null)', () => { + const result = snapTrussToDock([existing], { x: 500, y: 500 }, 200, tol); + expect(result).toBeNull(); + }); + + it('andocken an rotierte Traverse (90 Grad): Rotation wird uebernommen', () => { + const vert = makeTruss('v1', 100, 100, 200, 90); // Enden (100,0) und (100,200) + const result = snapTrussToDock([vert], { x: 105, y: 210 }, 200, tol); + expect(result).not.toBeNull(); + expect(result!.rotationDeg).toBeCloseTo(90, 5); + expect(result!.center.x).toBeCloseTo(100, 3); + expect(result!.center.y).toBeCloseTo(300, 3); + }); + + it('mehrere Traversen: naechstgelegener Dock gewinnt', () => { + const other = makeTruss('t2', 400, 100, 200, 0); // Enden (300,100),(500,100) + const result = snapTrussToDock([existing, other], { x: 305, y: 100 }, 200, tol); + expect(result!.dockedTo).toBe('t2'); + }); +}); + +// ─── trussTool (Platzierung + Magnetismus) ────────────────── + +describe('CAD-7: trussTool', () => { + it('Klick platziert freie Traverse (200cm Standard) als truss-Element', () => { + const { ctx, doc } = mkCtx(); + trussTool.handlers.down!(pe(300, 300), ctx); + trussTool.handlers.up!(pe(400, 300), ctx); + const els = doc.getAllElements(); + expect(els).toHaveLength(1); + expect(els[0].type).toBe('truss'); + expect(els[0].properties.length).toBe(200); + expect(els[0].properties.truss).toBe(true); + }); + + it('Drag definiert die Richtung: horizontale Traverse bei Drag nach rechts', () => { + const { ctx, doc } = mkCtx(); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.up!(pe(300, 100), ctx); + const el = doc.getAllElements()[0]; + expect(el.properties.rotation).toBeCloseTo(0, 5); + // Laenge aus Drag-Distanz? Nein: Standardlaenge bleibt 200 (Option) + expect(el.properties.length).toBe(200); + }); + + it('Drag nach oben: Rotation 90', () => { + const { ctx, doc } = mkCtx(); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.up!(pe(100, 300), ctx); + expect(doc.getAllElements()[0].properties.rotation).toBeCloseTo(90, 1); + }); + + it('MAGNETISCH: Klick nahe bestehendem Traversen-Ende dockt exakt an', () => { + const existing = makeTruss('t1', 100, 100, 200, 0); + const { ctx, doc } = mkCtx([existing]); + // Klick nahe (200,100) → andocken + trussTool.handlers.down!(pe(210, 105), ctx); + trussTool.handlers.up!(pe(300, 100), ctx); + const els = doc.getAllElements().filter((e) => e.properties.truss); + expect(els).toHaveLength(2); + const newTruss = els.find((e) => e.id !== 't1')!; + // Exakt angedockt: Rotation 0, Start am Ende der ersten + expect(newTruss.properties.rotation).toBeCloseTo(0, 5); + expect(newTruss.x).toBeCloseTo(300, 3); + expect(newTruss.y).toBeCloseTo(100, 3); + }); + + it('ohne bestehende Traversen: freie Platzierung (kein Crash)', () => { + const { ctx, doc } = mkCtx(); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.up!(pe(200, 100), ctx); + expect(doc.getAllElements()).toHaveLength(1); + }); + + it('Laengen-Option: 290 statt 200', () => { + const { ctx, doc } = mkCtx([], { length: 290 }); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.up!(pe(300, 100), ctx); + expect(doc.getAllElements()[0].properties.length).toBe(290); + }); + + it('ALLES in EINER Transaktion (1 Undo entfernt die Traverse)', () => { + const { ctx, doc } = mkCtx(); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.up!(pe(300, 100), ctx); + expect(doc.getAllElements()).toHaveLength(1); + doc.undo(); + expect(doc.getAllElements()).toHaveLength(0); + }); + + it('cancel resettet die Drag-Sitzung', () => { + const { ctx, doc } = mkCtx(); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.cancel?.(ctx); + trussTool.handlers.up!(pe(300, 100), ctx); + expect(doc.getAllElements()).toHaveLength(0); + }); + + it('Preview bei move zwischen down und up', () => { + const { ctx, previews } = mkCtx(); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.move!(pe(250, 100), ctx); + expect(previews.length).toBeGreaterThanOrEqual(1); + const last = previews[previews.length - 1]; + expect(last).not.toBeNull(); + }); + + it('Manifest: id=truss, Optionen length/rotation', () => { + expect(trussTool.manifest.id).toBe('truss'); + const keys = (trussTool.optionsSchema ?? []).map((f) => f.key); + expect(keys).toContain('length'); + }); +});