igurielidze 6f0ac836fb Refactor collision/distance modules, fix curved geometry, add mirror support
- Split collision.ts (707→549): extract distance.ts (pure math) and grid-snap.ts
- Fix curved conveyor/chute outline to match SVG viewBox geometry
- Draw curves programmatically with fixed 30px band width (no SVG stretching)
- Single resize handle for curves (was 2)
- Add .gitattributes for consistent line endings
- Make Vec2 type module-private
- Add mirror transform support in renderer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:21:04 +04:00

491 lines
17 KiB
TypeScript

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();
}