Files
web-cad/frontend/src/interaction/index.ts
T
2026-08-26 13:51:02 +02:00

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