2026-06-26 10:50:24 +02:00
|
|
|
|
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';
|
2026-07-04 16:43:55 +02:00
|
|
|
|
import { GroupManager } from '../tools/modification/GroupTool';
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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';
|
2026-07-04 16:43:55 +02:00
|
|
|
|
import { formatDistance, type UnitType } from '../utils/format';
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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[] = [];
|
2026-07-04 16:43:55 +02:00
|
|
|
|
private groupManager: GroupManager | null = null;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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;
|
2026-07-04 16:08:51 +02:00
|
|
|
|
private boundKeyDown: EventListener;
|
|
|
|
|
|
private boundMouseMove: EventListener;
|
|
|
|
|
|
private boundMouseUp: EventListener;
|
|
|
|
|
|
private boundPointerCancel: EventListener;
|
2026-07-04 16:43:55 +02:00
|
|
|
|
private unit: UnitType = 'mm';
|
|
|
|
|
|
private scaleFactor: number = 1;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
|
};
|
2026-07-04 16:08:51 +02:00
|
|
|
|
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;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setElements(elements: CADElement[]): void {
|
|
|
|
|
|
this.allElements = elements;
|
|
|
|
|
|
this.snapEngine.setElements(elements);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setGroupManager(gm: GroupManager | null): void {
|
|
|
|
|
|
this.groupManager = gm;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setSnapEnabled(enabled: boolean): void {
|
|
|
|
|
|
this.state.snapEnabled = enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setOrthoEnabled(enabled: boolean): void {
|
|
|
|
|
|
this.state.ortho = enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setUnits(unit: UnitType, scaleFactor: number): void {
|
|
|
|
|
|
this.unit = unit;
|
|
|
|
|
|
this.scaleFactor = scaleFactor;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setGridSize(gridSize: number): void {
|
|
|
|
|
|
this.snapEngine.setConfig({ gridSpacing: gridSize });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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;
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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)) {
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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 {
|
2026-07-04 16:08:51 +02:00
|
|
|
|
this.addListener('pointerdown', this.onMouseDown.bind(this) as EventListener);
|
|
|
|
|
|
document.addEventListener('pointermove', this.boundMouseMove);
|
|
|
|
|
|
document.addEventListener('pointerup', this.boundMouseUp);
|
|
|
|
|
|
document.addEventListener('pointercancel', this.boundPointerCancel);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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);
|
2026-07-04 16:08:51 +02:00
|
|
|
|
document.addEventListener('keydown', this.boundKeyDown);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
detach(): void {
|
|
|
|
|
|
for (const { type, fn } of this.eventListeners) {
|
|
|
|
|
|
this.canvas.removeEventListener(type, fn);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.eventListeners = [];
|
2026-07-04 16:08:51 +02:00
|
|
|
|
document.removeEventListener('pointermove', this.boundMouseMove);
|
|
|
|
|
|
document.removeEventListener('pointerup', this.boundMouseUp);
|
|
|
|
|
|
document.removeEventListener('pointercancel', this.boundPointerCancel);
|
|
|
|
|
|
document.removeEventListener('keydown', this.boundKeyDown);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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 {
|
2026-07-04 16:08:51 +02:00
|
|
|
|
try { this.canvas.setPointerCapture((e as any).pointerId); } catch {}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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 {
|
2026-07-04 16:08:51 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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 {
|
2026-07-04 16:08:51 +02:00
|
|
|
|
try { this.canvas.releasePointerCapture((e as any).pointerId); } catch {}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
if (this.state.activeTool === 'select' && this.selectionEngine.isBoxSelecting()) {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
this.requestRender();
|
2026-06-27 14:12:37 +02:00
|
|
|
|
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
|
2026-07-04 16:08:51 +02:00
|
|
|
|
if (tool === 'line' || tool === 'rect' || tool === 'circle' || tool === 'dimension' || tool === 'leader' || tool === 'arc' || tool === 'polygon' || tool === 'revcloud') {
|
2026-06-27 14:12:37 +02:00
|
|
|
|
this.state.points.push(final);
|
|
|
|
|
|
this.confirmDraw();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
// 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) {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
} else {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
this.selectionEngine.clickSelect(world.x, world.y, this.allElements);
|
|
|
|
|
|
this.emitSelectionChange();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private handleDrawDown(pt: { x: number; y: number }): void {
|
2026-06-27 14:12:37 +02:00
|
|
|
|
// Clear any previous points when starting a new draw operation
|
|
|
|
|
|
if (this.state.points.length === 0) {
|
|
|
|
|
|
this.state.phase = 'drawing';
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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') {
|
2026-07-27 02:02:35 +02:00
|
|
|
|
this.updatePreview(pt);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
this.confirmDraw();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 14:12:37 +02:00
|
|
|
|
// Line, rect, circle, dimension, leader: wait for mouseup (click-drag behavior)
|
|
|
|
|
|
// Points are collected on mousedown, confirmed on mouseup
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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];
|
2026-07-04 16:43:55 +02:00
|
|
|
|
const rawDist = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
const angle = (Math.atan2(pt.y - first.y, pt.x - first.x) * 180) / Math.PI;
|
2026-07-04 16:43:55 +02:00
|
|
|
|
const distStr = formatDistance(rawDist, this.unit, this.scaleFactor);
|
|
|
|
|
|
this.onCommandTrigger?.(`distance: ${distStr} angle: ${angle.toFixed(1)}°`);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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 offsetted = offsetElement(this.modifySelected[0], dist);
|
|
|
|
|
|
// Create as new element (copy)
|
|
|
|
|
|
this.onElementCreated?.({ ...offsetted, id: this.generateId() });
|
|
|
|
|
|
this.resetModify();
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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)
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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') {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
} else if (tool === 'scale') {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
} 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;
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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));
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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') {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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));
|
2026-06-26 10:50:24 +02:00
|
|
|
|
this.onElementsModified?.(modified);
|
|
|
|
|
|
} else if (tool === 'scale') {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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));
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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 {
|
2026-06-26 20:31:50 +02:00
|
|
|
|
el = { ...this.state.previewElement, id: this.generateId(), layerId: layerId };
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-27 14:12:37 +02:00
|
|
|
|
this.renderEngine.setPreviewElement(this.state.previewElement);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|