Files
web-cad/frontend/src/canvas/RenderEngine.ts
T

1229 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
CADElement, 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;
unit?: 'mm' | 'cm' | 'm';
scaleFactor?: number;
showGridLabels?: boolean;
canvasBgColor?: string;
gridColor?: string;
rulerEnabled?: boolean;
}
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' | 'grid' | 'perpendicular' | 'tangent' | 'quadrant' | 'none';
}
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 previewElement: CADElement | null = null;
/** Gecachtes Hintergrundbild (BUG-4-Fix): nur 1× pro backgroundSrc laden. */
private bgImage: HTMLImageElement | null = null;
/** src des zuletzt erfolgreich geladenen Hintergrundbilds. */
private bgSrcLoaded = '';
private dpr = 1;
/** CAD-13-fix: true = Legacy-Canvas zeichnet KEINE Elemente (Pixi-Overlay zeichnet sie). Grid/Hintergrund bleiben. */
skipElementRendering = false;
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,
canvasBgColor: '#1e1e2e',
gridColor: '#3a3a4a',
rulerEnabled: true,
};
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;
}
setPreviewElement(el: CADElement | null): void {
this.previewElement = el;
}
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);
}
}
private groups: Array<{ id: string; name: string; elementIds: string[]; parentGroupId: string | null }> = [];
setGroups(groups: Array<{ id: string; name: string; elementIds: string[]; parentGroupId: string | null }>): void {
this.groups = groups;
}
render(): void {
const w = this.canvas.width / this.dpr;
const h = this.canvas.height / this.dpr;
this.ctx.save();
this.ctx.fillStyle = this.options.canvasBgColor || '#1e1e2e';
this.ctx.fillRect(0, 0, w, h);
if (this.options.showGrid) this.drawGrid(w, h);
if (this.options.backgroundSrc) this.drawBackground();
this.drawRuler(w, h);
// CAD-13-fix: Wenn Pixi-Renderer aktiv ist (skipElementRendering=true),
// zeichnet das Legacy-Canvas KEINE Elemente mehr (sonst doppelt) —
// nur Grid/Hintergrund/Lineal bleiben hier.
if (this.skipElementRendering) {
this.ctx.restore();
return;
}
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 && e.properties?.visible !== false);
for (const el of els) {
this.drawElement(el, layer);
}
}
// Draw preview element with dashed style
if (this.previewElement) {
this.ctx.save();
this.ctx.setLineDash([6, 4]);
const previewEl: CADElement = { ...this.previewElement, properties: { ...this.previewElement.properties, stroke: '#00aaff', strokeWidth: 2 } };
const previewLayer: CADLayer = { id: '__preview__', name: 'Preview', visible: true, locked: false, color: '#00aaff', lineType: 'solid', transparency: 0, sortOrder: 999, parentId: null };
this.drawElement(previewEl, previewLayer);
this.ctx.restore();
}
this.drawSelectionBox();
this.drawGroupBoxes();
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 = this.options.gridColor || '#3a3a4a';
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;
// Major lines use a brighter variant of gridColor
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 15);
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();
// Draw axis labels with unit at each major grid line
if (this.options.showGridLabels !== false) {
const unit = this.options.unit || 'mm';
const scaleFactor = this.options.scaleFactor || 1;
const transform = this.zoomPan.getTransform();
const scale = this.zoomPan.getScale();
const worldGridSize = this.options.gridSize * 5; // world units per major line
this.ctx.fillStyle = '#666680';
this.ctx.font = '10px sans-serif';
this.ctx.textBaseline = 'top';
// X-axis labels (top edge)
const xStartWorld = Math.floor((-transform.e / scale) / worldGridSize) * worldGridSize;
for (let i = 0; i * majorSize + majOffX < w; i++) {
const screenX = majOffX + i * majorSize;
const worldX = xStartWorld + i * worldGridSize;
if (screenX < 30) continue; // skip too-close-to-edge labels
const label = this.formatGridLabel(worldX, unit, scaleFactor);
this.ctx.fillText(label, screenX + 2, 2);
}
// Y-axis labels (left edge)
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'middle';
const yStartWorld = Math.floor((-transform.f / scale) / worldGridSize) * worldGridSize;
for (let i = 0; i * majorSize + majOffY < h; i++) {
const screenY = majOffY + i * majorSize;
const worldY = yStartWorld + i * worldGridSize;
if (screenY < 15) continue;
const label = this.formatGridLabel(worldY, unit, scaleFactor);
this.ctx.fillText(label, 2, screenY - 6);
}
this.ctx.textAlign = 'start';
this.ctx.textBaseline = 'alphabetic';
}
}
}
private formatGridLabel(worldVal: number, unit: 'mm' | 'cm' | 'm', scaleFactor: number): string {
const mm = worldVal * scaleFactor;
switch (unit) {
case 'mm':
return `${mm.toFixed(0)}mm`;
case 'cm':
return `${(mm / 10).toFixed(0)}cm`;
case 'm':
return `${(mm / 1000).toFixed(2)}m`;
}
}
private lightenColor(hex: string, percent: number): string {
const num = parseInt(hex.replace('#', ''), 16);
if (isNaN(num)) return hex;
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
const g = Math.min(255, ((num >> 8) & 0x00FF) + Math.round(255 * percent / 100));
const b = Math.min(255, (num & 0x0000FF) + Math.round(255 * percent / 100));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
private drawRuler(w: number, h: number): void {
if (!this.options.rulerEnabled) return;
const scale = this.zoomPan.getScale();
const transform = this.zoomPan.getTransform();
const unit = this.options.unit || 'mm';
const scaleFactor = this.options.scaleFactor || 1;
const rulerSize = 20;
this.ctx.save();
this.ctx.fillStyle = this.options.canvasBgColor || '#1e1e2e';
this.ctx.fillRect(0, 0, w, rulerSize);
this.ctx.fillRect(0, 0, rulerSize, h);
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 30);
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.ctx.moveTo(0, rulerSize);
this.ctx.lineTo(w, rulerSize);
this.ctx.moveTo(rulerSize, 0);
this.ctx.lineTo(rulerSize, h);
this.ctx.stroke();
// Determine tick spacing in world units (aim for ~50px between major ticks)
const targetPx = 50;
const worldPerPx = 1 / scale;
const rawTickSpacing = targetPx * worldPerPx;
const niceSpacings = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000];
let tickSpacing = niceSpacings[0];
for (const ns of niceSpacings) {
if (ns >= rawTickSpacing) { tickSpacing = ns; break; }
tickSpacing = ns;
}
const tickPx = tickSpacing * scale;
this.ctx.fillStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 50);
this.ctx.font = '9px sans-serif';
this.ctx.textBaseline = 'middle';
// X-axis ruler (top)
this.ctx.textAlign = 'center';
const xStartWorld = Math.floor((-transform.e / scale) / tickSpacing) * tickSpacing;
for (let i = 0; ; i++) {
const screenX = transform.e + (xStartWorld + i * tickSpacing) * scale;
if (screenX < rulerSize) continue;
if (screenX > w) break;
// Tick mark
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 30);
this.ctx.beginPath();
this.ctx.moveTo(screenX, rulerSize - 6);
this.ctx.lineTo(screenX, rulerSize);
this.ctx.stroke();
// Label
const worldVal = (xStartWorld + i * tickSpacing) * scaleFactor;
let label: string;
switch (unit) {
case 'mm': label = `${worldVal.toFixed(0)}`; break;
case 'cm': label = `${(worldVal / 10).toFixed(0)}`; break;
case 'm': label = `${(worldVal / 1000).toFixed(2)}`; break;
}
this.ctx.fillText(label, screenX, rulerSize / 2);
}
// Y-axis ruler (left)
this.ctx.textAlign = 'right';
this.ctx.textBaseline = 'middle';
const yStartWorld = Math.floor((-transform.f / scale) / tickSpacing) * tickSpacing;
for (let i = 0; ; i++) {
const screenY = transform.f + (yStartWorld + i * tickSpacing) * scale;
if (screenY < rulerSize) continue;
if (screenY > h) break;
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 30);
this.ctx.beginPath();
this.ctx.moveTo(rulerSize - 6, screenY);
this.ctx.lineTo(rulerSize, screenY);
this.ctx.stroke();
const worldVal = (yStartWorld + i * tickSpacing) * scaleFactor;
let label: string;
switch (unit) {
case 'mm': label = `${worldVal.toFixed(0)}`; break;
case 'cm': label = `${(worldVal / 10).toFixed(0)}`; break;
case 'm': label = `${(worldVal / 1000).toFixed(2)}`; break;
}
this.ctx.fillText(label, rulerSize - 2, screenY);
}
this.ctx.restore();
this.ctx.textAlign = 'start';
this.ctx.textBaseline = 'alphabetic';
}
private drawBackground(): void {
// Background image rendering with transform.
// BUG-4-Fix: Das Bild wird nur EINMAL pro backgroundSrc geladen und gecacht;
// vorher wurde pro Frame ein neues Image erzeugt (Flackern/Rerender-Schleife).
const src = this.options.backgroundSrc!;
// Quellwechsel: Cache invalidieren und neu laden
if (!this.bgImage || this.bgSrcLoaded !== src) {
const img = new Image();
img.onload = () => {
this.bgImage = img;
this.bgSrcLoaded = src;
this.render(); // einmalig nach erfolgreichem Laden neu zeichnen
};
img.src = src;
if (img.complete && img.naturalWidth > 0) {
// Bereits aus Browser-Cache verfügbar (z.B. dataURL)
this.bgImage = img;
this.bgSrcLoaded = src;
}
return;
}
// Gecachtes Bild zeichnen
const img = this.bgImage;
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 drawGroupBoxes(): void {
if (this.groups.length === 0) return;
const s = this.zoomPan.getScale();
const ox = this.zoomPan.getTransform().e;
const oy = this.zoomPan.getTransform().f;
const visibleElements = this.spatialIndex.search(this.zoomPan.getViewport());
const elementMap = new Map(visibleElements.map(e => [e.id, e]));
for (const group of this.groups) {
const els = group.elementIds.map(id => elementMap.get(id)).filter((e): e is CADElement => e !== undefined);
if (els.length === 0) continue;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of els) {
const bb = this.getElementBBox(el);
minX = Math.min(minX, bb.minX);
minY = Math.min(minY, bb.minY);
maxX = Math.max(maxX, bb.maxX);
maxY = Math.max(maxY, bb.maxY);
}
if (minX === Infinity) continue;
const sx1 = minX * s + ox;
const sy1 = minY * s + oy;
const sx2 = maxX * s + ox;
const sy2 = maxY * s + oy;
const pad = 4;
this.ctx.save();
this.ctx.strokeStyle = '#ff9800';
this.ctx.lineWidth = 1.5;
this.ctx.setLineDash([6, 4]);
this.ctx.strokeRect(sx1 - pad, sy1 - pad, (sx2 - sx1) + pad * 2, (sy2 - sy1) + pad * 2);
this.ctx.setLineDash([]);
// Group name label
this.ctx.font = '11px sans-serif';
this.ctx.fillStyle = '#ff9800';
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
this.ctx.fillText(group.name, sx1 - pad + 2, sy1 - pad - 14);
this.ctx.restore();
}
}
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;
});
}
}