diff --git a/svelte-app/src/components/Toolbar.svelte b/svelte-app/src/components/Toolbar.svelte index 514186c..afaf0ef 100644 --- a/svelte-app/src/components/Toolbar.svelte +++ b/svelte-app/src/components/Toolbar.svelte @@ -28,9 +28,11 @@ layout.loadMcmState(); } else { layout.currentProject = savedProject || 'default'; - layout.currentMcm = savedMcm || 'MCM08'; + layout.currentMcm = savedMcm || 'MCM01'; layout.loadMcmState(); } + localStorage.setItem('scada_lastProject', layout.currentProject); + localStorage.setItem('scada_lastMcm', layout.currentMcm); await restorePdf(); }); @@ -58,9 +60,9 @@ async function autoLoadPdf() { const proj = layout.projects.find(p => p.name === layout.currentProject); - if (!proj) return; + if (!proj) { removePdf(); return; } const mcm = proj.mcms.find(m => m.name === layout.currentMcm); - if (!mcm?.pdfPath) return; + if (!mcm?.pdfPath) { removePdf(); return; } await loadPdfFromPath(mcm.pdfPath); } diff --git a/svelte-app/src/lib/canvas/collision.ts b/svelte-app/src/lib/canvas/collision.ts index 3f49838..16a334d 100644 --- a/svelte-app/src/lib/canvas/collision.ts +++ b/svelte-app/src/lib/canvas/collision.ts @@ -1,5 +1,5 @@ import type { AABB, EpcWaypoint } from '../types.js'; -import { SPACING_EXEMPT, isCurvedType, isSpurType, isEpcType, getCurveBandWidth, EPC_LEFT_BOX, EPC_RIGHT_BOX, EPC_LINE_WIDTH, EPC_DEFAULT_WAYPOINTS } from '../symbols.js'; +import { SPACING_EXEMPT, isCurvedType, isSpurType, isEpcType, getCurveBandWidth, EPC_CONFIG } from '../symbols.js'; import { layout } from '../stores/layout.svelte.js'; import { orientedBoxCorners, createCurveTransforms } from './geometry.js'; @@ -452,7 +452,7 @@ function getEpcCollisionQuads( const ox = symX, oy = symY; // Line segment quads (use half-width of at least 1px for collision) - const segHalfW = Math.max(EPC_LINE_WIDTH / 2, 0.5); + const segHalfW = Math.max(EPC_CONFIG.lineWidth / 2, 0.5); for (let i = 0; i < waypoints.length - 1; i++) { const q = segmentQuad( ox + waypoints[i].x, oy + waypoints[i].y, @@ -467,7 +467,7 @@ function getEpcCollisionQuads( const lbQ = boxQuad( ox + p0.x, oy + p0.y, p1.x - p0.x, p1.y - p0.y, - EPC_LEFT_BOX.w, EPC_LEFT_BOX.h, + EPC_CONFIG.leftBox.w, EPC_CONFIG.leftBox.h, 'right' ); if (lbQ.length === 4) quads.push(lbQ); @@ -478,7 +478,7 @@ function getEpcCollisionQuads( const rbQ = boxQuad( ox + last.x, oy + last.y, last.x - prev.x, last.y - prev.y, - EPC_RIGHT_BOX.w, EPC_RIGHT_BOX.h, + EPC_CONFIG.rightBox.w, EPC_CONFIG.rightBox.h, 'left' ); if (rbQ.length === 4) quads.push(rbQ); @@ -489,7 +489,7 @@ function getEpcCollisionQuads( /** Look up EPC waypoints for a symbol (from layout store or defaults) */ function getSymWaypoints(id: number): EpcWaypoint[] { const sym = layout.symbols.find(s => s.id === id); - return sym?.epcWaypoints || EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp })); + return sym?.epcWaypoints || EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp })); } // ─── Main spacing violation check ─── @@ -562,7 +562,7 @@ export function checkSpacingViolation( // Get EPC quads for "other" symbol if it's EPC let symEpcQuads: [number, number][][] | null = null; if (symIsEpc) { - const symWps = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS; + const symWps = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; symEpcQuads = getEpcCollisionQuads(sym.x, sym.y, sym.w, sym.h, sym.rotation, symWps); } diff --git a/svelte-app/src/lib/canvas/hit-testing.ts b/svelte-app/src/lib/canvas/hit-testing.ts new file mode 100644 index 0000000..0259b68 --- /dev/null +++ b/svelte-app/src/lib/canvas/hit-testing.ts @@ -0,0 +1,116 @@ +/** Pure hit-testing functions — no module state, no side effects */ + +import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, EPC_CONFIG, INDUCTION_CONFIG } from '../symbols.js'; +import { THEME } from './render-theme.js'; +import type { PlacedSymbol } from '../types.js'; + +/** Transform a point into a symbol's unrotated local space */ +export function toSymbolLocal(px: number, py: number, sym: PlacedSymbol): { x: number; y: number } { + if (!sym.rotation) return { x: px, y: py }; + const scx = sym.x + sym.w / 2; + const scy = sym.y + sym.h / 2; + const rad = (-sym.rotation * Math.PI) / 180; + const cos = Math.cos(rad), sin = Math.sin(rad); + const dx = px - scx, dy = py - scy; + return { x: dx * cos - dy * sin + scx, y: dx * sin + dy * cos + scy }; +} + +export function pointInRect(px: number, py: number, rx: number, ry: number, rw: number, rh: number): boolean { + return px >= rx && px <= rx + rw && py >= ry && py <= ry + rh; +} + +export function pointInTrapezoid(px: number, py: number, sym: PlacedSymbol): boolean { + const w2 = sym.w2 ?? sym.w; + const t = (py - sym.y) / sym.h; + const maxX = sym.x + w2 + t * (sym.w - w2); + return px >= sym.x && px <= maxX && py >= sym.y && py <= sym.y + sym.h; +} + +/** Hit test EPC waypoint handles. Returns waypoint index or -1 */ +export function hitEpcWaypoint(cx: number, cy: number, sym: PlacedSymbol): number { + if (!isEpcType(sym.symbolId)) return -1; + const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; + const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym); + const hs = THEME.epcWaypoint.size; + for (let i = 0; i < waypoints.length; i++) { + const wx = sym.x + waypoints[i].x; + const wy = sym.y + waypoints[i].y; + const dx = lx - wx, dy = ly - wy; + if (dx * dx + dy * dy <= hs * hs) return i; + } + return -1; +} + +/** Hit test EPC line segment midpoints for adding waypoints. Returns insert index or -1 */ +export function hitEpcSegmentMidpoint(cx: number, cy: number, sym: PlacedSymbol): number { + if (!isEpcType(sym.symbolId)) return -1; + const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; + const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym); + const hs = 8; + 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; + const dx = lx - mx, dy = ly - (my + THEME.epcWaypoint.hintOffsetY); + if (dx * dx + dy * dy <= hs * hs) return i + 1; + } + return -1; +} + +/** Hit test resize handles. Caller must verify single-selection. */ +export function hitResizeHandle(cx: number, cy: number, sym: PlacedSymbol): 'left' | 'right' | 'spur-top' | 'spur-bottom' | null { + if (!isResizable(sym.symbolId)) return null; + + const hs = THEME.resizeHandle.size; + const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym); + + if (isCurvedType(sym.symbolId)) { + const arcAngle = sym.curveAngle || 90; + const arcRad = (arcAngle * Math.PI) / 180; + const outerR = sym.w; + const arcCx = sym.x; + const arcCy = sym.y + sym.h; + const h1x = arcCx + outerR, h1y = arcCy; + if (pointInRect(lx, ly, h1x - hs / 2, h1y - hs / 2, hs, hs)) return 'right'; + const h2x = arcCx + outerR * Math.cos(arcRad); + const h2y = arcCy - outerR * Math.sin(arcRad); + if (pointInRect(lx, ly, h2x - hs / 2, h2y - hs / 2, hs, hs)) return 'left'; + return null; + } + + if (isSpurType(sym.symbolId)) { + const w2 = sym.w2 ?? sym.w; + if (pointInRect(lx, ly, sym.x + w2 - hs / 2, sym.y - hs / 2, hs, hs)) return 'spur-top'; + if (pointInRect(lx, ly, sym.x + sym.w - hs / 2, sym.y + sym.h - hs / 2, hs, hs)) return 'spur-bottom'; + return null; + } + + if (isInductionType(sym.symbolId)) { + const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac; + const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac; + const stripMidY = (stripTopY + stripBottomY) / 2 - hs / 2; + if (pointInRect(lx, ly, sym.x + sym.w - hs / 2, stripMidY, hs, hs)) return 'right'; + return null; + } + + const midY = sym.y + sym.h / 2 - hs / 2; + if (pointInRect(lx, ly, sym.x - hs / 2, midY, hs, hs)) return 'left'; + if (pointInRect(lx, ly, sym.x + sym.w - hs / 2, midY, hs, hs)) return 'right'; + return null; +} + +/** Hit test all symbols, returning the id of the topmost hit, or null */ +export function hitTestSymbols( + cx: number, cy: number, + symbols: PlacedSymbol[] +): number | null { + for (let i = symbols.length - 1; i >= 0; i--) { + const sym = symbols[i]; + const local = toSymbolLocal(cx, cy, sym); + if (isSpurType(sym.symbolId)) { + if (pointInTrapezoid(local.x, local.y, sym)) return sym.id; + } else if (pointInRect(local.x, local.y, sym.x, sym.y, sym.w, sym.h)) { + return sym.id; + } + } + return null; +} diff --git a/svelte-app/src/lib/canvas/interactions.ts b/svelte-app/src/lib/canvas/interactions.ts index 08f55bf..d4c067a 100644 --- a/svelte-app/src/lib/canvas/interactions.ts +++ b/svelte-app/src/lib/canvas/interactions.ts @@ -1,8 +1,17 @@ import { layout } from '../stores/layout.svelte.js'; -import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, SYMBOLS, EPC_DEFAULT_WAYPOINTS, EPC_LEFT_BOX, EPC_RIGHT_BOX, INDUCTION_STRIP_TOP_FRAC, INDUCTION_STRIP_BOTTOM_FRAC } from '../symbols.js'; +import { isCurvedType, isEpcType, SYMBOLS, EPC_CONFIG } from '../symbols.js'; import type { EpcWaypoint } from '../types.js'; import { getAABB, snapToGrid, clamp, findValidPosition } from './collision.js'; import { orientedBoxCorners, pastDragThreshold } from './geometry.js'; +import { toSymbolLocal, pointInRect, pointInTrapezoid, hitEpcWaypoint, hitEpcSegmentMidpoint, hitResizeHandle, hitTestSymbols } from './hit-testing.js'; + +/** Ensure an EPC symbol has its waypoints array initialized */ +function ensureEpcWaypoints(sym: { epcWaypoints?: import('../types.js').EpcWaypoint[] }) { + if (!sym.epcWaypoints) { + sym.epcWaypoints = EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp })); + } + return sym.epcWaypoints; +} const ZOOM_MIN = 0.1; const ZOOM_MAX = 5; @@ -83,115 +92,8 @@ function screenToCanvas(clientX: number, clientY: number): { x: number; y: numbe }; } -/** Transform a point into a symbol's unrotated local space */ -function toSymbolLocal(px: number, py: number, sym: typeof layout.symbols[0]): { x: number; y: number } { - if (!sym.rotation) return { x: px, y: py }; - const scx = sym.x + sym.w / 2; - const scy = sym.y + sym.h / 2; - const rad = (-sym.rotation * Math.PI) / 180; - const cos = Math.cos(rad), sin = Math.sin(rad); - const dx = px - scx, dy = py - scy; - return { x: dx * cos - dy * sin + scx, y: dx * sin + dy * cos + scy }; -} - -function pointInRect(px: number, py: number, rx: number, ry: number, rw: number, rh: number): boolean { - return px >= rx && px <= rx + rw && py >= ry && py <= ry + rh; -} - -function pointInTrapezoid(px: number, py: number, sym: typeof layout.symbols[0]): boolean { - const w2 = sym.w2 ?? sym.w; - // The right edge of the trapezoid is a slanted line from (x+w2, y) to (x+w, y+h) - // For a given py, the max x is interpolated between w2 (top) and w (bottom) - const t = (py - sym.y) / sym.h; - const maxX = sym.x + w2 + t * (sym.w - w2); - return px >= sym.x && px <= maxX && py >= sym.y && py <= sym.y + sym.h; -} - function hitTest(cx: number, cy: number): number | null { - for (let i = layout.symbols.length - 1; i >= 0; i--) { - const sym = layout.symbols[i]; - const local = toSymbolLocal(cx, cy, sym); - if (isSpurType(sym.symbolId)) { - if (pointInTrapezoid(local.x, local.y, sym)) return sym.id; - } else if (pointInRect(local.x, local.y, sym.x, sym.y, sym.w, sym.h)) { - return sym.id; - } - } - return null; -} - -/** Hit test EPC waypoint handles. Returns waypoint index or -1 */ -function hitEpcWaypoint(cx: number, cy: number, sym: typeof layout.symbols[0]): number { - if (!isEpcType(sym.symbolId)) return -1; - const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS; - const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym); - const hs = 6; - for (let i = 0; i < waypoints.length; i++) { - const wx = sym.x + waypoints[i].x; - const wy = sym.y + waypoints[i].y; - const dx = lx - wx, dy = ly - wy; - if (dx * dx + dy * dy <= hs * hs) return i; - } - return -1; -} - -/** Hit test EPC line segment midpoints for adding waypoints. Returns insert index or -1 */ -function hitEpcSegmentMidpoint(cx: number, cy: number, sym: typeof layout.symbols[0]): number { - if (!isEpcType(sym.symbolId)) return -1; - const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS; - const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym); - const hs = 8; - 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; - const dx = lx - mx, dy = ly - (my - 4); // offset for the "+" indicator position - if (dx * dx + dy * dy <= hs * hs) return i + 1; - } - return -1; -} - -function hitResizeHandle(cx: number, cy: number, sym: typeof layout.symbols[0]): 'left' | 'right' | 'spur-top' | 'spur-bottom' | null { - if (!isResizable(sym.symbolId) || layout.selectedIds.size !== 1 || !layout.selectedIds.has(sym.id)) return null; - - const hs = 10; - const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym); - - if (isCurvedType(sym.symbolId)) { - const arcAngle = sym.curveAngle || 90; - const arcRad = (arcAngle * Math.PI) / 180; - const outerR = sym.w; - const arcCx = sym.x; - const arcCy = sym.y + sym.h; - const h1x = arcCx + outerR, h1y = arcCy; - if (pointInRect(lx, ly, h1x - hs / 2, h1y - hs / 2, hs, hs)) return 'right'; - const h2x = arcCx + outerR * Math.cos(arcRad); - const h2y = arcCy - outerR * Math.sin(arcRad); - if (pointInRect(lx, ly, h2x - hs / 2, h2y - hs / 2, hs, hs)) return 'left'; - return null; - } - - if (isSpurType(sym.symbolId)) { - const w2 = sym.w2 ?? sym.w; - // Top-right handle (controls w2) - if (pointInRect(lx, ly, sym.x + w2 - hs / 2, sym.y - hs / 2, hs, hs)) return 'spur-top'; - // Bottom-right handle (controls w) - if (pointInRect(lx, ly, sym.x + sym.w - hs / 2, sym.y + sym.h - hs / 2, hs, hs)) return 'spur-bottom'; - return null; - } - - if (isInductionType(sym.symbolId)) { - // Only right handle — arrow head is fixed width - const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC; - const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC; - const stripMidY = (stripTopY + stripBottomY) / 2 - hs / 2; - if (pointInRect(lx, ly, sym.x + sym.w - hs / 2, stripMidY, hs, hs)) return 'right'; - return null; - } - - const midY = sym.y + sym.h / 2 - hs / 2; - if (pointInRect(lx, ly, sym.x - hs / 2, midY, hs, hs)) return 'left'; - if (pointInRect(lx, ly, sym.x + sym.w - hs / 2, midY, hs, hs)) return 'right'; - return null; + return hitTestSymbols(cx, cy, layout.symbols); } function onCanvasMousedown(e: MouseEvent) { @@ -220,11 +122,11 @@ function onCanvasMousedown(e: MouseEvent) { // Double-click on existing waypoint to remove it (if >2 waypoints) if (e.detail === 2) { const wpIdx = hitEpcWaypoint(pos.x, pos.y, sel); - const wps = sel.epcWaypoints || EPC_DEFAULT_WAYPOINTS; + const wps = sel.epcWaypoints || EPC_CONFIG.defaultWaypoints; if (wpIdx >= 0 && wps.length > 2) { layout.pushUndo(); if (!sel.epcWaypoints) { - sel.epcWaypoints = EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp })); + sel.epcWaypoints = EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp })); } sel.epcWaypoints.splice(wpIdx, 1); recalcEpcBounds(sel); @@ -237,7 +139,7 @@ function onCanvasMousedown(e: MouseEvent) { if (insertIdx >= 0) { layout.pushUndo(); if (!sel.epcWaypoints) { - sel.epcWaypoints = EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp })); + sel.epcWaypoints = EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp })); } const prev = sel.epcWaypoints[insertIdx - 1]; const next = sel.epcWaypoints[insertIdx]; @@ -254,9 +156,7 @@ function onCanvasMousedown(e: MouseEvent) { const wpIdx = hitEpcWaypoint(pos.x, pos.y, sel); if (wpIdx >= 0) { layout.pushUndo(); - if (!sel.epcWaypoints) { - sel.epcWaypoints = EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp })); - } + ensureEpcWaypoints(sel); dragState = { type: 'epc-waypoint', placedId: sel.id, @@ -268,7 +168,7 @@ function onCanvasMousedown(e: MouseEvent) { return; } } - if (sel) { + if (sel && layout.selectedIds.size === 1) { const handle = hitResizeHandle(pos.x, pos.y, sel); if (handle) { layout.pushUndo(); @@ -409,7 +309,7 @@ function recalcEpcBounds(sym: typeof layout.symbols[0]) { // Left box corners (oriented along first segment) const lbDir = { x: wps[1].x - wps[0].x, y: wps[1].y - wps[0].y }; - const lbCorners = orientedBoxCorners(wps[0].x, wps[0].y, lbDir.x, lbDir.y, EPC_LEFT_BOX.w, EPC_LEFT_BOX.h, 'right'); + const lbCorners = orientedBoxCorners(wps[0].x, wps[0].y, lbDir.x, lbDir.y, EPC_CONFIG.leftBox.w, EPC_CONFIG.leftBox.h, 'right'); for (const [bx, by] of lbCorners) { minX = Math.min(minX, bx); minY = Math.min(minY, by); maxX = Math.max(maxX, bx); maxY = Math.max(maxY, by); @@ -419,7 +319,7 @@ function recalcEpcBounds(sym: typeof layout.symbols[0]) { const last = wps[wps.length - 1]; const prev = wps[wps.length - 2]; const rbDir = { x: last.x - prev.x, y: last.y - prev.y }; - const rbCorners = orientedBoxCorners(last.x, last.y, rbDir.x, rbDir.y, EPC_RIGHT_BOX.w, EPC_RIGHT_BOX.h, 'left'); + const rbCorners = orientedBoxCorners(last.x, last.y, rbDir.x, rbDir.y, EPC_CONFIG.rightBox.w, EPC_CONFIG.rightBox.h, 'left'); for (const [bx, by] of rbCorners) { minX = Math.min(minX, bx); minY = Math.min(minY, by); maxX = Math.max(maxX, bx); maxY = Math.max(maxY, by); @@ -668,7 +568,7 @@ function onMouseup(e: MouseEvent) { w2: sym.w2, rotation: rot, curveAngle: sym.curveAngle, - epcWaypoints: isEpcType(sym.id) ? EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp })) : undefined, + epcWaypoints: isEpcType(sym.id) ? EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp })) : undefined, pdpCBs: undefined, }); } diff --git a/svelte-app/src/lib/canvas/render-theme.ts b/svelte-app/src/lib/canvas/render-theme.ts new file mode 100644 index 0000000..f8c873d --- /dev/null +++ b/svelte-app/src/lib/canvas/render-theme.ts @@ -0,0 +1,55 @@ +/** Centralized visual theme for canvas rendering — no magic numbers in draw code */ + +export const THEME = { + grid: { + color: '#333', + lineWidth: 0.5, + }, + selection: { + strokeColor: '#00ff88', + lineWidth: 2, + shadowColor: 'rgba(0, 255, 136, 0.4)', + shadowBlur: 6, + }, + collision: { + strokeColor: '#ff0000', + lineWidth: 2, + shadowColor: 'rgba(255, 0, 0, 0.6)', + shadowBlur: 8, + }, + hover: { + strokeColor: 'rgba(233, 69, 96, 0.3)', + lineWidth: 1, + }, + label: { + color: '#e94560', + font: '10px sans-serif', + offsetY: -3, + }, + resizeHandle: { + size: 10, + fillColor: '#00ff88', + strokeColor: '#009955', + lineWidth: 1, + }, + epcWaypoint: { + size: 6, + fillColor: '#00ccff', + strokeColor: '#0088aa', + lineWidth: 1, + hintFont: '6px sans-serif', + hintOffsetY: -4, + }, + epcBody: { + lineColor: '#000000', + rightBoxFill: '#aaaaaa', + rightBoxStroke: '#000000', + rightBoxStrokeWidth: 0.3, + }, + induction: { + fillColor: '#000000', + }, + canvas: { + maxRenderScale: 4, + }, +} as const; diff --git a/svelte-app/src/lib/canvas/renderer.ts b/svelte-app/src/lib/canvas/renderer.ts index 1f950e8..78f0c1d 100644 --- a/svelte-app/src/lib/canvas/renderer.ts +++ b/svelte-app/src/lib/canvas/renderer.ts @@ -1,6 +1,7 @@ import { layout } from '../stores/layout.svelte.js'; -import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, getCurveBandWidth, SPACING_EXEMPT, EPC_LEFT_BOX, EPC_RIGHT_BOX, EPC_DEFAULT_WAYPOINTS, EPC_LINE_WIDTH, EPC_ICON_FILE, INDUCTION_HEAD_W, INDUCTION_ARROW, INDUCTION_STRIP_TOP_FRAC, INDUCTION_STRIP_BOTTOM_FRAC, PHOTOEYE_CONFIG } from '../symbols.js'; +import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, getCurveBandWidth, 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; @@ -8,7 +9,7 @@ let canvas: HTMLCanvasElement | null = null; let animFrameId: number | null = null; let lastDirty = -1; -const MAX_RENDER_SCALE = 4; +const MAX_RENDER_SCALE = THEME.canvas.maxRenderScale; let currentRenderScale = 0; let lastCanvasW = 0; let lastCanvasH = 0; @@ -80,8 +81,8 @@ export function render() { function drawGrid(ctx: CanvasRenderingContext2D) { const size = layout.gridSize; - ctx.strokeStyle = '#333'; - ctx.lineWidth = 0.5; + 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); @@ -125,7 +126,7 @@ function traceSpurPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: nu /** 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_DEFAULT_WAYPOINTS; + const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; const ox = sym.x; // origin x const oy = sym.y; // origin y @@ -137,19 +138,19 @@ function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { for (let i = 1; i < waypoints.length; i++) { ctx.lineTo(ox + waypoints[i].x, oy + waypoints[i].y); } - ctx.strokeStyle = '#000000'; - ctx.lineWidth = EPC_LINE_WIDTH; + 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_LEFT_BOX; + 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_ICON_FILE); + const iconImg = getSymbolImage(EPC_CONFIG.iconFile); if (iconImg) { ctx.save(); ctx.translate(p0x, p0y); @@ -170,10 +171,10 @@ function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { ctx.save(); ctx.translate(plx, ply); ctx.rotate(angle); - const rb = EPC_RIGHT_BOX; - ctx.fillStyle = '#aaaaaa'; - ctx.strokeStyle = '#000000'; - ctx.lineWidth = 0.3; + 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(); @@ -182,11 +183,11 @@ function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { /** Draw EPC waypoint handles when selected */ function drawEpcWaypointHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { - const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS; - const hs = 6; - ctx.fillStyle = '#00ccff'; - ctx.strokeStyle = '#0088aa'; - ctx.lineWidth = 1; + 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; @@ -199,21 +200,21 @@ function drawEpcWaypointHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol // Draw "+" at midpoints of segments to hint add-waypoint if (waypoints.length >= 2) { - ctx.fillStyle = '#00ccff'; - ctx.font = '6px sans-serif'; + 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 - 4); + 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_DEFAULT_WAYPOINTS; + const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; if (waypoints.length < 2) return; const ox = sym.x, oy = sym.y; @@ -221,7 +222,7 @@ function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, p 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_LEFT_BOX; + const lb = EPC_CONFIG.leftBox; ctx.save(); ctx.translate(p0x, p0y); @@ -241,7 +242,7 @@ function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, p ctx.translate(ax, ay); ctx.rotate(segAngle); ctx.beginPath(); - ctx.rect(-pad, -pad - EPC_LINE_WIDTH / 2, segLen + pad * 2, EPC_LINE_WIDTH + pad * 2); + ctx.rect(-pad, -pad - EPC_CONFIG.lineWidth / 2, segLen + pad * 2, EPC_CONFIG.lineWidth + pad * 2); ctx.stroke(); ctx.restore(); } @@ -252,7 +253,7 @@ function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, p 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_RIGHT_BOX; + const rb = EPC_CONFIG.rightBox; ctx.save(); ctx.translate(plx, ply); @@ -265,10 +266,10 @@ function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, p /** Trace the induction outline path (arrow head + strip) */ function traceInductionPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { - const hw = INDUCTION_HEAD_W; - const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC; - const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC; - const pts = INDUCTION_ARROW.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const); + 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); @@ -311,10 +312,10 @@ function drawHandle(ctx: CanvasRenderingContext2D, x: number, y: number, size: n function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { if (!isResizable(sym.symbolId)) return; - const hs = 10; - ctx.fillStyle = '#00ff88'; - ctx.strokeStyle = '#009955'; - ctx.lineWidth = 1; + 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 arcAngle = sym.curveAngle || 90; @@ -332,8 +333,8 @@ function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { 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_STRIP_TOP_FRAC; - const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC; + 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 { @@ -345,12 +346,12 @@ function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { /** Draw induction programmatically: fixed arrow head + variable strip, as one path */ function drawInductionSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { - const hw = INDUCTION_HEAD_W; - const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC; - const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC; + 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_ARROW.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const); + 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 @@ -367,7 +368,7 @@ function drawInductionSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { ctx.lineTo(sym.x + sym.w, stripBottomY); ctx.closePath(); - ctx.fillStyle = '#000000'; + ctx.fillStyle = THEME.induction.fillColor; ctx.fill(); } @@ -413,10 +414,10 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { // Selection highlight if (isSelected) { - ctx.strokeStyle = '#00ff88'; - ctx.lineWidth = 2; - ctx.shadowColor = 'rgba(0, 255, 136, 0.4)'; - ctx.shadowBlur = 6; + 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) { @@ -430,28 +431,28 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { // Collision highlight if (checkSpacingViolation(sym.id, sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.curveAngle, sym.w2)) { - ctx.strokeStyle = '#ff0000'; - ctx.lineWidth = 2; - ctx.shadowColor = 'rgba(255, 0, 0, 0.6)'; - ctx.shadowBlur = 8; + 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 = 'rgba(233, 69, 96, 0.3)'; - ctx.lineWidth = 1; + ctx.strokeStyle = THEME.hover.strokeColor; + ctx.lineWidth = THEME.hover.lineWidth; strokeOutline(ctx, sym, 0); } // Label above symbol if (sym.label) { - ctx.fillStyle = '#e94560'; - ctx.font = '10px sans-serif'; + ctx.fillStyle = THEME.label.color; + ctx.font = THEME.label.font; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; - ctx.fillText(sym.label, cx, sym.y - 3); + ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY); } } diff --git a/svelte-app/src/lib/export.ts b/svelte-app/src/lib/export.ts index 0926af4..e510409 100644 --- a/svelte-app/src/lib/export.ts +++ b/svelte-app/src/lib/export.ts @@ -1,5 +1,5 @@ import { layout } from './stores/layout.svelte.js'; -import { isEpcType, isInductionType, EPC_LEFT_BOX, EPC_RIGHT_BOX, EPC_LINE_WIDTH, EPC_DEFAULT_WAYPOINTS, EPC_ICON_FILE, INDUCTION_HEAD_W, INDUCTION_ARROW, INDUCTION_STRIP_TOP_FRAC, INDUCTION_STRIP_BOTTOM_FRAC } from './symbols.js'; +import { isEpcType, isInductionType, EPC_CONFIG, INDUCTION_CONFIG } from './symbols.js'; import { deserializeSymbol } from './serialization.js'; import type { PlacedSymbol } from './types.js'; @@ -13,7 +13,7 @@ function downloadBlob(blob: Blob, filename: string) { } async function buildEpcSvgElements(sym: PlacedSymbol): Promise { - const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS; + const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; const ox = sym.x; const oy = sym.y; const parts: string[] = []; @@ -21,18 +21,18 @@ async function buildEpcSvgElements(sym: PlacedSymbol): Promise { // Polyline if (waypoints.length >= 2) { const points = waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' '); - parts.push(` `); + parts.push(` `); } if (waypoints.length >= 2) { // Left icon — embed actual SVG, oriented along first segment - const lb = EPC_LEFT_BOX; + 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 lAngle = Math.atan2(p1y - p0y, p1x - p0x) * 180 / Math.PI; try { - const svgText = await (await fetch(EPC_ICON_FILE)).text(); + const svgText = await (await fetch(EPC_CONFIG.iconFile)).text(); const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml'); const svgEl = doc.documentElement; const vb = svgEl.getAttribute('viewBox'); @@ -54,7 +54,7 @@ async function buildEpcSvgElements(sym: PlacedSymbol): Promise { 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) * 180 / Math.PI; - const rb = EPC_RIGHT_BOX; + const rb = EPC_CONFIG.rightBox; parts.push(` `); } @@ -83,10 +83,10 @@ export async function exportSVG() { lines.push(await buildEpcSvgElements(sym as PlacedSymbol)); lines.push(' '); } else if (isInductionType(sym.symbolId)) { - const hw = INDUCTION_HEAD_W; - const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC; - const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC; - const pts = INDUCTION_ARROW.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const); + 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); const d = `M ${sym.x + sym.w},${stripTopY} L ${pts[0][0]},${stripTopY} ${pts.map(([px, py]) => `L ${px},${py}`).join(' ')} L ${pts[5][0]},${stripBottomY} L ${sym.x + sym.w},${stripBottomY} Z`; lines.push(` `); lines.push(` `); diff --git a/svelte-app/src/lib/stores/layout.svelte.ts b/svelte-app/src/lib/stores/layout.svelte.ts index 22506c8..85e64a9 100644 --- a/svelte-app/src/lib/stores/layout.svelte.ts +++ b/svelte-app/src/lib/stores/layout.svelte.ts @@ -10,10 +10,10 @@ class LayoutStore { symbols = $state([]); nextId = $state(1); selectedIds = $state>(new Set()); - gridSize = $state(20); - minSpacing = $state(10); + gridSize = $state(2); + minSpacing = $state(2); snapEnabled = $state(true); - showGrid = $state(true); + showGrid = $state(false); zoomLevel = $state(1); panX = $state(0); panY = $state(0); diff --git a/svelte-app/src/lib/symbols.ts b/svelte-app/src/lib/symbols.ts index 37dded1..c38709f 100644 --- a/svelte-app/src/lib/symbols.ts +++ b/svelte-app/src/lib/symbols.ts @@ -87,14 +87,6 @@ export const SPACING_EXEMPT = new Set([ 'photoeye', 'photoeye_v', ]); -// Re-export config as legacy names for backward compatibility -export const EPC_ICON_FILE = EPC_CONFIG.iconFile; -export const INDUCTION_HEAD_W = INDUCTION_CONFIG.headWidth; -export const INDUCTION_ARROW: [number, number][] = [...INDUCTION_CONFIG.arrowPoints]; -export const INDUCTION_STRIP_TOP_FRAC = INDUCTION_CONFIG.stripTopFrac; -export const INDUCTION_STRIP_BOTTOM_FRAC = INDUCTION_CONFIG.stripBottomFrac; -export const CURVE_CONV_BAND = CURVE_CONFIG.convBand; -export const CURVE_CHUTE_BAND = CURVE_CONFIG.chuteBand; export function getCurveBandWidth(symbolId: string): number { if (symbolId.startsWith('curved_chute')) return CURVE_CONFIG.chuteBand; @@ -104,44 +96,32 @@ export function getCurveBandWidth(symbolId: string): number { const imageCache = new Map(); const SVG_SCALE = 10; // Rasterize SVGs at 10x for crisp canvas rendering +async function loadSvgImage(file: string): Promise { + if (imageCache.has(file)) return; + try { + const resp = await fetch(file); + const svgText = await resp.text(); + const scaled = svgText.replace( + /]*)\bwidth="([^"]*)"([^>]*)\bheight="([^"]*)"/, + (_, before, w, mid, h) => + `((resolve) => { + img.onload = () => { URL.revokeObjectURL(url); imageCache.set(file, img); resolve(); }; + img.onerror = () => { URL.revokeObjectURL(url); resolve(); }; + img.src = url; + }); + } catch { + console.warn(`Failed to load symbol image: ${file}`); + } +} + export function preloadSymbolImages(): Promise { - const uniqueFiles = [...new Set(SYMBOLS.map(s => s.file)), EPC_ICON_FILE]; - return Promise.all( - uniqueFiles.map( - (file) => - new Promise(async (resolve) => { - if (imageCache.has(file)) { - resolve(); - return; - } - try { - const resp = await fetch(file); - const svgText = await resp.text(); - // Scale up width/height so the browser rasterizes at higher resolution - const scaled = svgText.replace( - /]*)\bwidth="([^"]*)"([^>]*)\bheight="([^"]*)"/, - (_, before, w, mid, h) => - ` { - URL.revokeObjectURL(url); - imageCache.set(file, img); - resolve(); - }; - img.onerror = () => { - URL.revokeObjectURL(url); - resolve(); - }; - img.src = url; - } catch { - resolve(); - } - }) - ) - ); + const uniqueFiles = [...new Set(SYMBOLS.map(s => s.file)), EPC_CONFIG.iconFile]; + return Promise.all(uniqueFiles.map(loadSvgImage)); } export function getSymbolImage(file: string): HTMLImageElement | undefined { @@ -168,11 +148,6 @@ export function isPhotoeyeType(symbolId: string): boolean { return symbolId === 'photoeye' || symbolId === 'photoeye_v'; } -// EPC box dimensions — derived from symbol-config -export const EPC_LEFT_BOX = EPC_CONFIG.leftBox; -export const EPC_RIGHT_BOX = EPC_CONFIG.rightBox; -export const EPC_LINE_WIDTH = EPC_CONFIG.lineWidth; -export const EPC_DEFAULT_WAYPOINTS = EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp })); export function isResizable(symbolId: string): boolean { return PRIORITY_TYPES.has(symbolId);