feat: initial commit web-cad-neu with docker-compose, frontend and backend

This commit is contained in:
2026-06-26 10:50:24 +02:00
commit 4ec76fe406
102 changed files with 25722 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
import type { CADLayer } from '../types/cad.types';
export class LayerManager {
private layers: Map<string, CADLayer> = new Map();
private activeLayerId: string = '';
addLayer(layer: CADLayer): void {
this.layers.set(layer.id, layer);
if (!this.activeLayerId) this.activeLayerId = layer.id;
}
removeLayer(id: string): void {
this.layers.delete(id);
if (this.activeLayerId === id) {
const first = this.layers.keys().next();
this.activeLayerId = first.done ? '' : first.value;
}
}
getLayer(id: string): CADLayer | undefined {
return this.layers.get(id);
}
getLayers(): CADLayer[] {
return Array.from(this.layers.values()).sort((a, b) => a.sortOrder - b.sortOrder);
}
getVisibleLayers(): CADLayer[] {
return this.getLayers().filter(l => l.visible && !l.locked);
}
setActiveLayer(id: string): void {
if (this.layers.has(id)) this.activeLayerId = id;
}
getActiveLayer(): CADLayer | undefined {
return this.layers.get(this.activeLayerId);
}
getActiveLayerId(): string {
return this.activeLayerId;
}
toggleVisibility(id: string): void {
const layer = this.layers.get(id);
if (layer) layer.visible = !layer.visible;
}
toggleLock(id: string): void {
const layer = this.layers.get(id);
if (layer) layer.locked = !layer.locked;
}
clear(): void {
this.layers.clear();
this.activeLayerId = '';
}
/**
* Rename a layer.
*/
renameLayer(id: string, name: string): void {
const layer = this.layers.get(id);
if (layer) layer.name = name;
}
/**
* Update layer properties.
*/
updateLayer(id: string, props: Partial<CADLayer>): void {
const layer = this.layers.get(id);
if (layer) {
Object.assign(layer, props);
}
}
/**
* Set parent for a layer (tree structure).
*/
setParent(id: string, parentId: string | null): void {
const layer = this.layers.get(id);
if (!layer) return;
// Prevent circular references
if (parentId) {
let current: string | null = parentId;
while (current) {
if (current === id) return; // Would create a cycle
const parent = this.layers.get(current);
if (!parent) break;
current = parent.parentId;
}
}
layer.parentId = parentId;
}
/**
* Get child layers of a parent.
*/
getChildLayers(parentId: string | null): CADLayer[] {
return this.getLayers().filter(l => l.parentId === parentId);
}
/**
* Get layer tree structure.
*/
getLayerTree(): Array<CADLayer & { children: CADLayer[] }> {
const buildTree = (parentId: string | null): Array<CADLayer & { children: CADLayer[] }> => {
return this.getChildLayers(parentId).map(layer => ({
...layer,
children: buildTree(layer.id),
}));
};
return buildTree(null);
}
/**
* Move layer to a new position in the sort order.
*/
moveLayer(id: string, newSortOrder: number): void {
const layer = this.layers.get(id);
if (!layer) return;
layer.sortOrder = newSortOrder;
}
/**
* Duplicate a layer with all its properties.
*/
duplicateLayer(id: string): CADLayer | null {
const layer = this.layers.get(id);
if (!layer) return null;
const newId = `layer_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const copy: CADLayer = {
...layer,
id: newId,
name: `${layer.name} (Kopie)`,
sortOrder: layer.sortOrder + 1,
parentId: layer.parentId,
};
this.layers.set(newId, copy);
return copy;
}
/**
* Filter layers by criteria.
*/
filterLayers(criteria: {
visible?: boolean;
locked?: boolean;
color?: string;
lineType?: string;
nameContains?: string;
}): CADLayer[] {
return this.getLayers().filter(l => {
if (criteria.visible !== undefined && l.visible !== criteria.visible) return false;
if (criteria.locked !== undefined && l.locked !== criteria.locked) return false;
if (criteria.color && l.color !== criteria.color) return false;
if (criteria.lineType && l.lineType !== criteria.lineType) return false;
if (criteria.nameContains && !l.name.toLowerCase().includes(criteria.nameContains.toLowerCase())) return false;
return true;
});
}
/**
* Get all descendant layer IDs (children, grandchildren, etc.).
*/
getDescendantIds(id: string): string[] {
const result: string[] = [];
const collect = (parentId: string) => {
for (const layer of this.layers.values()) {
if (layer.parentId === parentId) {
result.push(layer.id);
collect(layer.id);
}
}
};
collect(id);
return result;
}
/**
* Check if a layer is locked.
*/
isLocked(id: string): boolean {
const layer = this.layers.get(id);
return layer ? layer.locked : false;
}
/**
* Get layer color.
*/
getLayerColor(id: string): string | undefined {
const layer = this.layers.get(id);
return layer?.color;
}
}
+970
View File
@@ -0,0 +1,970 @@
import type {
CADElement, CADLayer, CADProperties, BoundingBox, Viewport,
} from '../types/cad.types';
import { ZoomPanController } from './ZoomPanController';
import { SpatialIndex } from './SpatialIndex';
import { LayerManager } from './LayerManager';
import { pluginRegistry } from '../plugins';
export interface RenderOptions {
showGrid: boolean;
gridSize: number;
showSnapPoints: boolean;
showOrtho: boolean;
orthoAngle: number;
backgroundSrc?: string;
backgroundScale: number;
backgroundOffsetX: number;
backgroundOffsetY: number;
backgroundRotation: number;
backgroundOpacity: number;
}
export interface SelectionState {
selectedIds: Set<string>;
hoverId: string | null;
boxStart: { x: number; y: number } | null;
boxEnd: { x: number; y: number } | null;
}
export interface SnapPoint {
x: number;
y: number;
type: 'endpoint' | 'midpoint' | 'center' | 'intersection' | 'nearest';
}
export class RenderEngine {
private ctx: CanvasRenderingContext2D;
private canvas: HTMLCanvasElement;
private zoomPan: ZoomPanController;
private spatialIndex: SpatialIndex;
private layerManager: LayerManager;
private options: RenderOptions;
private selection: SelectionState;
private snapPoints: SnapPoint[] = [];
private activeSnapPoint: SnapPoint | null = null;
private dpr = 1;
constructor(
canvas: HTMLCanvasElement,
zoomPan: ZoomPanController,
spatialIndex: SpatialIndex,
layerManager: LayerManager,
) {
this.canvas = canvas;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context not available');
this.ctx = ctx;
this.zoomPan = zoomPan;
this.spatialIndex = spatialIndex;
this.layerManager = layerManager;
this.options = {
showGrid: true,
gridSize: 20,
showSnapPoints: false,
showOrtho: false,
orthoAngle: 0,
backgroundScale: 1,
backgroundOffsetX: 0,
backgroundOffsetY: 0,
backgroundRotation: 0,
backgroundOpacity: 0.5,
};
this.selection = {
selectedIds: new Set(),
hoverId: null,
boxStart: null,
boxEnd: null,
};
}
setOptions(opts: Partial<RenderOptions>): void {
this.options = { ...this.options, ...opts };
}
getOptions(): RenderOptions {
return { ...this.options };
}
setSelection(sel: Partial<SelectionState>): void {
this.selection = { ...this.selection, ...sel };
}
getSelection(): SelectionState {
return { ...this.selection };
}
setSnapPoints(points: SnapPoint[]): void {
this.snapPoints = points;
}
setActiveSnapPoint(pt: SnapPoint | null): void {
this.activeSnapPoint = pt;
}
resize(width: number, height: number): void {
this.dpr = window.devicePixelRatio || 1;
this.canvas.width = width * this.dpr;
this.canvas.height = height * this.dpr;
this.canvas.style.width = width + 'px';
this.canvas.style.height = height + 'px';
this.ctx.scale(this.dpr, this.dpr);
}
setLayers(layers: CADLayer[]): void {
this.layerManager.clear();
for (const layer of layers) {
this.layerManager.addLayer(layer);
}
}
private blockDefinitions: Map<string, { elements: CADElement[] }> = new Map();
setBlockDefinitions(blocks: Array<{ id: string; elements: CADElement[] }>): void {
this.blockDefinitions.clear();
for (const b of blocks) {
this.blockDefinitions.set(b.id, b);
}
}
render(): void {
const w = this.canvas.width / this.dpr;
const h = this.canvas.height / this.dpr;
this.ctx.save();
this.ctx.fillStyle = '#1e1e2e';
this.ctx.fillRect(0, 0, w, h);
if (this.options.showGrid) this.drawGrid(w, h);
if (this.options.backgroundSrc) this.drawBackground();
const viewport = this.zoomPan.getViewport();
const visibleElements = this.spatialIndex.search({
minX: viewport.minX, minY: viewport.minY,
maxX: viewport.maxX, maxY: viewport.maxY,
});
const layers = this.layerManager.getLayers();
for (const layer of layers) {
if (!layer.visible) continue;
const els = visibleElements.filter(e => e.layerId === layer.id);
for (const el of els) {
this.drawElement(el, layer);
}
}
this.drawSelectionBox();
if (this.options.showSnapPoints) this.drawSnapPoints();
if (this.options.showOrtho) this.drawOrtho();
this.ctx.restore();
}
private drawGrid(w: number, h: number): void {
const scale = this.zoomPan.getScale();
const gridSize = this.options.gridSize * scale;
if (gridSize < 4) return;
const offsetX = this.zoomPan.getTransform().e % gridSize;
const offsetY = this.zoomPan.getTransform().f % gridSize;
this.ctx.strokeStyle = '#2a2a3e';
this.ctx.lineWidth = 1;
this.ctx.beginPath();
for (let x = offsetX; x < w; x += gridSize) {
this.ctx.moveTo(x, 0);
this.ctx.lineTo(x, h);
}
for (let y = offsetY; y < h; y += gridSize) {
this.ctx.moveTo(0, y);
this.ctx.lineTo(w, y);
}
this.ctx.stroke();
// Major grid lines every 5 cells
const majorSize = gridSize * 5;
if (majorSize >= 20) {
const majOffX = this.zoomPan.getTransform().e % majorSize;
const majOffY = this.zoomPan.getTransform().f % majorSize;
this.ctx.strokeStyle = '#33334a';
this.ctx.beginPath();
for (let x = majOffX; x < w; x += majorSize) {
this.ctx.moveTo(x, 0);
this.ctx.lineTo(x, h);
}
for (let y = majOffY; y < h; y += majorSize) {
this.ctx.moveTo(0, y);
this.ctx.lineTo(w, y);
}
this.ctx.stroke();
}
}
private drawBackground(): void {
// Background image rendering with transform
const img = new Image();
img.src = this.options.backgroundSrc!;
if (!img.complete) {
img.onload = () => this.render();
return;
}
this.ctx.save();
this.ctx.globalAlpha = this.options.backgroundOpacity;
const s = this.zoomPan.getScale();
const ox = this.zoomPan.getTransform().e;
const oy = this.zoomPan.getTransform().f;
const bx = this.options.backgroundOffsetX * s + ox;
const by = this.options.backgroundOffsetY * s + oy;
const bw = img.width * this.options.backgroundScale * s;
const bh = img.height * this.options.backgroundScale * s;
this.ctx.translate(bx + bw / 2, by + bh / 2);
this.ctx.rotate(this.options.backgroundRotation);
this.ctx.drawImage(img, -bw / 2, -bh / 2, bw, bh);
this.ctx.restore();
}
private drawElement(el: CADElement, layer: CADLayer): void {
const ctx = this.ctx;
const s = this.zoomPan.getScale();
const ox = this.zoomPan.getTransform().e;
const oy = this.zoomPan.getTransform().f;
const sx = (val: number) => val * s + ox;
const sy = (val: number) => val * s + oy;
const stroke = el.properties.stroke || layer.color;
const strokeWidth = (el.properties.strokeWidth || 1) * s;
const fill = el.properties.fill;
const isSelected = this.selection.selectedIds.has(el.id);
const isHover = this.selection.hoverId === el.id;
ctx.save();
ctx.strokeStyle = stroke;
ctx.lineWidth = Math.max(0.5, strokeWidth);
if (layer.lineType === 'dashed') ctx.setLineDash([8, 4]);
if (layer.lineType === 'dotted') ctx.setLineDash([2, 4]);
if (isSelected) {
ctx.strokeStyle = '#00aaff';
ctx.lineWidth = Math.max(1, strokeWidth + 1);
} else if (isHover) {
ctx.strokeStyle = '#ffaa00';
}
switch (el.type) {
case 'line': this.drawLine(el, sx, sy); break;
case 'rect': this.drawRect(el, sx, sy); break;
case 'circle': this.drawCircle(el, sx, sy, s); break;
case 'arc': this.drawArc(el, sx, sy, s); break;
case 'polyline': this.drawPolyline(el, sx, sy); break;
case 'polygon': this.drawPolygon(el, sx, sy, fill); break;
case 'text': this.drawText(el, sx, sy, s); break;
case 'dimension': this.drawDimension(el, sx, sy, s); break;
case 'block_instance': this.drawBlockInstance(el, sx, sy, s); break;
case 'chair': this.drawChair(el, sx, sy, s); break;
case 'table': this.drawTable(el, sx, sy, s); break;
case 'stage': this.drawStage(el, sx, sy, s); break;
case 'leader': this.drawLeader(el, sx, sy, s); break;
case 'revcloud': this.drawRevCloud(el, sx, sy, s); break;
default: {
// Plugin element types
const ext = pluginRegistry.getElementType(el.type);
if (ext?.render) {
ext.render(this.ctx, el, s);
}
break;
}
}
if (isSelected) this.drawSelectionHandles(el, sx, sy, s);
ctx.restore();
}
private drawLine(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number): void {
const p = el.properties;
this.ctx.beginPath();
this.ctx.moveTo(sx(p.x1 ?? el.x), sy(p.y1 ?? el.y));
this.ctx.lineTo(sx(p.x2 ?? el.x + el.width), sy(p.y2 ?? el.y + el.height));
this.ctx.stroke();
}
private drawRect(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number): void {
const x = sx(el.x - el.width / 2);
const y = sy(el.y - el.height / 2);
const w = el.width * (this.zoomPan.getScale());
const h = el.height * (this.zoomPan.getScale());
if (el.properties.fill) {
this.ctx.fillStyle = el.properties.fill;
this.ctx.fillRect(x, y, w, h);
}
if (el.properties.hatch) {
this.drawHatchRect(x, y, w, h, (el.properties.hatchSpacing as number) || 8);
}
this.ctx.strokeRect(x, y, w, h);
}
private drawHatchRect(x: number, y: number, w: number, h: number, spacing: number): void {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(x, y, w, h);
this.ctx.clip();
this.ctx.strokeStyle = this.ctx.strokeStyle || '#888';
this.ctx.lineWidth = 0.5;
for (let i = -h; i < w + h; i += spacing) {
this.ctx.beginPath();
this.ctx.moveTo(x + i, y);
this.ctx.lineTo(x + i + h, y + h);
this.ctx.stroke();
}
this.ctx.restore();
}
private drawCircle(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const r = (el.properties.radius || el.width / 2) * s;
this.ctx.beginPath();
this.ctx.arc(sx(el.x), sy(el.y), Math.max(0.5, r), 0, Math.PI * 2);
if (el.properties.fill) {
this.ctx.fillStyle = el.properties.fill;
this.ctx.fill();
}
if (el.properties.hatch) {
this.ctx.save();
this.ctx.clip();
const cx = sx(el.x);
const cy = sy(el.y);
const spacing = (el.properties.hatchSpacing as number) || 8;
this.ctx.lineWidth = 0.5;
for (let i = -r; i < r * 2; i += spacing) {
this.ctx.beginPath();
this.ctx.moveTo(cx - r + i, cy - r);
this.ctx.lineTo(cx - r + i + r * 2, cy + r);
this.ctx.stroke();
}
this.ctx.restore();
}
this.ctx.stroke();
}
private drawArc(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const r = (el.properties.radius || el.width / 2) * s;
const start = (el.properties.startAngle || 0) * Math.PI / 180;
const end = (el.properties.endAngle || 360) * Math.PI / 180;
this.ctx.beginPath();
this.ctx.arc(sx(el.x), sy(el.y), Math.max(0.5, r), start, end);
this.ctx.stroke();
}
private drawPolyline(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number): void {
const pts = el.properties.points || [];
if (pts.length < 2) return;
this.ctx.beginPath();
this.ctx.moveTo(sx(pts[0].x), sy(pts[0].y));
for (let i = 1; i < pts.length; i++) {
this.ctx.lineTo(sx(pts[i].x), sy(pts[i].y));
}
this.ctx.stroke();
}
private drawPolygon(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, fill?: string): void {
const pts = el.properties.points || [];
if (pts.length < 3) return;
this.ctx.beginPath();
this.ctx.moveTo(sx(pts[0].x), sy(pts[0].y));
for (let i = 1; i < pts.length; i++) {
this.ctx.lineTo(sx(pts[i].x), sy(pts[i].y));
}
this.ctx.closePath();
if (fill || el.properties.fill) {
this.ctx.fillStyle = fill || el.properties.fill!;
this.ctx.fill();
}
this.ctx.stroke();
}
private drawText(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const fontSize = (el.properties.fontSize || 12) * s;
this.ctx.font = `${fontSize}px sans-serif`;
this.ctx.fillStyle = el.properties.stroke || '#e0e0e0';
this.ctx.textBaseline = 'top';
const text = el.properties.text || '';
const lines = String(text).split('\n');
const align = (el.properties.align as string) || 'left';
this.ctx.textAlign = align as CanvasTextAlign;
if (el.properties.rotation) {
this.ctx.save();
this.ctx.translate(sx(el.x), sy(el.y));
this.ctx.rotate((el.properties.rotation as number) * Math.PI / 180);
lines.forEach((line, i) => {
this.ctx.fillText(line, 0, i * fontSize * 1.2);
});
this.ctx.restore();
} else {
lines.forEach((line, i) => {
this.ctx.fillText(line, sx(el.x), sy(el.y) + i * fontSize * 1.2);
});
}
this.ctx.textAlign = 'left';
}
private drawDimension(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const p = el.properties;
const dimType = (p.dimType as string) || 'linear';
const value = (p.value as string) || '';
const arrowSize = 5 * s;
if (dimType === 'angular') {
this.drawAngularDimension(el, sx, sy, s, arrowSize, value);
return;
}
if (dimType === 'radial') {
this.drawRadialDimension(el, sx, sy, s, arrowSize, value);
return;
}
// Linear dimension
const x1 = p.x1 ?? el.x - el.width / 2;
const y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x + el.width / 2;
const y2 = p.y2 ?? el.y;
const offset = 15 * s;
// Extension lines
this.ctx.strokeStyle = '#888';
this.ctx.lineWidth = 0.5 * s;
this.ctx.setLineDash([]);
this.ctx.beginPath();
this.ctx.moveTo(sx(x1), sy(y1));
this.ctx.lineTo(sx(x1), sy(y1) - offset);
this.ctx.moveTo(sx(x2), sy(y2));
this.ctx.lineTo(sx(x2), sy(y2) - offset);
this.ctx.stroke();
// Dimension line
this.ctx.strokeStyle = '#aaa';
this.ctx.lineWidth = 1 * s;
this.ctx.beginPath();
this.ctx.moveTo(sx(x1), sy(y1) - offset);
this.ctx.lineTo(sx(x2), sy(y2) - offset);
this.ctx.stroke();
// Arrows
this.ctx.beginPath();
this.ctx.moveTo(sx(x1), sy(y1) - offset);
this.ctx.lineTo(sx(x1) + arrowSize, sy(y1) - offset - arrowSize / 2);
this.ctx.moveTo(sx(x1), sy(y1) - offset);
this.ctx.lineTo(sx(x1) + arrowSize, sy(y1) - offset + arrowSize / 2);
this.ctx.moveTo(sx(x2), sy(y2) - offset);
this.ctx.lineTo(sx(x2) - arrowSize, sy(y2) - offset - arrowSize / 2);
this.ctx.moveTo(sx(x2), sy(y2) - offset);
this.ctx.lineTo(sx(x2) - arrowSize, sy(y2) - offset + arrowSize / 2);
this.ctx.stroke();
// Text
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
this.ctx.font = `${10 * s}px sans-serif`;
this.ctx.fillStyle = '#ccc';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'bottom';
this.ctx.fillText(value || Math.sqrt((x2-x1)**2+(y2-y1)**2).toFixed(1), sx(midX), sy(midY) - offset - 4);
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
}
private drawAngularDimension(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number, arrowSize: number, value: string): void {
const p = el.properties;
const vx = Number(p.x1 ?? el.x);
const vy = Number(p.y1 ?? el.y);
const ax1 = Number(p.ax1 ?? vx + 50);
const ay1 = Number(p.ay1 ?? vy);
const ax2 = Number(p.ax2 ?? vx + 50);
const ay2 = Number(p.ay2 ?? vy + 50);
const r = Number(p.radius) || 30;
const a1 = Math.atan2(ay1 - vy, ax1 - vx);
const a2 = Math.atan2(ay2 - vy, ax2 - vx);
// Arc
this.ctx.strokeStyle = '#aaa';
this.ctx.lineWidth = 1 * s;
this.ctx.beginPath();
this.ctx.arc(sx(vx), sy(vy), r * s, a1, a2);
this.ctx.stroke();
// Extension lines
this.ctx.strokeStyle = '#888';
this.ctx.lineWidth = 0.5 * s;
this.ctx.beginPath();
this.ctx.moveTo(sx(vx), sy(vy));
this.ctx.lineTo(sx(ax1), sy(ay1));
this.ctx.moveTo(sx(vx), sy(vy));
this.ctx.lineTo(sx(ax2), sy(ay2));
this.ctx.stroke();
// Text
const midAngle = (a1 + a2) / 2;
const tx = vx + Math.cos(midAngle) * (r + 10);
const ty = vy + Math.sin(midAngle) * (r + 10);
this.ctx.font = `${10 * s}px sans-serif`;
this.ctx.fillStyle = '#ccc';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(value, sx(tx), sy(ty));
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
}
private drawRadialDimension(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number, arrowSize: number, value: string): void {
const p = el.properties;
const cx = p.x1 ?? el.x;
const cy = p.y1 ?? el.y;
const ex = p.x2 ?? el.x + el.width;
const ey = p.y2 ?? el.y;
// Radial line
this.ctx.strokeStyle = '#aaa';
this.ctx.lineWidth = 1 * s;
this.ctx.beginPath();
this.ctx.moveTo(sx(cx), sy(cy));
this.ctx.lineTo(sx(ex), sy(ey));
this.ctx.stroke();
// Arrow at end
const angle = Math.atan2(ey - cy, ex - cx);
this.ctx.beginPath();
this.ctx.moveTo(sx(ex), sy(ey));
this.ctx.lineTo(sx(ex) - arrowSize * Math.cos(angle - 0.3), sy(ey) - arrowSize * Math.sin(angle - 0.3));
this.ctx.moveTo(sx(ex), sy(ey));
this.ctx.lineTo(sx(ex) - arrowSize * Math.cos(angle + 0.3), sy(ey) - arrowSize * Math.sin(angle + 0.3));
this.ctx.stroke();
// Text
const midX = (cx + ex) / 2;
const midY = (cy + ey) / 2;
this.ctx.font = `${10 * s}px sans-serif`;
this.ctx.fillStyle = '#ccc';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'bottom';
this.ctx.fillText(value, sx(midX), sy(midY) - 4);
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
}
private drawLeader(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const p = el.properties;
const x1 = p.x1 ?? el.x;
const y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x;
const y2 = p.y2 ?? el.y;
const text = (p.text as string) || '';
const fontSize = ((p.fontSize as number) || 12) * s;
// Arrow at start point
const angle = Math.atan2(y2 - y1, x2 - x1);
const arrowSize = 6 * s;
this.ctx.strokeStyle = p.stroke || '#e0e0e0';
this.ctx.lineWidth = (p.strokeWidth as number) || 1;
this.ctx.setLineDash([]);
this.ctx.beginPath();
this.ctx.moveTo(sx(x1), sy(y1));
this.ctx.lineTo(sx(x1) + arrowSize * Math.cos(angle - 0.4), sy(y1) + arrowSize * Math.sin(angle - 0.4));
this.ctx.moveTo(sx(x1), sy(y1));
this.ctx.lineTo(sx(x1) + arrowSize * Math.cos(angle + 0.4), sy(y1) + arrowSize * Math.sin(angle + 0.4));
this.ctx.stroke();
// Leader line
this.ctx.beginPath();
this.ctx.moveTo(sx(x1), sy(y1));
this.ctx.lineTo(sx(x2), sy(y2));
// Small horizontal dogleg
const doglegX = (x2 > x1 ? 1 : -1) * 20;
this.ctx.lineTo(sx(x2 + doglegX), sy(y2));
this.ctx.stroke();
// Text
if (text) {
this.ctx.font = `${fontSize}px sans-serif`;
this.ctx.fillStyle = p.stroke || '#e0e0e0';
this.ctx.textBaseline = 'bottom';
this.ctx.textAlign = x2 > x1 ? 'left' : 'right';
this.ctx.fillText(text, sx(x2 + doglegX), sy(y2) - 2);
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
}
}
private drawRevCloud(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const p = el.properties;
const points = (p.points as Array<{x:number;y:number}>) || [];
if (points.length < 2) return;
const arcHeight = ((p.arcHeight as number) || 8) * s;
this.ctx.strokeStyle = p.stroke || '#e0e0e0';
this.ctx.lineWidth = (p.strokeWidth as number) || 1.5;
this.ctx.setLineDash([]);
if (p.fill && p.fill !== 'none') {
this.ctx.fillStyle = p.fill;
}
this.ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const cur = points[i];
const next = points[(i + 1) % points.length];
const mx = (cur.x + next.x) / 2;
const my = (cur.y + next.y) / 2;
const dist = Math.sqrt((next.x - cur.x) ** 2 + (next.y - cur.y) ** 2);
const bulge = Math.min(arcHeight / dist, 0.5);
// Draw arc segment as quadratic curve with bulge
const cpX = mx + (next.y - cur.y) * bulge;
const cpY = my - (next.x - cur.x) * bulge;
if (i === 0) this.ctx.moveTo(sx(cur.x), sy(cur.y));
this.ctx.quadraticCurveTo(sx(cpX), sy(cpY), sx(next.x), sy(next.y));
}
this.ctx.closePath();
if (p.fill && p.fill !== 'none') this.ctx.fill();
this.ctx.stroke();
}
private drawBlockInstance(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const blockId = el.properties.blockId as string;
const blockDef = this.blockDefinitions.get(blockId);
if (!blockDef) {
// Fallback: bounding box
const w = el.width * s;
const h = el.height * s;
this.ctx.strokeStyle = '#666';
this.ctx.setLineDash([4, 4]);
this.ctx.strokeRect(sx(el.x) - w / 2, sy(el.y) - h / 2, w, h);
this.ctx.setLineDash([]);
return;
}
const rotation = (el.properties.rotation || 0) * Math.PI / 180;
const scale = (el.properties.scale || 1) * s;
const ox = el.properties.offsetX || 0;
const oy = el.properties.offsetY || 0;
const cx = sx(el.x);
const cy = sy(el.y);
this.ctx.save();
this.ctx.translate(cx, cy);
this.ctx.rotate(rotation);
for (const childEl of blockDef.elements) {
const lx = (childEl.x + Number(ox)) * scale;
const ly = (childEl.y + Number(oy)) * scale;
const lw = childEl.width * scale;
const lh = childEl.height * scale;
const props = { ...childEl.properties };
this.ctx.save();
if (props.fill) this.ctx.fillStyle = props.fill;
this.ctx.strokeStyle = props.stroke || '#999';
this.ctx.lineWidth = Math.max(0.5, 1 * s);
switch (childEl.type) {
case 'rect':
if (props.fill) this.ctx.fillRect(lx - lw / 2, ly - lh / 2, lw, lh);
this.ctx.strokeRect(lx - lw / 2, ly - lh / 2, lw, lh);
break;
case 'circle': {
const r = (props.radius || lw / 2) * scale / s;
this.ctx.beginPath();
this.ctx.arc(lx, ly, r, 0, Math.PI * 2);
if (props.fill) this.ctx.fill();
this.ctx.stroke();
break;
}
case 'line': {
const x1 = (Number(props.x1) + Number(ox)) * scale;
const y1 = (Number(props.y1) + Number(oy)) * scale;
const x2 = (Number(props.x2) + Number(ox)) * scale;
const y2 = (Number(props.y2) + Number(oy)) * scale;
this.ctx.beginPath();
this.ctx.moveTo(x1, y1);
this.ctx.lineTo(x2, y2);
this.ctx.stroke();
break;
}
case 'arc': {
const r = (props.radius || lw / 2) * scale / s;
const startAngle = (props.startAngle || 0) * Math.PI / 180;
const endAngle = (props.endAngle || 360) * Math.PI / 180;
this.ctx.beginPath();
this.ctx.arc(lx, ly, r, startAngle, endAngle);
this.ctx.stroke();
break;
}
}
this.ctx.restore();
}
this.ctx.restore();
}
private drawChair(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const w = el.width * s;
const h = el.height * s;
const cx = sx(el.x);
const cy = sy(el.y);
const rot = (el.properties.rotation || 0) * Math.PI / 180;
this.ctx.save();
this.ctx.translate(cx, cy);
this.ctx.rotate(rot);
// Seat
if (el.properties.fill) {
this.ctx.fillStyle = el.properties.fill;
} else {
this.ctx.fillStyle = '#4a90d9';
}
this.ctx.fillRect(-w / 2, -h / 2, w, h);
// Backrest (top portion)
this.ctx.fillStyle = '#3a7ac9';
this.ctx.fillRect(-w / 2, -h / 2, w, h * 0.2);
// Outline
this.ctx.strokeStyle = '#2a5a99';
this.ctx.lineWidth = 0.5;
this.ctx.strokeRect(-w / 2, -h / 2, w, h);
this.ctx.restore();
}
private drawTable(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const w = el.width * s;
const h = el.height * s;
const cx = sx(el.x);
const cy = sy(el.y);
const rot = (el.properties.rotation || 0) * Math.PI / 180;
const shape = el.properties.shape || 'rect';
this.ctx.save();
this.ctx.translate(cx, cy);
this.ctx.rotate(rot);
if (shape === 'round') {
const r = Math.min(w, h) / 2;
this.ctx.fillStyle = el.properties.fill || '#8b6f47';
this.ctx.beginPath();
this.ctx.arc(0, 0, r, 0, Math.PI * 2);
this.ctx.fill();
this.ctx.strokeStyle = el.properties.stroke || '#5a4a37';
this.ctx.lineWidth = (el.properties.strokeWidth || 1.5) * s;
this.ctx.stroke();
} else {
this.ctx.fillStyle = el.properties.fill || '#8b6f47';
this.ctx.fillRect(-w / 2, -h / 2, w, h);
this.ctx.strokeStyle = el.properties.stroke || '#5a4a37';
this.ctx.lineWidth = (el.properties.strokeWidth || 1.5) * s;
this.ctx.strokeRect(-w / 2, -h / 2, w, h);
}
this.ctx.restore();
}
private drawStage(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const w = el.width * s;
const h = el.height * s;
const cx = sx(el.x);
const cy = sy(el.y);
const rot = (el.properties.rotation || 0) * Math.PI / 180;
this.ctx.save();
this.ctx.translate(cx, cy);
this.ctx.rotate(rot);
// Stage floor
this.ctx.fillStyle = el.properties.fill || '#2c3e50';
this.ctx.fillRect(-w / 2, -h / 2, w, h);
// Border
this.ctx.strokeStyle = el.properties.stroke || '#1a2e3f';
this.ctx.lineWidth = (el.properties.strokeWidth || 2) * s;
this.ctx.strokeRect(-w / 2, -h / 2, w, h);
// Label
const label = el.properties.label as string || 'Bühne';
this.ctx.fillStyle = '#fff';
this.ctx.font = `${Math.max(10, 14 * s)}px Inter, sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(label, 0, 0);
this.ctx.restore();
}
private drawSelectionHandles(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const w = el.width * s;
const h = el.height * s;
const x = sx(el.x) - w / 2;
const y = sy(el.y) - h / 2;
const handleSize = 6;
this.ctx.fillStyle = '#00aaff';
this.ctx.strokeStyle = '#fff';
this.ctx.lineWidth = 1;
const corners = [
[x, y], [x + w, y], [x, y + h], [x + w, y + h],
[x + w / 2, y], [x + w / 2, y + h], [x, y + h / 2], [x + w, y + h / 2],
];
for (const [hx, hy] of corners) {
this.ctx.fillRect(hx - handleSize / 2, hy - handleSize / 2, handleSize, handleSize);
this.ctx.strokeRect(hx - handleSize / 2, hy - handleSize / 2, handleSize, handleSize);
}
}
private drawSelectionBox(): void {
if (!this.selection.boxStart || !this.selection.boxEnd) return;
const s = this.zoomPan.getScale();
const ox = this.zoomPan.getTransform().e;
const oy = this.zoomPan.getTransform().f;
const x1 = this.selection.boxStart.x * s + ox;
const y1 = this.selection.boxStart.y * s + oy;
const x2 = this.selection.boxEnd.x * s + ox;
const y2 = this.selection.boxEnd.y * s + oy;
this.ctx.strokeStyle = '#00aaff';
this.ctx.fillStyle = 'rgba(0, 170, 255, 0.1)';
this.ctx.lineWidth = 1;
this.ctx.setLineDash([4, 4]);
this.ctx.fillRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
this.ctx.strokeRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
this.ctx.setLineDash([]);
}
private drawSnapPoints(): void {
const s = this.zoomPan.getScale();
const ox = this.zoomPan.getTransform().e;
const oy = this.zoomPan.getTransform().f;
for (const pt of this.snapPoints) {
const x = pt.x * s + ox;
const y = pt.y * s + oy;
const isActive = this.activeSnapPoint?.x === pt.x && this.activeSnapPoint?.y === pt.y;
this.ctx.fillStyle = isActive ? '#ff0' : '#0f0';
this.ctx.beginPath();
this.ctx.arc(x, y, isActive ? 6 : 4, 0, Math.PI * 2);
this.ctx.fill();
}
}
private drawOrtho(): void {
// Draw ortho tracking line from cursor
// Placeholder — will be connected to interaction engine
}
// Hit testing
hitTest(worldX: number, worldY: number, tolerance: number = 5): CADElement | null {
const viewport = this.zoomPan.getViewport();
const candidates = this.spatialIndex.search({
minX: worldX - tolerance, minY: worldY - tolerance,
maxX: worldX + tolerance, maxY: worldY + tolerance,
});
const visibleLayerIds = new Set(
this.layerManager.getVisibleLayers().map(l => l.id),
);
let best: CADElement | null = null;
let bestDist = tolerance;
for (const el of candidates) {
if (!visibleLayerIds.has(el.layerId)) continue;
const dist = this.elementDistance(el, worldX, worldY);
if (dist < bestDist) {
bestDist = dist;
best = el;
}
}
return best;
}
private elementDistance(el: CADElement, x: number, y: number): number {
const p = el.properties;
switch (el.type) {
case 'line': {
const x1 = p.x1 ?? el.x;
const y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x + el.width;
const y2 = p.y2 ?? el.y + el.height;
return this.pointToSegmentDist(x, y, x1, y1, x2, y2);
}
case 'circle': {
const r = p.radius || el.width / 2;
const d = Math.sqrt((x - el.x) ** 2 + (y - el.y) ** 2);
return Math.abs(d - r);
}
case 'rect':
case 'table':
case 'stage': {
const halfW = el.width / 2;
const halfH = el.height / 2;
const dx = Math.max(Math.abs(x - el.x) - halfW, 0);
const dy = Math.max(Math.abs(y - el.y) - halfH, 0);
return Math.sqrt(dx * dx + dy * dy);
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
let minDist = Infinity;
for (let i = 0; i < pts.length - 1; i++) {
const d = this.pointToSegmentDist(x, y, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
minDist = Math.min(minDist, d);
}
if (el.type === 'polygon' && pts.length > 2) {
const d = this.pointToSegmentDist(x, y, pts[pts.length - 1].x, pts[pts.length - 1].y, pts[0].x, pts[0].y);
minDist = Math.min(minDist, d);
}
return minDist;
}
default: {
const halfW = el.width / 2;
const halfH = el.height / 2;
const dx = Math.max(Math.abs(x - el.x) - halfW, 0);
const dy = Math.max(Math.abs(y - el.y) - halfH, 0);
return Math.sqrt(dx * dx + dy * dy);
}
}
}
private pointToSegmentDist(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1;
const dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.sqrt((px - x1) ** 2 + (py - y1) ** 2);
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const cx = x1 + t * dx;
const cy = y1 + t * dy;
return Math.sqrt((px - cx) ** 2 + (py - cy) ** 2);
}
// Bounding box for an element
getElementBBox(el: CADElement): BoundingBox {
const halfW = el.width / 2;
const halfH = el.height / 2;
return {
minX: el.x - halfW, minY: el.y - halfH,
maxX: el.x + halfW, maxY: el.y + halfH,
};
}
// Get elements within a world-space rectangle (for box selection)
getElementsInRect(minX: number, minY: number, maxX: number, maxY: number): CADElement[] {
const candidates = this.spatialIndex.search({ minX, minY, maxX, maxY });
const visibleLayerIds = new Set(
this.layerManager.getVisibleLayers().map(l => l.id),
);
return candidates.filter(el => {
if (!visibleLayerIds.has(el.layerId)) return false;
const bb = this.getElementBBox(el);
return bb.minX >= minX && bb.maxX <= maxX && bb.minY >= minY && bb.maxY <= maxY;
});
}
// Get elements intersecting a world-space rectangle (for crossing selection)
getElementsIntersectingRect(minX: number, minY: number, maxX: number, maxY: number): CADElement[] {
const candidates = this.spatialIndex.search({ minX, minY, maxX, maxY });
const visibleLayerIds = new Set(
this.layerManager.getVisibleLayers().map(l => l.id),
);
return candidates.filter(el => {
if (!visibleLayerIds.has(el.layerId)) return false;
const bb = this.getElementBBox(el);
// Check if bbox intersects the rect (not necessarily fully enclosed)
return bb.minX <= maxX && bb.maxX >= minX && bb.minY <= maxY && bb.maxY >= minY;
});
}
}
+297
View File
@@ -0,0 +1,297 @@
import type { CADElement, CADLayer } from '../types/cad.types';
import { RenderEngine } from './RenderEngine';
import { SpatialIndex } from './SpatialIndex';
import { LayerManager } from './LayerManager';
export type SelectionMode = 'single' | 'multiple' | 'box' | 'lasso';
export type SelectionFilter = 'all' | 'lines' | 'circles' | 'rects' | 'text' | 'chairs' | 'blocks';
export interface SelectionOptions {
mode: SelectionMode;
filter: SelectionFilter;
additive: boolean; // shift-click to add to selection
subtractive: boolean; // ctrl-click to remove from selection
tolerance: number; // world units for hit testing
}
export class SelectionEngine {
private renderEngine: RenderEngine;
private spatialIndex: SpatialIndex;
private layerManager: LayerManager;
private selectedIds: Set<string> = new Set();
private hoverId: string | null = null;
private options: SelectionOptions;
private boxStart: { x: number; y: number } | null = null;
private boxEnd: { x: number; y: number } | null = null;
private listeners: Array<(selected: CADElement[]) => void> = [];
constructor(
renderEngine: RenderEngine,
spatialIndex: SpatialIndex,
layerManager: LayerManager,
) {
this.renderEngine = renderEngine;
this.spatialIndex = spatialIndex;
this.layerManager = layerManager;
this.options = {
mode: 'single',
filter: 'all',
additive: false,
subtractive: false,
tolerance: 5,
};
}
setOptions(opts: Partial<SelectionOptions>): void {
this.options = { ...this.options, ...opts };
}
getOptions(): SelectionOptions {
return { ...this.options };
}
getSelectedIds(): Set<string> {
return new Set(this.selectedIds);
}
getSelectedElements(allElements: CADElement[]): CADElement[] {
return allElements.filter(e => this.selectedIds.has(e.id));
}
getHoverId(): string | null {
return this.hoverId;
}
setHover(id: string | null): void {
this.hoverId = id;
this.updateRenderSelection();
}
/**
* Click selection at world coordinates.
* Returns the selected element or null.
*/
clickSelect(worldX: number, worldY: number, allElements: CADElement[]): CADElement | null {
const hit = this.renderEngine.hitTest(worldX, worldY, this.options.tolerance);
if (!hit) {
if (!this.options.additive && !this.options.subtractive) {
this.clearSelection();
}
return null;
}
if (!this.matchesFilter(hit)) {
if (!this.options.additive && !this.options.subtractive) {
this.clearSelection();
}
return null;
}
if (this.options.subtractive) {
this.selectedIds.delete(hit.id);
} else if (this.options.additive) {
this.selectedIds.add(hit.id);
} else {
this.clearSelection();
this.selectedIds.add(hit.id);
}
this.updateRenderSelection();
this.notifyListeners(allElements);
return hit;
}
/**
* Start box selection at world coordinates.
*/
startBoxSelect(worldX: number, worldY: number): void {
this.boxStart = { x: worldX, y: worldY };
this.boxEnd = { x: worldX, y: worldY };
this.updateRenderSelection();
}
/**
* Update box selection end point during drag.
*/
updateBoxSelect(worldX: number, worldY: number, allElements: CADElement[]): void {
if (!this.boxStart) return;
this.boxEnd = { x: worldX, y: worldY };
this.updateRenderSelection();
}
/**
* Finish box selection and select all elements within the box.
* Left-to-right drag = window selection (fully enclosed elements).
* Right-to-left drag = crossing selection (intersecting elements).
*/
finishBoxSelect(allElements: CADElement[]): CADElement[] {
if (!this.boxStart || !this.boxEnd) {
this.boxStart = null;
this.boxEnd = null;
return [];
}
const minX = Math.min(this.boxStart.x, this.boxEnd.x);
const minY = Math.min(this.boxStart.y, this.boxEnd.y);
const maxX = Math.max(this.boxStart.x, this.boxEnd.x);
const maxY = Math.max(this.boxStart.y, this.boxEnd.y);
// Determine window vs crossing: if start X < end X, it's window (left-to-right)
const isWindow = this.boxStart.x <= this.boxEnd.x;
let elements: CADElement[];
if (isWindow) {
// Window selection: only fully enclosed elements
elements = this.renderEngine.getElementsInRect(minX, minY, maxX, maxY);
} else {
// Crossing selection: all intersecting elements
elements = this.renderEngine.getElementsIntersectingRect(minX, minY, maxX, maxY);
}
const filtered = elements.filter(e => this.matchesFilter(e));
if (this.options.subtractive) {
for (const el of filtered) this.selectedIds.delete(el.id);
} else if (this.options.additive) {
for (const el of filtered) this.selectedIds.add(el.id);
} else {
this.clearSelection();
for (const el of filtered) this.selectedIds.add(el.id);
}
this.boxStart = null;
this.boxEnd = null;
this.updateRenderSelection();
this.notifyListeners(allElements);
return filtered;
}
/**
* Quick select elements by property value.
* Supports filtering by type, layerId, color (stroke/fill), or any property.
*/
quickSelect(allElements: CADElement[], criteria: {
type?: string;
layerId?: string;
color?: string;
property?: string;
value?: unknown;
}, additive: boolean = false): CADElement[] {
if (!additive) this.clearSelection();
const matched = allElements.filter(el => {
if (criteria.type && el.type !== criteria.type) return false;
if (criteria.layerId && el.layerId !== criteria.layerId) return false;
if (criteria.color) {
const elColor = el.properties.stroke ?? el.properties.fill;
if (elColor !== criteria.color) return false;
}
if (criteria.property && criteria.value !== undefined) {
if ((el.properties as Record<string, unknown>)[criteria.property] !== criteria.value) return false;
}
return this.matchesFilter(el);
});
for (const el of matched) this.selectedIds.add(el.id);
this.updateRenderSelection();
this.notifyListeners(allElements);
return matched;
}
/**
* Cancel any in-progress box selection.
*/
cancelBoxSelect(): void {
this.boxStart = null;
this.boxEnd = null;
this.updateRenderSelection();
}
/**
* Select all elements matching the current filter.
*/
selectAll(allElements: CADElement[]): void {
const visibleLayerIds = new Set(this.layerManager.getVisibleLayers().map(l => l.id));
this.selectedIds.clear();
for (const el of allElements) {
if (!visibleLayerIds.has(el.layerId)) continue;
if (this.matchesFilter(el)) this.selectedIds.add(el.id);
}
this.updateRenderSelection();
this.notifyListeners(allElements);
}
/**
* Invert current selection.
*/
invertSelection(allElements: CADElement[]): void {
const visibleLayerIds = new Set(this.layerManager.getVisibleLayers().map(l => l.id));
const newSelection = new Set<string>();
for (const el of allElements) {
if (!visibleLayerIds.has(el.layerId)) continue;
if (!this.matchesFilter(el)) continue;
if (!this.selectedIds.has(el.id)) newSelection.add(el.id);
}
this.selectedIds = newSelection;
this.updateRenderSelection();
this.notifyListeners(allElements);
}
/**
* Clear all selection.
*/
clearSelection(): void {
this.selectedIds.clear();
this.updateRenderSelection();
}
/**
* Select specific elements by ID.
*/
selectByIds(ids: string[], additive: boolean = false): void {
if (!additive) this.selectedIds.clear();
for (const id of ids) this.selectedIds.add(id);
this.updateRenderSelection();
}
/**
* Add a listener that gets called when selection changes.
*/
addListener(fn: (selected: CADElement[]) => void): void {
this.listeners.push(fn);
}
removeListener(fn: (selected: CADElement[]) => void): void {
this.listeners = this.listeners.filter(f => f !== fn);
}
private matchesFilter(el: CADElement): boolean {
switch (this.options.filter) {
case 'all': return true;
case 'lines': return el.type === 'line';
case 'circles': return el.type === 'circle' || el.type === 'arc';
case 'rects': return el.type === 'rect';
case 'text': return el.type === 'text';
case 'chairs': return el.type === 'chair';
case 'blocks': return el.type === 'block_instance';
default: return true;
}
}
private updateRenderSelection(): void {
this.renderEngine.setSelection({
selectedIds: this.selectedIds,
hoverId: this.hoverId,
boxStart: this.boxStart,
boxEnd: this.boxEnd,
});
}
private notifyListeners(allElements: CADElement[]): void {
const selected = allElements.filter(e => this.selectedIds.has(e.id));
for (const fn of this.listeners) fn(selected);
}
isBoxSelecting(): boolean {
return this.boxStart !== null;
}
}
+402
View File
@@ -0,0 +1,402 @@
import type { CADElement } from '../types/cad.types';
import type { SnapPoint } from './RenderEngine';
export type SnapMode =
| 'endpoint' | 'midpoint' | 'center' | 'intersection'
| 'nearest' | 'perpendicular' | 'tangent' | 'quadrant'
| 'grid' | 'none';
export interface SnapConfig {
enabled: boolean;
modes: Set<SnapMode>;
tolerance: number; // world units
gridSpacing: number;
polarEnabled: boolean;
polarAngles: number[]; // angles in degrees for polar tracking
polarTolerance: number; // angular tolerance in degrees
}
export interface SnapResult {
point: SnapPoint | null;
preview: SnapPoint[]; // nearby candidates for visual feedback
}
export class SnapEngine {
private config: SnapConfig;
private elements: CADElement[] = [];
constructor(config?: Partial<SnapConfig>) {
this.config = {
enabled: true,
modes: new Set<SnapMode>(['endpoint', 'midpoint', 'center', 'intersection', 'nearest']),
tolerance: 10,
gridSpacing: 20,
polarEnabled: false,
polarAngles: [0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330],
polarTolerance: 5,
...config,
};
}
setElements(elements: CADElement[]): void {
this.elements = elements;
}
setConfig(config: Partial<SnapConfig>): void {
this.config = { ...this.config, ...config };
}
getConfig(): SnapConfig {
return { ...this.config, modes: new Set(this.config.modes) };
}
toggleMode(mode: SnapMode): void {
if (this.config.modes.has(mode)) {
this.config.modes.delete(mode);
} else {
this.config.modes.add(mode);
}
}
/**
* Find the best snap point near the given world coordinates.
* Returns null if no snap point is within tolerance.
* If refPoint is provided and polar tracking is enabled, snaps to polar angles.
*/
snap(worldX: number, worldY: number, refPoint?: { x: number; y: number }): SnapResult {
if (!this.config.enabled || this.config.modes.size === 0) {
return { point: null, preview: [] };
}
// Polar tracking: if we have a reference point, check polar angles first
if (this.config.polarEnabled && refPoint) {
const polarResult = this.polarSnap(worldX, worldY, refPoint);
if (polarResult) {
return { point: polarResult, preview: [polarResult] };
}
}
const candidates: SnapPoint[] = [];
const tol = this.config.tolerance;
// Grid snap (lowest priority)
if (this.config.modes.has('grid')) {
const gs = this.config.gridSpacing;
const gx = Math.round(worldX / gs) * gs;
const gy = Math.round(worldY / gs) * gs;
const dist = Math.sqrt((worldX - gx) ** 2 + (worldY - gy) ** 2);
if (dist < tol) {
candidates.push({ x: gx, y: gy, type: 'grid' as SnapMode as any });
}
}
// Element-based snaps
for (const el of this.elements) {
if (this.config.modes.has('endpoint')) {
this.collectEndpoints(el, worldX, worldY, tol, candidates);
}
if (this.config.modes.has('midpoint')) {
this.collectMidpoints(el, worldX, worldY, tol, candidates);
}
if (this.config.modes.has('center')) {
this.collectCenters(el, worldX, worldY, tol, candidates);
}
if (this.config.modes.has('nearest')) {
this.collectNearest(el, worldX, worldY, tol, candidates);
}
}
// Intersection snap (between pairs)
if (this.config.modes.has('intersection')) {
this.collectIntersections(worldX, worldY, tol, candidates);
}
if (candidates.length === 0) {
return { point: null, preview: [] };
}
// Sort by distance, pick closest
candidates.sort((a, b) => {
const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2;
const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2;
return da - db;
});
// Priority: endpoint > intersection > center > midpoint > nearest > grid
const priority: Record<string, number> = {
endpoint: 0, intersection: 1, center: 2, midpoint: 3, nearest: 4, grid: 5,
};
// Find best within tolerance — prefer higher priority if distances are close
const best = candidates[0];
const closeOnes = candidates.filter(c => {
const d = Math.sqrt((c.x - worldX) ** 2 + (c.y - worldY) ** 2);
return d < tol * 1.5;
});
closeOnes.sort((a, b) => {
const pa = priority[a.type] ?? 99;
const pb = priority[b.type] ?? 99;
if (pa !== pb) return pa - pb;
const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2;
const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2;
return da - db;
});
return {
point: closeOnes[0] || best,
preview: candidates.slice(0, 10),
};
}
private collectEndpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const p = el.properties;
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'endpoint' });
};
switch (el.type) {
case 'line':
check(p.x1 ?? el.x, p.y1 ?? el.y);
check(p.x2 ?? el.x + el.width, p.y2 ?? el.y + el.height);
break;
case 'polyline':
case 'polygon': {
const pts = p.points || [];
for (const pt of pts) check(pt.x, pt.y);
break;
}
case 'rect':
check(el.x - el.width / 2, el.y - el.height / 2);
check(el.x + el.width / 2, el.y - el.height / 2);
check(el.x - el.width / 2, el.y + el.height / 2);
check(el.x + el.width / 2, el.y + el.height / 2);
break;
case 'arc': {
const r = p.radius || el.width / 2;
const sa = (p.startAngle || 0) * Math.PI / 180;
const ea = (p.endAngle || 360) * Math.PI / 180;
check(el.x + r * Math.cos(sa), el.y + r * Math.sin(sa));
check(el.x + r * Math.cos(ea), el.y + r * Math.sin(ea));
break;
}
}
}
private collectMidpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const p = el.properties;
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'midpoint' });
};
switch (el.type) {
case 'line': {
const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height;
check((x1 + x2) / 2, (y1 + y2) / 2);
break;
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
for (let i = 0; i < pts.length - 1; i++) {
check((pts[i].x + pts[i + 1].x) / 2, (pts[i].y + pts[i + 1].y) / 2);
}
if (el.type === 'polygon' && pts.length > 2) {
check((pts[pts.length - 1].x + pts[0].x) / 2, (pts[pts.length - 1].y + pts[0].y) / 2);
}
break;
}
case 'rect':
check(el.x, el.y - el.height / 2);
check(el.x, el.y + el.height / 2);
check(el.x - el.width / 2, el.y);
check(el.x + el.width / 2, el.y);
break;
}
}
private collectCenters(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'center' });
};
switch (el.type) {
case 'circle':
case 'arc':
check(el.x, el.y);
break;
case 'rect':
check(el.x, el.y);
break;
}
}
private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const p = el.properties;
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'nearest' });
};
switch (el.type) {
case 'line': {
const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height;
const np = this.nearestOnSegment(wx, wy, x1, y1, x2, y2);
check(np.x, np.y);
break;
}
case 'circle': {
const r = p.radius || el.width / 2;
const d = Math.sqrt((wx - el.x) ** 2 + (wy - el.y) ** 2);
if (d > 0) {
check(el.x + r * (wx - el.x) / d, el.y + r * (wy - el.y) / d);
}
break;
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
for (let i = 0; i < pts.length - 1; i++) {
const np = this.nearestOnSegment(wx, wy, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
check(np.x, np.y);
}
if (el.type === 'polygon' && pts.length > 2) {
const np = this.nearestOnSegment(wx, wy, pts[pts.length - 1].x, pts[pts.length - 1].y, pts[0].x, pts[0].y);
check(np.x, np.y);
}
break;
}
}
}
private collectIntersections(wx: number, wy: number, tol: number, out: SnapPoint[]): void {
// Check pairs of elements near the cursor
const nearby = this.elements.filter(el => {
const halfW = el.width / 2 + tol;
const halfH = el.height / 2 + tol;
return Math.abs(wx - el.x) < halfW && Math.abs(wy - el.y) < halfH;
});
for (let i = 0; i < nearby.length; i++) {
for (let j = i + 1; j < nearby.length; j++) {
const pts = this.findIntersection(nearby[i], nearby[j]);
for (const pt of pts) {
const d = Math.sqrt((wx - pt.x) ** 2 + (wy - pt.y) ** 2);
if (d < tol) out.push({ x: pt.x, y: pt.y, type: 'intersection' });
}
}
}
}
private findIntersection(a: CADElement, b: CADElement): Array<{ x: number; y: number }> {
// Get line segments from both elements
const segsA = this.getElementSegments(a);
const segsB = this.getElementSegments(b);
const results: Array<{ x: number; y: number }> = [];
for (const sa of segsA) {
for (const sb of segsB) {
const pt = this.segmentIntersection(sa.x1, sa.y1, sa.x2, sa.y2, sb.x1, sb.y1, sb.x2, sb.y2);
if (pt) results.push(pt);
}
}
return results;
}
private getElementSegments(el: CADElement): Array<{ x1: number; y1: number; x2: number; y2: number }> {
const p = el.properties;
switch (el.type) {
case 'line':
return [{
x1: p.x1 ?? el.x, y1: p.y1 ?? el.y,
x2: p.x2 ?? el.x + el.width, y2: p.y2 ?? el.y + el.height,
}];
case 'rect': {
const hw = el.width / 2, hh = el.height / 2;
return [
{ x1: el.x - hw, y1: el.y - hh, x2: el.x + hw, y2: el.y - hh },
{ x1: el.x + hw, y1: el.y - hh, x2: el.x + hw, y2: el.y + hh },
{ x1: el.x + hw, y1: el.y + hh, x2: el.x - hw, y2: el.y + hh },
{ x1: el.x - hw, y1: el.y + hh, x2: el.x - hw, y2: el.y - hh },
];
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
for (let i = 0; i < pts.length - 1; i++) {
segs.push({ x1: pts[i].x, y1: pts[i].y, x2: pts[i + 1].x, y2: pts[i + 1].y });
}
if (el.type === 'polygon' && pts.length > 2) {
segs.push({ x1: pts[pts.length - 1].x, y1: pts[pts.length - 1].y, x2: pts[0].x, y2: pts[0].y });
}
return segs;
}
default:
return [];
}
}
private segmentIntersection(
x1: number, y1: number, x2: number, y2: number,
x3: number, y3: number, x4: number, y4: number,
): { x: number; y: number } | null {
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if (Math.abs(denom) < 1e-10) return null;
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
return { x: x1 + t * (x2 - x1), y: y1 + t * (y2 - y1) };
}
return null;
}
private nearestOnSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): { x: number; y: number } {
const dx = x2 - x1;
const dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return { x: x1, y: y1 };
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return { x: x1 + t * dx, y: y1 + t * dy };
}
/**
* Polar tracking: snap cursor to the nearest polar angle from a reference point.
* Returns a SnapPoint if the cursor is close to a polar angle, or null.
*/
private polarSnap(worldX: number, worldY: number, refPoint: { x: number; y: number }): SnapPoint | null {
const dx = worldX - refPoint.x;
const dy = worldY - refPoint.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) return null; // too close to reference point
const cursorAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
const normalizedCursor = ((cursorAngle % 360) + 360) % 360;
// Find closest polar angle
let bestAngle: number | null = null;
let bestDiff = Infinity;
for (const angle of this.config.polarAngles) {
let diff = Math.abs(normalizedCursor - angle);
if (diff > 180) diff = 360 - diff;
if (diff < bestDiff) {
bestDiff = diff;
bestAngle = angle;
}
}
if (bestAngle === null || bestDiff > this.config.polarTolerance) return null;
// Project cursor position onto the polar angle line at the same distance
const rad = (bestAngle * Math.PI) / 180;
const snapX = refPoint.x + dist * Math.cos(rad);
const snapY = refPoint.y + dist * Math.sin(rad);
return { x: snapX, y: snapY, type: 'nearest' };
}
}
+48
View File
@@ -0,0 +1,48 @@
import RBush from 'rbush';
import type { BoundingBox, CADElement } from '../types/cad.types';
interface IndexedItem extends BoundingBox {
element: CADElement;
}
export class SpatialIndex {
private tree: RBush<IndexedItem>;
constructor() {
this.tree = new RBush<IndexedItem>();
}
insert(element: CADElement): void {
const bbox = this.elementBBox(element);
this.tree.insert({ ...bbox, element });
}
bulkInsert(elements: CADElement[]): void {
const items = elements.map(el => ({ ...this.elementBBox(el), element: el }));
this.tree.load(items);
}
search(viewport: BoundingBox): CADElement[] {
return this.tree.search(viewport).map(item => item.element);
}
remove(element: CADElement): void {
const bbox = this.elementBBox(element);
this.tree.remove({ ...bbox, element }, (a, b) => a.element.id === b.element.id);
}
clear(): void {
this.tree.clear();
}
private elementBBox(el: CADElement): BoundingBox {
const halfW = el.width / 2;
const halfH = el.height / 2;
return {
minX: el.x - halfW,
minY: el.y - halfH,
maxX: el.x + halfW,
maxY: el.y + halfH,
};
}
}
+97
View File
@@ -0,0 +1,97 @@
import type { Transform, Viewport } from '../types/cad.types';
export class ZoomPanController {
private scale = 1;
private offsetX = 0;
private offsetY = 0;
private canvas: HTMLCanvasElement;
private isPanning = false;
private lastX = 0;
private lastY = 0;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
}
getTransform(): Transform {
return {
a: this.scale, b: 0, c: 0,
d: this.scale, e: this.offsetX, f: this.offsetY,
};
}
getViewport(): Viewport {
const w = this.canvas.width / this.scale;
const h = this.canvas.height / this.scale;
return {
minX: -this.offsetX / this.scale,
minY: -this.offsetY / this.scale,
maxX: (-this.offsetX / this.scale) + w,
maxY: (-this.offsetY / this.scale) + h,
};
}
getScale(): number { return this.scale; }
zoomAt(cx: number, cy: number, factor: number): void {
const newScale = Math.max(0.01, Math.min(100, this.scale * factor));
const ratio = newScale / this.scale;
this.offsetX = cx - (cx - this.offsetX) * ratio;
this.offsetY = cy - (cy - this.offsetY) * ratio;
this.scale = newScale;
}
zoomFit(elements: Array<{x:number;y:number;width:number;height:number}>): void {
if (elements.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of elements) {
minX = Math.min(minX, el.x - el.width/2);
minY = Math.min(minY, el.y - el.height/2);
maxX = Math.max(maxX, el.x + el.width/2);
maxY = Math.max(maxY, el.y + el.height/2);
}
const padding = 40;
const scaleX = (this.canvas.width - padding*2) / (maxX - minX);
const scaleY = (this.canvas.height - padding*2) / (maxY - minY);
this.scale = Math.min(scaleX, scaleY);
this.offsetX = -minX * this.scale + padding;
this.offsetY = -minY * this.scale + padding;
}
zoomToRect(rect: { minX: number; minY: number; maxX: number; maxY: number }): void {
const padding = 20;
const w = rect.maxX - rect.minX;
const h = rect.maxY - rect.minY;
if (w <= 0 || h <= 0) return;
const scaleX = (this.canvas.width - padding * 2) / w;
const scaleY = (this.canvas.height - padding * 2) / h;
this.scale = Math.max(0.01, Math.min(100, Math.min(scaleX, scaleY)));
this.offsetX = -rect.minX * this.scale + padding;
this.offsetY = -rect.minY * this.scale + padding;
}
pan(dx: number, dy: number): void {
this.offsetX += dx;
this.offsetY += dy;
}
screenToWorld(sx: number, sy: number): { x: number; y: number } {
const rect = this.canvas.getBoundingClientRect();
const x = (sx - rect.left - this.offsetX) / this.scale;
const y = (sy - rect.top - this.offsetY) / this.scale;
return { x, y };
}
worldToScreen(wx: number, wy: number): { x: number; y: number } {
return {
x: wx * this.scale + this.offsetX,
y: wy * this.scale + this.offsetY,
};
}
reset(): void {
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
}
}