feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user