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