Files
web-cad/frontend/src/interaction/index.ts
T

1037 lines
34 KiB
TypeScript
Raw Normal View History

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 { 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';
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 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;
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',
};
}
setElements(elements: CADElement[]): void {
this.allElements = elements;
this.snapEngine.setElements(elements);
}
setSnapEnabled(enabled: boolean): void {
this.state.snapEnabled = enabled;
}
setOrthoEnabled(enabled: boolean): void {
this.state.ortho = enabled;
}
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;
if (tool !== 'select' && tool !== 'pan') {
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('mousedown', this.onMouseDown.bind(this) as EventListener);
this.addListener('mousemove', this.onMouseMove.bind(this) as EventListener);
this.addListener('mouseup', this.onMouseUp.bind(this) as EventListener);
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.onKeyDown.bind(this) as EventListener);
}
detach(): void {
for (const { type, fn } of this.eventListeners) {
this.canvas.removeEventListener(type, fn);
}
this.eventListeners = [];
document.removeEventListener('keydown', this.onKeyDown.bind(this) as EventListener);
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 {
e.preventDefault();
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 'zoom-win':
this.handleZoomWinDown(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 {
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 {
e.preventDefault();
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;
}
if (this.state.activeTool === 'select' && this.selectionEngine.isBoxSelecting()) {
this.selectionEngine.finishBoxSelect(this.allElements);
this.emitSelectionChange();
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') {
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;
}
// 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) {
// Start potential box select
this.selectionEngine.startBoxSelect(world.x, world.y);
} else {
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.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 handleZoomWinDown(pt: { x: number; y: number }): void {
// Zoom window: two clicks define the zoom area
if (this.state.points.length === 0) {
this.state.points.push(pt);
this.state.phase = 'drawing';
this.onCommandTrigger?.('zoom: click opposite corner');
} else {
const first = this.state.points[0];
const minX = Math.min(first.x, pt.x);
const minY = Math.min(first.y, pt.y);
const maxX = Math.max(first.x, pt.x);
const maxY = Math.max(first.y, pt.y);
this.zoomPan.zoomToRect({ minX, minY, maxX, maxY });
this.state.points = [];
this.state.phase = 'idle';
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 dist = 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;
this.onCommandTrigger?.(`distance: ${dist.toFixed(2)} 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 offsetted = offsetElement(this.modifySelected[0], dist);
// Create as new element (copy)
this.onElementCreated?.({ ...offsetted, id: this.generateId() });
this.resetModify();
}
return;
}
// Move/Copy/Rotate/Scale/Mirror: require existing selection
// Second click confirms the modification
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 angle = angleBetween(base, pt);
this.state.previewElement = rotateElement(this.modifySelected[0], base.x, base.y, angle);
} else if (tool === 'scale') {
const dist = distance(base, pt);
const refDist = distance(base, { x: this.modifySelected[0].x, y: this.modifySelected[0].y });
const factor = refDist > 0 ? dist / refDist : 1;
this.state.previewElement = scaleElement(this.modifySelected[0], base.x, base.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;
const modified = this.modifySelected.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 angle = angleBetween(base, pt);
const modified = this.modifySelected.map(el => rotateElement(el, base.x, base.y, angle));
this.onElementsModified?.(modified);
} else if (tool === 'scale') {
const dist = distance(base, pt);
const refDist = distance(base, { x: this.modifySelected[0].x, y: this.modifySelected[0].y });
const factor = refDist > 0 ? dist / refDist : 1;
const modified = this.modifySelected.map(el => scaleElement(el, base.x, base.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 'zoom-win':
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);
}
}