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; 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; 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, canvasBgColor: '#1e1e2e', gridColor: '#3a3a4a', rulerEnabled: true, }; this.selection = { selectedIds: new Set(), hoverId: null, boxStart: null, boxEnd: null, }; } setOptions(opts: Partial): void { this.options = { ...this.options, ...opts }; } getOptions(): RenderOptions { return { ...this.options }; } setSelection(sel: Partial): 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 = 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); 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 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 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; }); } }