import { layout } from '../stores/layout.svelte.js'; import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, getCurveGeometry, SPACING_EXEMPT, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG } from '../symbols.js'; import { checkSpacingViolation } from './collision.js'; import { THEME } from './render-theme.js'; import type { PlacedSymbol } from '../types.js'; let ctx: CanvasRenderingContext2D | null = null; let canvas: HTMLCanvasElement | null = null; let animFrameId: number | null = null; let lastDirty = -1; const MAX_RENDER_SCALE = THEME.canvas.maxRenderScale; let currentRenderScale = 0; let lastCanvasW = 0; let lastCanvasH = 0; export function setCanvas(c: HTMLCanvasElement) { canvas = c; ctx = c.getContext('2d')!; currentRenderScale = 0; // force resolution update on first render } function updateCanvasResolution() { if (!canvas) return; const dpr = window.devicePixelRatio || 1; const targetScale = Math.min(Math.ceil(dpr * layout.zoomLevel), MAX_RENDER_SCALE); const sizeChanged = layout.canvasW !== lastCanvasW || layout.canvasH !== lastCanvasH; if (targetScale === currentRenderScale && !sizeChanged) return; currentRenderScale = targetScale; lastCanvasW = layout.canvasW; lastCanvasH = layout.canvasH; canvas.width = layout.canvasW * currentRenderScale; canvas.height = layout.canvasH * currentRenderScale; canvas.style.width = layout.canvasW + 'px'; canvas.style.height = layout.canvasH + 'px'; } export function startRenderLoop() { function loop() { animFrameId = requestAnimationFrame(loop); const dpr = window.devicePixelRatio || 1; const targetScale = Math.min(Math.ceil(dpr * layout.zoomLevel), MAX_RENDER_SCALE); const sizeChanged = layout.canvasW !== lastCanvasW || layout.canvasH !== lastCanvasH; if (layout.dirty !== lastDirty || targetScale !== currentRenderScale || sizeChanged) { lastDirty = layout.dirty; render(); } } loop(); } export function stopRenderLoop() { if (animFrameId !== null) { cancelAnimationFrame(animFrameId); animFrameId = null; } } export function render() { if (!ctx || !canvas) return; updateCanvasResolution(); ctx.save(); ctx.setTransform(currentRenderScale, 0, 0, currentRenderScale, 0, 0); ctx.clearRect(0, 0, layout.canvasW, layout.canvasH); if (layout.showGrid) { drawGrid(ctx); } // Draw non-overlay symbols first, then overlay symbols (photoeyes) on top for (const sym of layout.symbols) { if (!SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol); } for (const sym of layout.symbols) { if (SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol); } ctx.restore(); } function drawGrid(ctx: CanvasRenderingContext2D) { const size = layout.gridSize; ctx.strokeStyle = THEME.grid.color; ctx.lineWidth = THEME.grid.lineWidth; ctx.beginPath(); for (let x = 0; x <= layout.canvasW; x += size) { ctx.moveTo(x, 0); ctx.lineTo(x, layout.canvasH); } for (let y = 0; y <= layout.canvasH; y += size) { ctx.moveTo(0, y); ctx.lineTo(layout.canvasW, y); } ctx.stroke(); } /** Trace the arc band outline path (for selection/collision/hover strokes on curved types) */ function traceArcBandPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) { const angle = sym.curveAngle || 90; const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h); const sweepRad = (angle * Math.PI) / 180; ctx.beginPath(); ctx.arc(arcCx, arcCy, outerR + pad, 0, -sweepRad, true); ctx.arc(arcCx, arcCy, Math.max(0, innerR - pad), -sweepRad, 0, false); ctx.closePath(); } /** Trace the spur trapezoid path (for selection/collision strokes) */ function traceSpurPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) { const w2 = sym.w2 ?? sym.w; // Expand vertices outward by pad ctx.beginPath(); ctx.moveTo(sym.x - pad, sym.y - pad); ctx.lineTo(sym.x + w2 + pad, sym.y - pad); ctx.lineTo(sym.x + sym.w + pad, sym.y + sym.h + pad); ctx.lineTo(sym.x - pad, sym.y + sym.h + pad); ctx.closePath(); } /** Draw the EPC symbol: SVG image for left icon, programmatic polyline + right box. * Both boxes auto-orient along the line direction at their respective ends. */ function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; const ox = sym.x; // origin x const oy = sym.y; // origin y // Draw polyline connecting waypoints if (waypoints.length >= 2) { ctx.beginPath(); ctx.moveTo(ox + waypoints[0].x, oy + waypoints[0].y); for (let i = 1; i < waypoints.length; i++) { ctx.lineTo(ox + waypoints[i].x, oy + waypoints[i].y); } ctx.strokeStyle = THEME.epcBody.lineColor; ctx.lineWidth = EPC_CONFIG.lineWidth; ctx.stroke(); } // --- Left icon: use actual SVG image, oriented along first segment --- if (waypoints.length >= 2) { const lb = EPC_CONFIG.leftBox; const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y; const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y; const angle = Math.atan2(p1y - p0y, p1x - p0x); const iconImg = getSymbolImage(EPC_CONFIG.iconFile); if (iconImg) { ctx.save(); ctx.translate(p0x, p0y); ctx.rotate(angle); ctx.drawImage(iconImg, -lb.w, -lb.h / 2, lb.w, lb.h); ctx.restore(); } } // --- Right box: oriented along direction from wp[n-2] to wp[n-1] --- if (waypoints.length >= 2) { const last = waypoints[waypoints.length - 1]; const prev = waypoints[waypoints.length - 2]; const plx = ox + last.x, ply = oy + last.y; const ppx = ox + prev.x, ppy = oy + prev.y; const angle = Math.atan2(ply - ppy, plx - ppx); ctx.save(); ctx.translate(plx, ply); ctx.rotate(angle); const rb = EPC_CONFIG.rightBox; ctx.fillStyle = THEME.epcBody.rightBoxFill; ctx.strokeStyle = THEME.epcBody.rightBoxStroke; ctx.lineWidth = THEME.epcBody.rightBoxStrokeWidth; ctx.fillRect(0, -rb.h / 2, rb.w, rb.h); ctx.strokeRect(0, -rb.h / 2, rb.w, rb.h); ctx.restore(); } } /** Draw EPC waypoint handles when selected */ function drawEpcWaypointHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; const hs = THEME.epcWaypoint.size; ctx.fillStyle = THEME.epcWaypoint.fillColor; ctx.strokeStyle = THEME.epcWaypoint.strokeColor; ctx.lineWidth = THEME.epcWaypoint.lineWidth; for (const wp of waypoints) { const hx = sym.x + wp.x; const hy = sym.y + wp.y; ctx.beginPath(); ctx.arc(hx, hy, hs / 2, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } // Draw "+" at midpoints of segments to hint add-waypoint if (waypoints.length >= 2) { ctx.fillStyle = THEME.epcWaypoint.fillColor; ctx.font = THEME.epcWaypoint.hintFont; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (let i = 0; i < waypoints.length - 1; i++) { const mx = sym.x + (waypoints[i].x + waypoints[i + 1].x) / 2; const my = sym.y + (waypoints[i].y + waypoints[i + 1].y) / 2; ctx.fillText('+', mx, my + THEME.epcWaypoint.hintOffsetY); } } } /** Trace the EPC outline path: left box + segments + right box */ function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; if (waypoints.length < 2) return; const ox = sym.x, oy = sym.y; // Draw left box outline const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y; const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y; const lAngle = Math.atan2(p1y - p0y, p1x - p0x); const lb = EPC_CONFIG.leftBox; ctx.save(); ctx.translate(p0x, p0y); ctx.rotate(lAngle); ctx.beginPath(); ctx.rect(-lb.w - pad, -lb.h / 2 - pad, lb.w + pad * 2, lb.h + pad * 2); ctx.stroke(); ctx.restore(); // Draw line segments outline (thickened) for (let i = 0; i < waypoints.length - 1; i++) { const ax = ox + waypoints[i].x, ay = oy + waypoints[i].y; const bx = ox + waypoints[i + 1].x, by = oy + waypoints[i + 1].y; const segAngle = Math.atan2(by - ay, bx - ax); const segLen = Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2); ctx.save(); ctx.translate(ax, ay); ctx.rotate(segAngle); ctx.beginPath(); ctx.rect(-pad, -pad - EPC_CONFIG.lineWidth / 2, segLen + pad * 2, EPC_CONFIG.lineWidth + pad * 2); ctx.stroke(); ctx.restore(); } // Draw right box outline const last = waypoints[waypoints.length - 1]; const prev = waypoints[waypoints.length - 2]; const plx = ox + last.x, ply = oy + last.y; const ppx = ox + prev.x, ppy = oy + prev.y; const rAngle = Math.atan2(ply - ppy, plx - ppx); const rb = EPC_CONFIG.rightBox; ctx.save(); ctx.translate(plx, ply); ctx.rotate(rAngle); ctx.beginPath(); ctx.rect(-pad, -rb.h / 2 - pad, rb.w + pad * 2, rb.h + pad * 2); ctx.stroke(); ctx.restore(); } /** Trace the induction outline path (arrow head + strip) */ function traceInductionPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { const hw = INDUCTION_CONFIG.headWidth; const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac; const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const); ctx.beginPath(); ctx.moveTo(sym.x + sym.w + pad, stripTopY - pad); ctx.lineTo(pts[0][0], stripTopY - pad); // Arrow outline with padding for (let i = 0; i < pts.length; i++) { const [px, py] = pts[i]; // Simple approach: offset each point outward by pad ctx.lineTo(px + (i <= 2 ? -pad : pad), py + (i <= 1 ? -pad : pad)); } ctx.lineTo(pts[5][0], stripBottomY + pad); ctx.lineTo(sym.x + sym.w + pad, stripBottomY + pad); ctx.closePath(); } /** Stroke an outline around a symbol — uses arc path for curved, trapezoid for spur, EPC shape for EPC, induction shape for induction, rect for straight */ function strokeOutline(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { if (isCurvedType(sym.symbolId)) { traceArcBandPath(ctx, sym, pad); ctx.stroke(); } else if (isSpurType(sym.symbolId)) { traceSpurPath(ctx, sym, pad); ctx.stroke(); } else if (isEpcType(sym.symbolId)) { traceEpcOutlinePath(ctx, sym, pad); } else if (isInductionType(sym.symbolId)) { traceInductionPath(ctx, sym, pad); ctx.stroke(); } else { ctx.strokeRect(sym.x - pad, sym.y - pad, sym.w + pad * 2, sym.h + pad * 2); } } /** Draw a filled+stroked resize handle at (x, y) */ function drawHandle(ctx: CanvasRenderingContext2D, x: number, y: number, size: number) { const half = size / 2; ctx.fillRect(x - half, y - half, size, size); ctx.strokeRect(x - half, y - half, size, size); } function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { if (!isResizable(sym.symbolId)) return; const hs = THEME.resizeHandle.size; ctx.fillStyle = THEME.resizeHandle.fillColor; ctx.strokeStyle = THEME.resizeHandle.strokeColor; ctx.lineWidth = THEME.resizeHandle.lineWidth; if (isCurvedType(sym.symbolId)) { const { arcCx, arcCy, outerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h); drawHandle(ctx, arcCx + outerR, arcCy, hs); } else if (isSpurType(sym.symbolId)) { const w2 = sym.w2 ?? sym.w; // Right handle on top base (controls w2) drawHandle(ctx, sym.x + w2, sym.y, hs); // Right handle on bottom base (controls w) drawHandle(ctx, sym.x + sym.w, sym.y + sym.h, hs); } else if (isInductionType(sym.symbolId)) { // Only right handle — arrow head is fixed width const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac; const stripMidY = (stripTopY + stripBottomY) / 2; drawHandle(ctx, sym.x + sym.w, stripMidY, hs); } else { const midY = sym.y + sym.h / 2; drawHandle(ctx, sym.x, midY, hs); drawHandle(ctx, sym.x + sym.w, midY, hs); } } /** Draw induction programmatically: fixed arrow head + variable strip, as one path */ function drawInductionSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const hw = INDUCTION_CONFIG.headWidth; const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac; // Arrow points in display coords const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const); ctx.beginPath(); // Top-right of strip ctx.moveTo(sym.x + sym.w, stripTopY); // Top-left junction (arrow meets strip) ctx.lineTo(pts[0][0], stripTopY); // Arrow outline for (const [px, py] of pts) { ctx.lineTo(px, py); } // Bottom-left junction to strip bottom ctx.lineTo(pts[5][0], stripBottomY); // Bottom-right of strip ctx.lineTo(sym.x + sym.w, stripBottomY); ctx.closePath(); ctx.fillStyle = THEME.induction.fillColor; ctx.fill(); } /** Draw photoeye with 3-slice: fixed left cap, stretched middle beam, fixed right cap */ function drawPhotoeye3Slice(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, img: HTMLImageElement) { const { leftCap, rightCap, defaultWidth } = PHOTOEYE_CONFIG; const srcW = img.naturalWidth; const srcH = img.naturalHeight; const scale = srcW / defaultWidth; const srcLeftW = leftCap * scale; const srcRightW = rightCap * scale; const srcMiddleW = srcW - srcLeftW - srcRightW; const dstMiddleW = sym.w - leftCap - rightCap; // Left cap (fixed) ctx.drawImage(img, 0, 0, srcLeftW, srcH, sym.x, sym.y, leftCap, sym.h); // Middle beam (stretched) ctx.drawImage(img, srcLeftW, 0, srcMiddleW, srcH, sym.x + leftCap, sym.y, dstMiddleW, sym.h); // Right cap (fixed) ctx.drawImage(img, srcW - srcRightW, 0, srcRightW, srcH, sym.x + sym.w - rightCap, sym.y, rightCap, sym.h); } /** Draw curved conveyor/chute programmatically with fixed band width */ function drawCurvedSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const angle = sym.curveAngle || 90; const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h); const sweepRad = (angle * Math.PI) / 180; ctx.beginPath(); ctx.arc(arcCx, arcCy, outerR, 0, -sweepRad, true); ctx.arc(arcCx, arcCy, innerR, -sweepRad, 0, false); ctx.closePath(); ctx.fillStyle = '#000000'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 0.5; ctx.fill(); ctx.stroke(); } function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boolean { if (isEpcType(sym.symbolId)) { drawEpcSymbol(ctx, sym); } else if (isInductionType(sym.symbolId)) { drawInductionSymbol(ctx, sym); } else if (isCurvedType(sym.symbolId)) { drawCurvedSymbol(ctx, sym); } else { const img = getSymbolImage(sym.file); if (!img) return false; if (isPhotoeyeType(sym.symbolId)) { drawPhotoeye3Slice(ctx, sym, img); } else { ctx.drawImage(img, sym.x, sym.y, sym.w, sym.h); } } return true; } function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const cx = sym.x + sym.w / 2; const isSelected = layout.selectedIds.has(sym.id); // Selection highlight if (isSelected) { ctx.strokeStyle = THEME.selection.strokeColor; ctx.lineWidth = THEME.selection.lineWidth; ctx.shadowColor = THEME.selection.shadowColor; ctx.shadowBlur = THEME.selection.shadowBlur; strokeOutline(ctx, sym, 2); ctx.shadowBlur = 0; if (layout.selectedIds.size === 1) { if (isEpcType(sym.symbolId)) { drawEpcWaypointHandles(ctx, sym); } else { drawResizeHandles(ctx, sym); } } } // Collision highlight if (checkSpacingViolation(sym.id, sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.curveAngle, sym.w2)) { ctx.strokeStyle = THEME.collision.strokeColor; ctx.lineWidth = THEME.collision.lineWidth; ctx.shadowColor = THEME.collision.shadowColor; ctx.shadowBlur = THEME.collision.shadowBlur; strokeOutline(ctx, sym, 2); ctx.shadowBlur = 0; } // Hover border (non-selected) if (!isSelected) { ctx.strokeStyle = THEME.hover.strokeColor; ctx.lineWidth = THEME.hover.lineWidth; strokeOutline(ctx, sym, 0); } // Label above symbol if (sym.label) { ctx.fillStyle = THEME.label.color; ctx.font = THEME.label.font; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY); } } function drawSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { ctx.save(); // Apply rotation and mirror transforms if (sym.rotation || sym.mirrored) { const cx = sym.x + sym.w / 2; const cy = sym.y + sym.h / 2; ctx.translate(cx, cy); if (sym.rotation) ctx.rotate((sym.rotation * Math.PI) / 180); if (sym.mirrored) ctx.scale(-1, 1); ctx.translate(-cx, -cy); } if (!drawSymbolBody(ctx, sym)) { ctx.restore(); return; } drawSymbolOverlays(ctx, sym); ctx.restore(); }