import type { CADElement, ElementType, ToolType } from '../types/cad.types'; import { ZoomPanController } from '../canvas/ZoomPanController'; import { RenderEngine } from '../canvas/RenderEngine'; import { SnapEngine } from '../canvas/SnapEngine'; import { SelectionEngine } from '../canvas/SelectionEngine'; import { SpatialIndex } from '../canvas/SpatialIndex'; import { LayerManager } from '../canvas/LayerManager'; import { GroupManager } from '../tools/modification/GroupTool'; import { moveElement, rotateElement, scaleElement, mirrorElement, offsetElement, trimElement, extendElement, filletElements, distance, angleBetween } from '../tools/modification/geometry'; import { SeatingService, SEATING_TEMPLATES } from '../services/seatingService'; import { DimensionService } from '../services/dimensionService'; import { formatDistance, type UnitType } from '../utils/format'; export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting'; export interface ToolState { activeTool: ToolType; phase: ToolPhase; points: Array<{ x: number; y: number }>; // accumulated world points for current operation previewElement: CADElement | null; // temporary element being drawn ortho: boolean; snapEnabled: boolean; } export interface InteractionConfig { orthoKey: string; // F8 snapToggleKey: string; // F3 escapeKey: string; deleteKey: string; selectAllKey: string; undoKey: string; redoKey: string; } export class InteractionEngine { private canvas: HTMLCanvasElement; private zoomPan: ZoomPanController; private renderEngine: RenderEngine; private snapEngine: SnapEngine; private selectionEngine: SelectionEngine; private spatialIndex: SpatialIndex; private layerManager: LayerManager; private state: ToolState; private config: InteractionConfig; private selectedTemplate: string | null = null; private allElements: CADElement[] = []; private groupManager: GroupManager | null = null; private isMouseDown = false; private isRightDown = false; private lastMouseScreen = { x: 0, y: 0 }; private eventListeners: Array<{ type: string; fn: EventListener }> = []; private onElementCreated?: (el: CADElement) => void; private onElementsDeleted?: (ids: string[]) => void; private onElementsModified?: (els: CADElement[]) => void; private onToolStateChanged?: (state: ToolState) => void; private onCursorMoved?: (worldX: number, worldY: number) => void; private onCommandTrigger?: (cmd: string) => void; private onTextEdit?: (el: CADElement) => void; private onSelectionChange?: (selectedIds: string[]) => void; private animationFrame: number | null = null; private boundKeyDown: EventListener; private boundMouseMove: EventListener; private boundMouseUp: EventListener; private boundPointerCancel: EventListener; private unit: UnitType = 'mm'; private scaleFactor: number = 1; constructor( canvas: HTMLCanvasElement, zoomPan: ZoomPanController, renderEngine: RenderEngine, snapEngine: SnapEngine, selectionEngine: SelectionEngine, spatialIndex: SpatialIndex, layerManager: LayerManager, ) { this.canvas = canvas; this.zoomPan = zoomPan; this.renderEngine = renderEngine; this.snapEngine = snapEngine; this.selectionEngine = selectionEngine; this.spatialIndex = spatialIndex; this.layerManager = layerManager; this.state = { activeTool: 'select', phase: 'idle', points: [], previewElement: null, ortho: false, snapEnabled: true, }; this.config = { orthoKey: 'F8', snapToggleKey: 'F3', escapeKey: 'Escape', deleteKey: 'Delete', selectAllKey: 'a', undoKey: 'z', redoKey: 'y', }; this.boundKeyDown = this.onKeyDown.bind(this) as EventListener; this.boundMouseMove = this.onMouseMove.bind(this) as EventListener; this.boundMouseUp = this.onMouseUp.bind(this) as EventListener; this.boundPointerCancel = this.onMouseUp.bind(this) as EventListener; } setElements(elements: CADElement[]): void { this.allElements = elements; this.snapEngine.setElements(elements); } setGroupManager(gm: GroupManager | null): void { this.groupManager = gm; } setSnapEnabled(enabled: boolean): void { this.state.snapEnabled = enabled; } setOrthoEnabled(enabled: boolean): void { this.state.ortho = enabled; } setUnits(unit: UnitType, scaleFactor: number): void { this.unit = unit; this.scaleFactor = scaleFactor; } setGridSize(gridSize: number): void { this.snapEngine.setConfig({ gridSpacing: gridSize }); } setPolarEnabled(enabled: boolean): void { this.snapEngine.setConfig({ polarEnabled: enabled }); } setSelectedTemplate(templateName: string | null): void { this.selectedTemplate = templateName; } setTool(tool: ToolType): void { this.state.activeTool = tool; this.state.phase = 'idle'; this.state.points = []; this.state.previewElement = null; const drawingTools: ToolType[] = ['line', 'rect', 'circle', 'arc', 'polyline', 'polygon', 'revcloud', 'text', 'dimension', 'leader', 'chair', 'seating-row', 'seating-block', 'table', 'stage', 'seating-template', 'hatch']; if (drawingTools.includes(tool)) { this.selectionEngine.clearSelection(); this.emitSelectionChange(); } this.notifyToolStateChanged(); this.requestRender(); } getToolState(): ToolState { return { ...this.state, points: [...this.state.points] }; } setCallbacks(callbacks: { onElementCreated?: (el: CADElement) => void; onElementsDeleted?: (ids: string[]) => void; onElementsModified?: (els: CADElement[]) => void; onToolStateChanged?: (state: ToolState) => void; onCursorMoved?: (worldX: number, worldY: number) => void; onCommandTrigger?: (cmd: string) => void; onTextEdit?: (el: CADElement) => void; onSelectionChange?: (selectedIds: string[]) => void; }): void { this.onElementCreated = callbacks.onElementCreated; this.onElementsDeleted = callbacks.onElementsDeleted; this.onElementsModified = callbacks.onElementsModified; this.onToolStateChanged = callbacks.onToolStateChanged; this.onCursorMoved = callbacks.onCursorMoved; this.onCommandTrigger = callbacks.onCommandTrigger; this.onTextEdit = callbacks.onTextEdit; this.onSelectionChange = callbacks.onSelectionChange; } private emitSelectionChange(): void { if (this.onSelectionChange) { this.onSelectionChange(Array.from(this.selectionEngine.getSelectedIds())); } } attach(): void { this.addListener('pointerdown', this.onMouseDown.bind(this) as EventListener); document.addEventListener('pointermove', this.boundMouseMove); document.addEventListener('pointerup', this.boundMouseUp); document.addEventListener('pointercancel', this.boundPointerCancel); this.addListener('dblclick', this.onDoubleClick.bind(this) as EventListener); this.addListener('wheel', this.onWheel.bind(this) as EventListener, { passive: false } as AddEventListenerOptions); this.addListener('contextmenu', this.onContextMenu.bind(this) as EventListener); document.addEventListener('keydown', this.boundKeyDown); } detach(): void { for (const { type, fn } of this.eventListeners) { this.canvas.removeEventListener(type, fn); } this.eventListeners = []; document.removeEventListener('pointermove', this.boundMouseMove); document.removeEventListener('pointerup', this.boundMouseUp); document.removeEventListener('pointercancel', this.boundPointerCancel); document.removeEventListener('keydown', this.boundKeyDown); if (this.animationFrame !== null) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } } private addListener(type: string, fn: EventListener, options?: AddEventListenerOptions): void { this.canvas.addEventListener(type, fn, options); this.eventListeners.push({ type, fn }); } private getWorldCoords(e: MouseEvent): { x: number; y: number } { return this.zoomPan.screenToWorld(e.clientX, e.clientY); } private applySnap(x: number, y: number): { x: number; y: number } { if (!this.state.snapEnabled) return { x, y }; // Pass last point as reference for polar tracking const refPoint = this.state.points.length > 0 ? this.state.points[this.state.points.length - 1] : undefined; const result = this.snapEngine.snap(x, y, refPoint); if (result.point) { this.renderEngine.setSnapPoints(result.preview); this.renderEngine.setActiveSnapPoint(result.point); return { x: result.point.x, y: result.point.y }; } this.renderEngine.setSnapPoints([]); this.renderEngine.setActiveSnapPoint(null); return { x, y }; } private applyOrtho(x: number, y: number): { x: number; y: number } { if (!this.state.ortho || this.state.points.length === 0) return { x, y }; const last = this.state.points[this.state.points.length - 1]; const dx = x - last.x; const dy = y - last.y; if (Math.abs(dx) > Math.abs(dy)) { return { x, y: last.y }; } else { return { x: last.x, y }; } } private onMouseDown(e: MouseEvent): void { try { this.canvas.setPointerCapture((e as any).pointerId); } catch {} this.isMouseDown = true; this.lastMouseScreen = { x: e.clientX, y: e.clientY }; if (e.button === 2) { this.isRightDown = true; this.state.phase = 'panning'; return; } if (e.button === 1 || this.state.activeTool === 'pan') { this.state.phase = 'panning'; return; } const raw = this.getWorldCoords(e); const snapped = this.applySnap(raw.x, raw.y); const final = this.applyOrtho(snapped.x, snapped.y); switch (this.state.activeTool) { case 'select': this.handleSelectDown(e, raw); break; case 'line': case 'polyline': case 'rect': case 'circle': case 'arc': case 'polygon': case 'text': case 'dimension': case 'leader': this.handleDrawDown(final); break; case 'revcloud': this.handleDrawDown(final); break; case 'chair': case 'seating-row': case 'seating-block': case 'table': case 'stage': case 'seating-template': this.handlePlaceDown(final); break; case 'hatch': this.handleHatchDown(final); break; case 'measure': this.handleMeasureDown(final); break; case 'move': case 'copy': case 'rotate': case 'scale': case 'mirror': case 'trim': case 'extend': case 'fillet': case 'offset': this.handleModifyDown(final); break; case 'delete': this.handleDeleteDown(raw); break; default: break; } this.requestRender(); } private onMouseMove(e: MouseEvent): void { if (this.state.phase === 'idle' && !this.isMouseDown) { const rect = this.canvas.getBoundingClientRect(); if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) return; } this.lastMouseScreen = { x: e.clientX, y: e.clientY }; const raw = this.getWorldCoords(e); this.onCursorMoved?.(raw.x, raw.y); if (this.state.phase === 'panning') { const dx = e.movementX; const dy = e.movementY; this.zoomPan.pan(dx, dy); this.requestRender(); return; } const snapped = this.applySnap(raw.x, raw.y); const final = this.applyOrtho(snapped.x, snapped.y); // Hover detection for select tool if (this.state.activeTool === 'select' && this.state.phase === 'idle') { const hit = this.renderEngine.hitTest(raw.x, raw.y, 5); this.selectionEngine.setHover(hit?.id ?? null); } // Update preview during drawing if (this.state.phase === 'drawing' && this.state.points.length > 0) { this.updatePreview(final); } // Update preview during modification if (this.state.phase === 'modifying' && this.state.points.length > 0) { this.updateModifyPreview(final); } // Update box selection if (this.selectionEngine.isBoxSelecting()) { this.selectionEngine.updateBoxSelect(raw.x, raw.y, this.allElements); } this.requestRender(); } private onMouseUp(e: MouseEvent): void { try { this.canvas.releasePointerCapture((e as any).pointerId); } catch {} this.isMouseDown = false; if (e.button === 2 || (e.button === 1 && this.state.phase === 'panning')) { this.isRightDown = false; this.state.phase = this.state.activeTool === 'pan' ? 'idle' : 'idle'; this.requestRender(); return; } if (this.state.phase === 'panning') { this.state.phase = 'idle'; this.requestRender(); return; } // Move/Copy/Rotate/Scale: confirm on mouseUp (press-drag-release) if (this.state.phase === 'modifying' && this.state.points.length >= 1) { const tool = this.state.activeTool; if (tool === 'move' || tool === 'copy' || tool === 'rotate' || tool === 'scale') { const raw = this.getWorldCoords(e); const snapped = this.applySnap(raw.x, raw.y); const final = this.applyOrtho(snapped.x, snapped.y); this.confirmModify(final); return; } } if (this.state.activeTool === 'select' && this.selectionEngine.isBoxSelecting()) { if (this.selectionEngine.hasBoxMoved()) { this.selectionEngine.finishBoxSelect(this.allElements); this.emitSelectionChange(); } else { // No drag movement — cancel box select (it was just a click, already handled by clickSelect) this.selectionEngine.cancelBoxSelect(); } this.requestRender(); return; } // Click-drag: confirm draw on mouseup for 2-point tools if (this.state.phase === 'drawing' && this.state.points.length >= 1) { const raw = this.getWorldCoords(e); const snapped = this.applySnap(raw.x, raw.y); const final = this.applyOrtho(snapped.x, snapped.y); const tool = this.state.activeTool; // For 2-point tools: use mouseup position as second point if (tool === 'line' || tool === 'rect' || tool === 'circle' || tool === 'dimension' || tool === 'leader' || tool === 'arc' || tool === 'polygon' || tool === 'revcloud') { this.state.points.push(final); this.confirmDraw(); return; } } } private onWheel(e: WheelEvent): void { e.preventDefault(); const factor = e.deltaY > 0 ? 0.9 : 1.1; const rect = this.canvas.getBoundingClientRect(); const cx = e.clientX - rect.left; const cy = e.clientY - rect.top; this.zoomPan.zoomAt(cx, cy, factor); this.requestRender(); } private onContextMenu(e: MouseEvent): void { e.preventDefault(); } private onDoubleClick(e: MouseEvent): void { e.preventDefault(); if ( (this.state.activeTool === 'polyline' || this.state.activeTool === 'polygon' || this.state.activeTool === 'revcloud') && this.state.phase === 'drawing' && this.state.points.length >= 2 ) { this.confirmDraw(); } } private onKeyDown(e: KeyboardEvent): void { // Tool-independent keys if (e.key === this.config.escapeKey) { this.cancelCurrentOperation(); return; } if (e.key === this.config.orthoKey) { this.state.ortho = !this.state.ortho; this.renderEngine.setOptions({ showOrtho: this.state.ortho }); this.notifyToolStateChanged(); this.requestRender(); return; } if (e.key === this.config.snapToggleKey) { this.state.snapEnabled = !this.state.snapEnabled; this.renderEngine.setOptions({ showSnapPoints: this.state.snapEnabled }); this.notifyToolStateChanged(); this.requestRender(); return; } if (e.key === this.config.deleteKey && this.state.activeTool === 'select') { const ids = Array.from(this.selectionEngine.getSelectedIds()); if (ids.length > 0) { this.onElementsDeleted?.(ids); } return; } // G = Group selected elements if (e.key === 'g' && !e.ctrlKey && !e.metaKey && this.state.activeTool === 'select') { const ids = Array.from(this.selectionEngine.getSelectedIds()); if (ids.length >= 2) { this.onCommandTrigger?.('group'); } return; } // Ctrl+G = Ungroup if ((e.ctrlKey || e.metaKey) && e.key === 'g' && this.state.activeTool === 'select') { e.preventDefault(); this.onCommandTrigger?.('ungroup'); return; } // Ctrl+A = select all if ((e.ctrlKey || e.metaKey) && e.key === this.config.selectAllKey) { e.preventDefault(); this.selectionEngine.selectAll(this.allElements); this.emitSelectionChange(); this.requestRender(); return; } // Ctrl+Z = undo (delegated to app) if ((e.ctrlKey || e.metaKey) && e.key === this.config.undoKey) { e.preventDefault(); this.onCommandTrigger?.('undo'); return; } // Ctrl+Y = redo if ((e.ctrlKey || e.metaKey) && e.key === this.config.redoKey) { e.preventDefault(); this.onCommandTrigger?.('redo'); return; } // Enter = confirm current operation if (e.key === 'Enter' && this.state.phase === 'drawing') { this.confirmDraw(); return; } // Enter = confirm modification with current cursor position if (e.key === 'Enter' && this.state.phase === 'modifying') { const world = this.zoomPan.screenToWorld(this.lastMouseScreen.x, this.lastMouseScreen.y); this.confirmModify(world); return; } // Space = pan toggle (hold) if (e.key === ' ' && this.state.activeTool !== 'pan') { e.preventDefault(); // Temporary pan — could implement hold-to-pan return; } } // --- Tool handlers --- private handleSelectDown(e: MouseEvent, world: { x: number; y: number }): void { const additive = e.shiftKey; const subtractive = e.ctrlKey || e.metaKey; this.selectionEngine.setOptions({ additive, subtractive }); if (!additive && !subtractive) { // Hit test first: if an object is clicked, select it directly const hit = this.renderEngine.hitTest(world.x, world.y, 5); if (hit) { // Group-aware click selection: if clicked element is in a group, select all group members if (this.groupManager) { const groupElements = this.groupManager.getGroupElements(hit.id); if (groupElements.length > 1) { this.selectionEngine.selectByIds(groupElements, false); this.emitSelectionChange(); return; } } this.selectionEngine.clickSelect(world.x, world.y, this.allElements); this.emitSelectionChange(); } else { // No object hit: start potential box select for drag this.selectionEngine.startBoxSelect(world.x, world.y); } } else { // Group-aware click selection: if clicked element is in a group, select all group members const hit = this.renderEngine.hitTest(world.x, world.y, 5); if (hit && this.groupManager) { const groupElements = this.groupManager.getGroupElements(hit.id); if (groupElements.length > 1) { if (subtractive) { // Remove all group elements from selection const currentIds = Array.from(this.selectionEngine.getSelectedIds()); const filtered = currentIds.filter(id => !groupElements.includes(id)); this.selectionEngine.selectByIds(filtered, false); } else { this.selectionEngine.selectByIds(groupElements, !additive ? false : true); } this.emitSelectionChange(); return; } } this.selectionEngine.clickSelect(world.x, world.y, this.allElements); this.emitSelectionChange(); } } private handleDrawDown(pt: { x: number; y: number }): void { // Clear any previous points when starting a new draw operation if (this.state.points.length === 0) { this.state.phase = 'drawing'; } this.state.points.push(pt); // Arc: auto-confirm after 3 points (center, start, end) if (this.state.activeTool === 'arc' && this.state.points.length >= 3) { this.confirmDraw(); return; } // Text: single click placement, then prompt for text content if (this.state.activeTool === 'text') { this.updatePreview(pt); this.confirmDraw(); return; } // Line, rect, circle, dimension, leader: wait for mouseup (click-drag behavior) // Points are collected on mousedown, confirmed on mouseup this.updatePreview(pt); this.notifyToolStateChanged(); } private handlePlaceDown(pt: { x: number; y: number }): void { const tool = this.state.activeTool; const layerId = this.layerManager.getActiveLayerId(); const seating = new SeatingService(); switch (tool) { case 'chair': { const el = seating.createChair(pt.x, pt.y, layerId); this.onElementCreated?.(el); break; } case 'seating-row': { const els = seating.createSeatingRow(pt.x, pt.y, layerId); for (const el of els) this.onElementCreated?.(el); this.onCommandTrigger?.(`Reihe mit ${els.length} Stühlen erstellt`); break; } case 'seating-block': { const els = seating.createSeatingBlock(pt.x, pt.y, layerId); for (const el of els) this.onElementCreated?.(el); this.onCommandTrigger?.(`Block mit ${els.length} Stühlen erstellt`); break; } case 'table': { const el = seating.createTable(pt.x, pt.y, layerId); this.onElementCreated?.(el); break; } case 'stage': { const el = seating.createStage(pt.x, pt.y, layerId); this.onElementCreated?.(el); break; } case 'seating-template': { if (this.selectedTemplate) { const els = seating.createFromTemplate(this.selectedTemplate, pt.x, pt.y, layerId); for (const el of els) this.onElementCreated?.(el); this.onCommandTrigger?.(`Vorlage "${this.selectedTemplate}" platziert: ${els.length} Elemente`); } else { this.onCommandTrigger?.('Keine Vorlage ausgewählt – bitte in der Seitenleiste wählen'); } break; } default: break; } this.requestRender(); } private handleHatchDown(pt: { x: number; y: number }): void { // Hatch: select a closed boundary element, then apply hatch pattern const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); if (hit && (hit.type === 'rect' || hit.type === 'circle' || hit.type === 'polygon')) { const updated: CADElement = { ...hit, properties: { ...hit.properties, hatch: true, hatchPattern: 'lines', hatchSpacing: 8 }, }; this.onElementsModified?.([updated]); } this.requestRender(); } private handleMeasureDown(pt: { x: number; y: number }): void { // Measure: two clicks, display distance if (this.state.points.length === 0) { this.state.points.push(pt); this.state.phase = 'drawing'; this.onCommandTrigger?.('measure: click end point'); } else { const first = this.state.points[0]; const rawDist = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); const angle = (Math.atan2(pt.y - first.y, pt.x - first.x) * 180) / Math.PI; const distStr = formatDistance(rawDist, this.unit, this.scaleFactor); this.onCommandTrigger?.(`distance: ${distStr} angle: ${angle.toFixed(1)}°`); this.state.points = []; this.state.phase = 'idle'; this.requestRender(); } } private modifySelected: CADElement[] = []; private modifyBoundary: CADElement | null = null; private modifyFilletRadius = 10; private handleModifyDown(pt: { x: number; y: number }): void { const tool = this.state.activeTool; // Trim/Extend/Fillet: first click selects boundary element, not from selection if (tool === 'trim' || tool === 'extend') { if (this.state.points.length === 0) { // First click: select boundary element const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); if (hit) { this.modifyBoundary = hit; this.state.phase = 'modifying'; this.state.points.push(pt); this.onCommandTrigger?.(`${tool}: select element to ${tool === 'trim' ? 'trim' : 'extend'}`); } return; } // Second click: select element to trim/extend const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); if (hit && this.modifyBoundary) { const fn = tool === 'trim' ? trimElement : extendElement; const result = fn(hit, this.modifyBoundary); if (result) { this.onElementsModified?.([result]); } this.resetModify(); } return; } if (tool === 'fillet') { if (this.state.points.length === 0) { // First click: select first element const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); if (hit) { this.modifySelected = [hit]; this.state.phase = 'modifying'; this.state.points.push(pt); this.onCommandTrigger?.('fillet: select second element'); } return; } if (this.state.points.length === 1) { // Second click: select second element const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); if (hit && this.modifySelected.length === 1) { const result = filletElements(this.modifySelected[0], hit, this.modifyFilletRadius); if (result) { this.onElementsModified?.(result); } this.resetModify(); } return; } return; } if (tool === 'offset') { if (this.state.points.length === 0) { // First click: select element to offset const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); if (hit) { this.modifySelected = [hit]; this.state.phase = 'modifying'; this.state.points.push(pt); this.onCommandTrigger?.('offset: click to set direction and distance'); } return; } // Second click: direction + distance if (this.modifySelected.length === 1) { const first = this.state.points[0]; const dist = distance(first, pt); const base = this.modifySelected[0]; // Seitigkeit bestimmen: Kreuzprodukt der Linienrichtung mit Klickvektor. // cross > 0 → Klick links der Laufrichtung (p1→p2) → side=+1, sonst -1. let side: 1 | -1 = 1; if (base.type === 'line' && base.properties.x1 !== undefined && base.properties.y1 !== undefined) { const p1x = base.properties.x1 as number; const p1y = base.properties.y1 as number; const p2x = base.properties.x2 as number; const p2y = base.properties.y2 as number; const cross = (p2x - p1x) * (pt.y - p1y) - (p2y - p1y) * (pt.x - p1x); side = cross >= 0 ? 1 : -1; } else if ((base.type === 'circle' || base.type === 'arc') && base.properties.radius !== undefined) { // Kreis/Bogen: Klick außerhalb → vergrößern (+1), innerhalb → verkleinern (-1) const clickDist = Math.hypot(pt.x - base.x, pt.y - base.y); side = clickDist >= base.properties.radius ? 1 : -1; } const offsetted = offsetElement(base, dist, side); // Create as new element (copy) this.onElementCreated?.({ ...offsetted, id: this.generateId() }); this.resetModify(); } return; } // Move/Copy/Rotate/Scale: press-drag-release (kein click-click) if (tool === 'move' || tool === 'copy' || tool === 'rotate' || tool === 'scale') { if (this.state.phase === 'modifying' && this.state.points.length >= 1) { // Zweiter Klick — sollte nicht passieren bei drag-release, aber falls doch: bestätigen this.confirmModify(pt); return; } const selected = this.selectionEngine.getSelectedElements(this.allElements); if (selected.length === 0) { this.onCommandTrigger?.('select-first'); return; } this.modifySelected = selected; this.state.phase = 'modifying'; this.state.points.push(pt); // Start-Punkt für drag this.notifyToolStateChanged(); return; } // Mirror: click-click (zwei Punkte für die Spiegelungsachse) if (this.state.phase === 'modifying' && this.state.points.length >= 1) { this.confirmModify(pt); return; } const selected = this.selectionEngine.getSelectedElements(this.allElements); if (selected.length === 0) { this.onCommandTrigger?.('select-first'); return; } this.modifySelected = selected; this.state.phase = 'modifying'; this.state.points.push(pt); this.notifyToolStateChanged(); } private updateModifyPreview(pt: { x: number; y: number }): void { if (this.modifySelected.length === 0 || this.state.points.length === 0) return; const tool = this.state.activeTool; const base = this.state.points[0]; if (tool === 'move' || tool === 'copy') { const dx = pt.x - base.x; const dy = pt.y - base.y; // Show preview of first selected element moved this.state.previewElement = moveElement(this.modifySelected[0], dx, dy); } else if (tool === 'rotate') { const objCenter = { x: this.modifySelected[0].x, y: this.modifySelected[0].y }; const startAngle = angleBetween(objCenter, base); const currentAngle = angleBetween(objCenter, pt); const rotationDelta = currentAngle - startAngle; this.state.previewElement = rotateElement(this.modifySelected[0], objCenter.x, objCenter.y, rotationDelta); } else if (tool === 'scale') { const objCenter = { x: this.modifySelected[0].x, y: this.modifySelected[0].y }; const startDist = distance(objCenter, base); const currentDist = distance(objCenter, pt); const factor = startDist > 0 ? currentDist / startDist : 1; this.state.previewElement = scaleElement(this.modifySelected[0], objCenter.x, objCenter.y, factor, factor); } else if (tool === 'mirror') { // Mirror preview: axis from base point to current mouse position this.state.previewElement = mirrorElement(this.modifySelected[0], base.x, base.y, pt.x, pt.y); } } private confirmModify(pt: { x: number; y: number }): void { if (this.modifySelected.length === 0 || this.state.points.length === 0) return; const tool = this.state.activeTool; const base = this.state.points[0]; if (tool === 'move') { const dx = pt.x - base.x; const dy = pt.y - base.y; // Group-aware: expand modifySelected to include group members let toMove = [...this.modifySelected]; if (this.groupManager) { const additionalGroupEls: CADElement[] = []; for (const el of this.modifySelected) { const groupEls = this.groupManager.getGroupElements(el.id); for (const gid of groupEls) { if (!toMove.some(e => e.id === gid)) { const found = this.allElements.find(e => e.id === gid); if (found) additionalGroupEls.push(found); } } } toMove = [...toMove, ...additionalGroupEls]; } const modified = toMove.map(el => moveElement(el, dx, dy)); this.onElementsModified?.(modified); } else if (tool === 'copy') { const dx = pt.x - base.x; const dy = pt.y - base.y; for (const el of this.modifySelected) { const copy = moveElement(el, dx, dy); this.onElementCreated?.({ ...copy, id: this.generateId() }); } } else if (tool === 'rotate') { const objCenter = { x: this.modifySelected[0].x, y: this.modifySelected[0].y }; const startAngle = angleBetween(objCenter, base); const endAngle = angleBetween(objCenter, pt); const rotationDelta = endAngle - startAngle; const modified = this.modifySelected.map(el => rotateElement(el, objCenter.x, objCenter.y, rotationDelta)); this.onElementsModified?.(modified); } else if (tool === 'scale') { const objCenter = { x: this.modifySelected[0].x, y: this.modifySelected[0].y }; const startDist = distance(objCenter, base); const endDist = distance(objCenter, pt); const factor = startDist > 0 ? endDist / startDist : 1; const modified = this.modifySelected.map(el => scaleElement(el, objCenter.x, objCenter.y, factor, factor)); this.onElementsModified?.(modified); } else if (tool === 'mirror') { // Mirror axis: base point (first click) to current point (second click) const modified = this.modifySelected.map(el => mirrorElement(el, base.x, base.y, pt.x, pt.y)); this.onElementsModified?.(modified); } this.resetModify(); } private resetModify(): void { this.modifySelected = []; this.modifyBoundary = null; this.state.points = []; this.state.previewElement = null; this.state.phase = 'idle'; this.notifyToolStateChanged(); this.requestRender(); } private handleDeleteDown(world: { x: number; y: number }): void { const hit = this.renderEngine.hitTest(world.x, world.y, 5); if (hit) { this.onElementsDeleted?.([hit.id]); } } private updatePreview(pt: { x: number; y: number }): void { if (this.state.points.length === 0) return; const layerId = this.layerManager.getActiveLayerId(); const first = this.state.points[0]; const tool = this.state.activeTool; switch (tool) { case 'line': this.state.previewElement = { id: '__preview__', type: 'line', layerId, x: (first.x + pt.x) / 2, y: (first.y + pt.y) / 2, width: Math.abs(pt.x - first.x), height: Math.abs(pt.y - first.y), properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y }, }; break; case 'rect': { const minX = Math.min(first.x, pt.x); const minY = Math.min(first.y, pt.y); const w = Math.abs(pt.x - first.x); const h = Math.abs(pt.y - first.y); this.state.previewElement = { id: '__preview__', type: 'rect', layerId, x: minX + w / 2, y: minY + h / 2, width: w, height: h, properties: {}, }; break; } case 'circle': { const r = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); this.state.previewElement = { id: '__preview__', type: 'circle', layerId, x: first.x, y: first.y, width: r * 2, height: r * 2, properties: { radius: r }, }; break; } case 'arc': { // 3-point arc: click 1 = center, click 2 = start point (radius + startAngle), click 3 = end point (endAngle) const pts = this.state.points; if (pts.length === 1) { // After center placed, show preview circle as radius guide const r = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); this.state.previewElement = { id: '__preview__', type: 'arc', layerId, x: first.x, y: first.y, width: r * 2, height: r * 2, properties: { radius: r, startAngle: 0, endAngle: 360 }, }; } else if (pts.length >= 2) { const center = pts[0]; const start = pts[1]; const radius = Math.sqrt((start.x - center.x) ** 2 + (start.y - center.y) ** 2); const startAngle = (Math.atan2(start.y - center.y, start.x - center.x) * 180) / Math.PI; const endAngle = (Math.atan2(pt.y - center.y, pt.x - center.x) * 180) / Math.PI; this.state.previewElement = { id: '__preview__', type: 'arc', layerId, x: center.x, y: center.y, width: radius * 2, height: radius * 2, properties: { radius, startAngle, endAngle }, }; } break; } case 'polyline': case 'polygon': { const pts = [...this.state.points, pt]; const xs = pts.map(p => p.x); const ys = pts.map(p => p.y); this.state.previewElement = { id: '__preview__', type: tool, layerId, x: (Math.min(...xs) + Math.max(...xs)) / 2, y: (Math.min(...ys) + Math.max(...ys)) / 2, width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys), properties: { points: pts }, }; break; } case 'text': // Text placement — single point, then user types this.state.previewElement = { id: '__preview__', type: 'text', layerId, x: pt.x, y: pt.y, width: 100, height: 20, properties: { text: '', fontSize: 12 }, }; break; case 'dimension': { const dist = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); this.state.previewElement = { id: '__preview__', type: 'dimension', layerId, x: (first.x + pt.x) / 2, y: (first.y + pt.y) / 2, width: dist, height: 20, properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y }, }; break; } case 'leader': { this.state.previewElement = { id: '__preview__', type: 'leader', layerId, x: pt.x, y: pt.y, width: Math.abs(pt.x - first.x), height: Math.abs(pt.y - first.y), properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y, text: '', fontSize: 12, stroke: '#e0e0e0', strokeWidth: 1 }, }; break; } case 'revcloud': { const pts = [...this.state.points, pt]; const xs = pts.map(p => p.x); const ys = pts.map(p => p.y); this.state.previewElement = { id: '__preview__', type: 'revcloud', layerId, x: (Math.min(...xs) + Math.max(...xs)) / 2, y: (Math.min(...ys) + Math.max(...ys)) / 2, width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys), properties: { points: pts, arcHeight: 8, fill: 'none', stroke: '#e0e0e0', strokeWidth: 1.5 }, }; break; } case 'measure': { // Show a dashed line preview from first point to cursor this.state.previewElement = { id: '__preview__', type: 'line', layerId, x: (first.x + pt.x) / 2, y: (first.y + pt.y) / 2, width: Math.abs(pt.x - first.x), height: Math.abs(pt.y - first.y), properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y }, }; break; } default: break; } } private confirmDraw(): void { if (!this.state.previewElement) return; const layerId = this.layerManager.getActiveLayerId(); const dimService = new DimensionService(); let el: CADElement; // Use DimensionService for leader and revcloud creation if (this.state.activeTool === 'leader' && this.state.points.length >= 2) { const p1 = this.state.points[0]; const p2 = this.state.points[1]; el = dimService.createLeader(p1.x, p1.y, p2.x, p2.y, layerId, { text: 'Hinweis' }); } else if (this.state.activeTool === 'revcloud' && this.state.points.length >= 2) { el = dimService.createRevCloud(this.state.points, layerId); } else { el = { ...this.state.previewElement, id: this.generateId(), layerId: layerId }; } this.onElementCreated?.(el); // Text: trigger edit callback after placement if (el.type === 'text') { this.onTextEdit?.(el); } // Leader: trigger edit callback for text input if (el.type === 'leader') { this.onTextEdit?.(el); } this.state.points = []; this.state.previewElement = null; this.state.phase = 'idle'; this.notifyToolStateChanged(); this.requestRender(); } private cancelCurrentOperation(): void { this.state.points = []; this.state.previewElement = null; this.state.phase = 'idle'; this.selectionEngine.cancelBoxSelect(); this.notifyToolStateChanged(); this.requestRender(); } private notifyToolStateChanged(): void { this.onToolStateChanged?.(this.getToolState()); } private requestRender(): void { if (this.animationFrame !== null) return; this.animationFrame = requestAnimationFrame(() => { this.animationFrame = null; this.renderEngine.setPreviewElement(this.state.previewElement); this.renderEngine.render(); }); } private generateId(): string { return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } // Public method to trigger zoom fit zoomFit(): void { this.zoomPan.zoomFit(this.allElements); this.requestRender(); } // Get current cursor world position getCursorWorld(): { x: number; y: number } { return this.zoomPan.screenToWorld(this.lastMouseScreen.x, this.lastMouseScreen.y); } }