Files
web-cad/frontend/src/interaction/index.ts
T
Leopoldadmin 7ad0e11873 fix: element creation fails with empty layer_id (FOREIGN KEY constraint)
Three fixes:
1. interaction/index.ts: Add layerId to created element in confirmDraw()
2. CanvasArea.tsx: Sync layers to LayerManager via layerManagerRef
3. App.tsx: Fallback to activeLayerId in handleElementCreated if layerId missing

Root cause: LayerManager.activeLayerId was empty because layers were never
synced from React state to the interaction layer LayerManager. New elements
got layer_id="" which failed SQLite FOREIGN KEY constraint.

Also includes Yjs sync race condition fix from previous commit.
2026-06-26 20:31:50 +02:00

1022 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}
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 {
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: auto-confirm after 2 points
if ((this.state.activeTool === 'line' || this.state.activeTool === 'rect' || this.state.activeTool === 'circle' || this.state.activeTool === 'dimension' || this.state.activeTool === 'leader') && this.state.points.length >= 2) {
this.updatePreview(pt);
this.confirmDraw();
return;
}
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.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);
}
}