- Right box stroke now uses EPC_CONFIG.lineWidth (1.5) instead of 0.3 - Right box positioned at -rb.w (backward) matching canvas renderer - End box rotated 90° (perpendicular to line) in canvas, export, and hit-testing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
545 lines
19 KiB
TypeScript
545 lines
19 KiB
TypeScript
import { layout } from '../stores/layout.svelte.js';
|
|
import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, getCurveGeometry, getSymbolGroup, SPACING_EXEMPT, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG } from '../symbols.js';
|
|
import { checkSpacingViolation } from './collision.js';
|
|
import { marqueeRect } from './interactions.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/FIOs) on top
|
|
// Skip hidden symbols (individually hidden or group hidden)
|
|
for (const sym of layout.symbols) {
|
|
if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue;
|
|
if (!SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol);
|
|
}
|
|
for (const sym of layout.symbols) {
|
|
if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue;
|
|
if (SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol);
|
|
}
|
|
|
|
// Marquee selection rectangle
|
|
if (marqueeRect) {
|
|
ctx.strokeStyle = '#4a9eff';
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.fillStyle = 'rgba(74, 158, 255, 0.1)';
|
|
ctx.fillRect(marqueeRect.x, marqueeRect.y, marqueeRect.w, marqueeRect.h);
|
|
ctx.strokeRect(marqueeRect.x, marqueeRect.y, marqueeRect.w, marqueeRect.h);
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
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.rotate(-Math.PI / 2);
|
|
ctx.fillRect(-rb.w, -rb.h / 2, rb.w, rb.h);
|
|
ctx.strokeRect(-rb.w, -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 - Math.PI / 2);
|
|
ctx.beginPath();
|
|
ctx.rect(-rb.w - 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 if (isSpurType(sym.symbolId)) {
|
|
// Draw trapezoid programmatically so w and w2 are respected during resize
|
|
const w2 = sym.w2 ?? sym.w;
|
|
ctx.beginPath();
|
|
ctx.moveTo(sym.x, sym.y);
|
|
ctx.lineTo(sym.x + w2, sym.y);
|
|
ctx.lineTo(sym.x + sym.w, sym.y + sym.h);
|
|
ctx.lineTo(sym.x, sym.y + sym.h);
|
|
ctx.closePath();
|
|
ctx.fillStyle = '#000000';
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 0.5;
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
} 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, THEME.selection.pad);
|
|
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, sym.mirrored)) {
|
|
ctx.strokeStyle = THEME.collision.strokeColor;
|
|
ctx.lineWidth = THEME.collision.lineWidth;
|
|
ctx.shadowColor = THEME.collision.shadowColor;
|
|
ctx.shadowBlur = THEME.collision.shadowBlur;
|
|
strokeOutline(ctx, sym, THEME.collision.pad);
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
// Label drop-target highlight
|
|
if (layout.labelDropTarget === sym.id) {
|
|
ctx.fillStyle = THEME.dropTarget.fillColor;
|
|
ctx.strokeStyle = THEME.dropTarget.strokeColor;
|
|
ctx.lineWidth = THEME.dropTarget.lineWidth;
|
|
ctx.shadowColor = THEME.dropTarget.shadowColor;
|
|
ctx.shadowBlur = THEME.dropTarget.shadowBlur;
|
|
// Fill + stroke the outline
|
|
ctx.beginPath();
|
|
ctx.rect(sym.x - THEME.dropTarget.pad, sym.y - THEME.dropTarget.pad,
|
|
sym.w + THEME.dropTarget.pad * 2, sym.h + THEME.dropTarget.pad * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
// Hover border (non-selected)
|
|
if (!isSelected && layout.labelDropTarget !== sym.id) {
|
|
ctx.strokeStyle = THEME.hover.strokeColor;
|
|
ctx.lineWidth = THEME.hover.lineWidth;
|
|
strokeOutline(ctx, sym, 0);
|
|
}
|
|
|
|
// Label: centered inside symbol if large enough, above if too small
|
|
if (sym.label) {
|
|
ctx.fillStyle = THEME.label.color;
|
|
ctx.font = THEME.label.font;
|
|
const metrics = ctx.measureText(sym.label);
|
|
const textH = 3; // approximate font height
|
|
if (sym.w >= metrics.width + 4 && sym.h >= textH + 4) {
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(sym.label, cx, sym.y + sym.h / 2);
|
|
} else {
|
|
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();
|
|
}
|