Refactor: extract render theme, hit-testing module, clean up legacy exports

- Extract 25+ hardcoded colors/sizes to render-theme.ts
- Extract pure hit-testing functions to hit-testing.ts (-104 lines from interactions.ts)
- Remove 11 legacy re-exports from symbols.ts, use config objects directly
- Fix preloadSymbolImages async/await anti-pattern
- Extract ensureEpcWaypoints() helper (3x dedup)
- Fix PDF not clearing on MCM switch
- Fix MCM persistence on reload
- Change defaults: grid off, grid size 2, min spacing 2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
igurielidze 2026-03-20 19:03:38 +04:00
parent 2ab5509607
commit c5bb986a82
9 changed files with 293 additions and 244 deletions

View File

@ -28,9 +28,11 @@
layout.loadMcmState(); layout.loadMcmState();
} else { } else {
layout.currentProject = savedProject || 'default'; layout.currentProject = savedProject || 'default';
layout.currentMcm = savedMcm || 'MCM08'; layout.currentMcm = savedMcm || 'MCM01';
layout.loadMcmState(); layout.loadMcmState();
} }
localStorage.setItem('scada_lastProject', layout.currentProject);
localStorage.setItem('scada_lastMcm', layout.currentMcm);
await restorePdf(); await restorePdf();
}); });
@ -58,9 +60,9 @@
async function autoLoadPdf() { async function autoLoadPdf() {
const proj = layout.projects.find(p => p.name === layout.currentProject); 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); const mcm = proj.mcms.find(m => m.name === layout.currentMcm);
if (!mcm?.pdfPath) return; if (!mcm?.pdfPath) { removePdf(); return; }
await loadPdfFromPath(mcm.pdfPath); await loadPdfFromPath(mcm.pdfPath);
} }

View File

@ -1,5 +1,5 @@
import type { AABB, EpcWaypoint } from '../types.js'; 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 { layout } from '../stores/layout.svelte.js';
import { orientedBoxCorners, createCurveTransforms } from './geometry.js'; import { orientedBoxCorners, createCurveTransforms } from './geometry.js';
@ -452,7 +452,7 @@ function getEpcCollisionQuads(
const ox = symX, oy = symY; const ox = symX, oy = symY;
// Line segment quads (use half-width of at least 1px for collision) // 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++) { for (let i = 0; i < waypoints.length - 1; i++) {
const q = segmentQuad( const q = segmentQuad(
ox + waypoints[i].x, oy + waypoints[i].y, ox + waypoints[i].x, oy + waypoints[i].y,
@ -467,7 +467,7 @@ function getEpcCollisionQuads(
const lbQ = boxQuad( const lbQ = boxQuad(
ox + p0.x, oy + p0.y, ox + p0.x, oy + p0.y,
p1.x - p0.x, p1.y - 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' 'right'
); );
if (lbQ.length === 4) quads.push(lbQ); if (lbQ.length === 4) quads.push(lbQ);
@ -478,7 +478,7 @@ function getEpcCollisionQuads(
const rbQ = boxQuad( const rbQ = boxQuad(
ox + last.x, oy + last.y, ox + last.x, oy + last.y,
last.x - prev.x, last.y - prev.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' 'left'
); );
if (rbQ.length === 4) quads.push(rbQ); 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) */ /** Look up EPC waypoints for a symbol (from layout store or defaults) */
function getSymWaypoints(id: number): EpcWaypoint[] { function getSymWaypoints(id: number): EpcWaypoint[] {
const sym = layout.symbols.find(s => s.id === id); 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 ─── // ─── Main spacing violation check ───
@ -562,7 +562,7 @@ export function checkSpacingViolation(
// Get EPC quads for "other" symbol if it's EPC // Get EPC quads for "other" symbol if it's EPC
let symEpcQuads: [number, number][][] | null = null; let symEpcQuads: [number, number][][] | null = null;
if (symIsEpc) { 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); symEpcQuads = getEpcCollisionQuads(sym.x, sym.y, sym.w, sym.h, sym.rotation, symWps);
} }

View File

@ -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;
}

View File

@ -1,8 +1,17 @@
import { layout } from '../stores/layout.svelte.js'; 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 type { EpcWaypoint } from '../types.js';
import { getAABB, snapToGrid, clamp, findValidPosition } from './collision.js'; import { getAABB, snapToGrid, clamp, findValidPosition } from './collision.js';
import { orientedBoxCorners, pastDragThreshold } from './geometry.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_MIN = 0.1;
const ZOOM_MAX = 5; 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 { function hitTest(cx: number, cy: number): number | null {
for (let i = layout.symbols.length - 1; i >= 0; i--) { return hitTestSymbols(cx, cy, layout.symbols);
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;
} }
function onCanvasMousedown(e: MouseEvent) { function onCanvasMousedown(e: MouseEvent) {
@ -220,11 +122,11 @@ function onCanvasMousedown(e: MouseEvent) {
// Double-click on existing waypoint to remove it (if >2 waypoints) // Double-click on existing waypoint to remove it (if >2 waypoints)
if (e.detail === 2) { if (e.detail === 2) {
const wpIdx = hitEpcWaypoint(pos.x, pos.y, sel); 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) { if (wpIdx >= 0 && wps.length > 2) {
layout.pushUndo(); layout.pushUndo();
if (!sel.epcWaypoints) { if (!sel.epcWaypoints) {
sel.epcWaypoints = EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp })); sel.epcWaypoints = EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp }));
} }
sel.epcWaypoints.splice(wpIdx, 1); sel.epcWaypoints.splice(wpIdx, 1);
recalcEpcBounds(sel); recalcEpcBounds(sel);
@ -237,7 +139,7 @@ function onCanvasMousedown(e: MouseEvent) {
if (insertIdx >= 0) { if (insertIdx >= 0) {
layout.pushUndo(); layout.pushUndo();
if (!sel.epcWaypoints) { 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 prev = sel.epcWaypoints[insertIdx - 1];
const next = sel.epcWaypoints[insertIdx]; const next = sel.epcWaypoints[insertIdx];
@ -254,9 +156,7 @@ function onCanvasMousedown(e: MouseEvent) {
const wpIdx = hitEpcWaypoint(pos.x, pos.y, sel); const wpIdx = hitEpcWaypoint(pos.x, pos.y, sel);
if (wpIdx >= 0) { if (wpIdx >= 0) {
layout.pushUndo(); layout.pushUndo();
if (!sel.epcWaypoints) { ensureEpcWaypoints(sel);
sel.epcWaypoints = EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp }));
}
dragState = { dragState = {
type: 'epc-waypoint', type: 'epc-waypoint',
placedId: sel.id, placedId: sel.id,
@ -268,7 +168,7 @@ function onCanvasMousedown(e: MouseEvent) {
return; return;
} }
} }
if (sel) { if (sel && layout.selectedIds.size === 1) {
const handle = hitResizeHandle(pos.x, pos.y, sel); const handle = hitResizeHandle(pos.x, pos.y, sel);
if (handle) { if (handle) {
layout.pushUndo(); layout.pushUndo();
@ -409,7 +309,7 @@ function recalcEpcBounds(sym: typeof layout.symbols[0]) {
// Left box corners (oriented along first segment) // Left box corners (oriented along first segment)
const lbDir = { x: wps[1].x - wps[0].x, y: wps[1].y - wps[0].y }; 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) { for (const [bx, by] of lbCorners) {
minX = Math.min(minX, bx); minY = Math.min(minY, by); minX = Math.min(minX, bx); minY = Math.min(minY, by);
maxX = Math.max(maxX, bx); maxY = Math.max(maxY, 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 last = wps[wps.length - 1];
const prev = wps[wps.length - 2]; const prev = wps[wps.length - 2];
const rbDir = { x: last.x - prev.x, y: last.y - prev.y }; 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) { for (const [bx, by] of rbCorners) {
minX = Math.min(minX, bx); minY = Math.min(minY, by); minX = Math.min(minX, bx); minY = Math.min(minY, by);
maxX = Math.max(maxX, bx); maxY = Math.max(maxY, by); maxX = Math.max(maxX, bx); maxY = Math.max(maxY, by);
@ -668,7 +568,7 @@ function onMouseup(e: MouseEvent) {
w2: sym.w2, w2: sym.w2,
rotation: rot, rotation: rot,
curveAngle: sym.curveAngle, 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, pdpCBs: undefined,
}); });
} }

View File

@ -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;

View File

@ -1,6 +1,7 @@
import { layout } from '../stores/layout.svelte.js'; 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 { checkSpacingViolation } from './collision.js';
import { THEME } from './render-theme.js';
import type { PlacedSymbol } from '../types.js'; import type { PlacedSymbol } from '../types.js';
let ctx: CanvasRenderingContext2D | null = null; let ctx: CanvasRenderingContext2D | null = null;
@ -8,7 +9,7 @@ let canvas: HTMLCanvasElement | null = null;
let animFrameId: number | null = null; let animFrameId: number | null = null;
let lastDirty = -1; let lastDirty = -1;
const MAX_RENDER_SCALE = 4; const MAX_RENDER_SCALE = THEME.canvas.maxRenderScale;
let currentRenderScale = 0; let currentRenderScale = 0;
let lastCanvasW = 0; let lastCanvasW = 0;
let lastCanvasH = 0; let lastCanvasH = 0;
@ -80,8 +81,8 @@ export function render() {
function drawGrid(ctx: CanvasRenderingContext2D) { function drawGrid(ctx: CanvasRenderingContext2D) {
const size = layout.gridSize; const size = layout.gridSize;
ctx.strokeStyle = '#333'; ctx.strokeStyle = THEME.grid.color;
ctx.lineWidth = 0.5; ctx.lineWidth = THEME.grid.lineWidth;
ctx.beginPath(); ctx.beginPath();
for (let x = 0; x <= layout.canvasW; x += size) { for (let x = 0; x <= layout.canvasW; x += size) {
ctx.moveTo(x, 0); 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. /** 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. */ * Both boxes auto-orient along the line direction at their respective ends. */
function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { 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 ox = sym.x; // origin x
const oy = sym.y; // origin y const oy = sym.y; // origin y
@ -137,19 +138,19 @@ function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
for (let i = 1; i < waypoints.length; i++) { for (let i = 1; i < waypoints.length; i++) {
ctx.lineTo(ox + waypoints[i].x, oy + waypoints[i].y); ctx.lineTo(ox + waypoints[i].x, oy + waypoints[i].y);
} }
ctx.strokeStyle = '#000000'; ctx.strokeStyle = THEME.epcBody.lineColor;
ctx.lineWidth = EPC_LINE_WIDTH; ctx.lineWidth = EPC_CONFIG.lineWidth;
ctx.stroke(); ctx.stroke();
} }
// --- Left icon: use actual SVG image, oriented along first segment --- // --- Left icon: use actual SVG image, oriented along first segment ---
if (waypoints.length >= 2) { 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 p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y;
const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y; const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y;
const angle = Math.atan2(p1y - p0y, p1x - p0x); const angle = Math.atan2(p1y - p0y, p1x - p0x);
const iconImg = getSymbolImage(EPC_ICON_FILE); const iconImg = getSymbolImage(EPC_CONFIG.iconFile);
if (iconImg) { if (iconImg) {
ctx.save(); ctx.save();
ctx.translate(p0x, p0y); ctx.translate(p0x, p0y);
@ -170,10 +171,10 @@ function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.save(); ctx.save();
ctx.translate(plx, ply); ctx.translate(plx, ply);
ctx.rotate(angle); ctx.rotate(angle);
const rb = EPC_RIGHT_BOX; const rb = EPC_CONFIG.rightBox;
ctx.fillStyle = '#aaaaaa'; ctx.fillStyle = THEME.epcBody.rightBoxFill;
ctx.strokeStyle = '#000000'; ctx.strokeStyle = THEME.epcBody.rightBoxStroke;
ctx.lineWidth = 0.3; ctx.lineWidth = THEME.epcBody.rightBoxStrokeWidth;
ctx.fillRect(0, -rb.h / 2, rb.w, rb.h); ctx.fillRect(0, -rb.h / 2, rb.w, rb.h);
ctx.strokeRect(0, -rb.h / 2, rb.w, rb.h); ctx.strokeRect(0, -rb.h / 2, rb.w, rb.h);
ctx.restore(); ctx.restore();
@ -182,11 +183,11 @@ function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
/** Draw EPC waypoint handles when selected */ /** Draw EPC waypoint handles when selected */
function drawEpcWaypointHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { function drawEpcWaypointHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS; const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints;
const hs = 6; const hs = THEME.epcWaypoint.size;
ctx.fillStyle = '#00ccff'; ctx.fillStyle = THEME.epcWaypoint.fillColor;
ctx.strokeStyle = '#0088aa'; ctx.strokeStyle = THEME.epcWaypoint.strokeColor;
ctx.lineWidth = 1; ctx.lineWidth = THEME.epcWaypoint.lineWidth;
for (const wp of waypoints) { for (const wp of waypoints) {
const hx = sym.x + wp.x; 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 // Draw "+" at midpoints of segments to hint add-waypoint
if (waypoints.length >= 2) { if (waypoints.length >= 2) {
ctx.fillStyle = '#00ccff'; ctx.fillStyle = THEME.epcWaypoint.fillColor;
ctx.font = '6px sans-serif'; ctx.font = THEME.epcWaypoint.hintFont;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
for (let i = 0; i < waypoints.length - 1; i++) { for (let i = 0; i < waypoints.length - 1; i++) {
const mx = sym.x + (waypoints[i].x + waypoints[i + 1].x) / 2; 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 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 */ /** Trace the EPC outline path: left box + segments + right box */
function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { 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; if (waypoints.length < 2) return;
const ox = sym.x, oy = sym.y; 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 p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y;
const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y; const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y;
const lAngle = Math.atan2(p1y - p0y, p1x - p0x); const lAngle = Math.atan2(p1y - p0y, p1x - p0x);
const lb = EPC_LEFT_BOX; const lb = EPC_CONFIG.leftBox;
ctx.save(); ctx.save();
ctx.translate(p0x, p0y); ctx.translate(p0x, p0y);
@ -241,7 +242,7 @@ function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, p
ctx.translate(ax, ay); ctx.translate(ax, ay);
ctx.rotate(segAngle); ctx.rotate(segAngle);
ctx.beginPath(); 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.stroke();
ctx.restore(); ctx.restore();
} }
@ -252,7 +253,7 @@ function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, p
const plx = ox + last.x, ply = oy + last.y; const plx = ox + last.x, ply = oy + last.y;
const ppx = ox + prev.x, ppy = oy + prev.y; const ppx = ox + prev.x, ppy = oy + prev.y;
const rAngle = Math.atan2(ply - ppy, plx - ppx); const rAngle = Math.atan2(ply - ppy, plx - ppx);
const rb = EPC_RIGHT_BOX; const rb = EPC_CONFIG.rightBox;
ctx.save(); ctx.save();
ctx.translate(plx, ply); ctx.translate(plx, ply);
@ -265,10 +266,10 @@ function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, p
/** Trace the induction outline path (arrow head + strip) */ /** Trace the induction outline path (arrow head + strip) */
function traceInductionPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { function traceInductionPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) {
const hw = INDUCTION_HEAD_W; const hw = INDUCTION_CONFIG.headWidth;
const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC; const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
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(); ctx.beginPath();
ctx.moveTo(sym.x + sym.w + pad, stripTopY - pad); 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) { function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
if (!isResizable(sym.symbolId)) return; if (!isResizable(sym.symbolId)) return;
const hs = 10; const hs = THEME.resizeHandle.size;
ctx.fillStyle = '#00ff88'; ctx.fillStyle = THEME.resizeHandle.fillColor;
ctx.strokeStyle = '#009955'; ctx.strokeStyle = THEME.resizeHandle.strokeColor;
ctx.lineWidth = 1; ctx.lineWidth = THEME.resizeHandle.lineWidth;
if (isCurvedType(sym.symbolId)) { if (isCurvedType(sym.symbolId)) {
const arcAngle = sym.curveAngle || 90; 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); drawHandle(ctx, sym.x + sym.w, sym.y + sym.h, hs);
} else if (isInductionType(sym.symbolId)) { } else if (isInductionType(sym.symbolId)) {
// Only right handle — arrow head is fixed width // Only right handle — arrow head is fixed width
const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC; const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
const stripMidY = (stripTopY + stripBottomY) / 2; const stripMidY = (stripTopY + stripBottomY) / 2;
drawHandle(ctx, sym.x + sym.w, stripMidY, hs); drawHandle(ctx, sym.x + sym.w, stripMidY, hs);
} else { } else {
@ -345,12 +346,12 @@ function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
/** Draw induction programmatically: fixed arrow head + variable strip, as one path */ /** Draw induction programmatically: fixed arrow head + variable strip, as one path */
function drawInductionSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { function drawInductionSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const hw = INDUCTION_HEAD_W; const hw = INDUCTION_CONFIG.headWidth;
const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC; const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
// Arrow points in display coords // 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(); ctx.beginPath();
// Top-right of strip // Top-right of strip
@ -367,7 +368,7 @@ function drawInductionSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.lineTo(sym.x + sym.w, stripBottomY); ctx.lineTo(sym.x + sym.w, stripBottomY);
ctx.closePath(); ctx.closePath();
ctx.fillStyle = '#000000'; ctx.fillStyle = THEME.induction.fillColor;
ctx.fill(); ctx.fill();
} }
@ -413,10 +414,10 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
// Selection highlight // Selection highlight
if (isSelected) { if (isSelected) {
ctx.strokeStyle = '#00ff88'; ctx.strokeStyle = THEME.selection.strokeColor;
ctx.lineWidth = 2; ctx.lineWidth = THEME.selection.lineWidth;
ctx.shadowColor = 'rgba(0, 255, 136, 0.4)'; ctx.shadowColor = THEME.selection.shadowColor;
ctx.shadowBlur = 6; ctx.shadowBlur = THEME.selection.shadowBlur;
strokeOutline(ctx, sym, 2); strokeOutline(ctx, sym, 2);
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
if (layout.selectedIds.size === 1) { if (layout.selectedIds.size === 1) {
@ -430,28 +431,28 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
// Collision highlight // Collision highlight
if (checkSpacingViolation(sym.id, sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.curveAngle, sym.w2)) { if (checkSpacingViolation(sym.id, sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.curveAngle, sym.w2)) {
ctx.strokeStyle = '#ff0000'; ctx.strokeStyle = THEME.collision.strokeColor;
ctx.lineWidth = 2; ctx.lineWidth = THEME.collision.lineWidth;
ctx.shadowColor = 'rgba(255, 0, 0, 0.6)'; ctx.shadowColor = THEME.collision.shadowColor;
ctx.shadowBlur = 8; ctx.shadowBlur = THEME.collision.shadowBlur;
strokeOutline(ctx, sym, 2); strokeOutline(ctx, sym, 2);
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
} }
// Hover border (non-selected) // Hover border (non-selected)
if (!isSelected) { if (!isSelected) {
ctx.strokeStyle = 'rgba(233, 69, 96, 0.3)'; ctx.strokeStyle = THEME.hover.strokeColor;
ctx.lineWidth = 1; ctx.lineWidth = THEME.hover.lineWidth;
strokeOutline(ctx, sym, 0); strokeOutline(ctx, sym, 0);
} }
// Label above symbol // Label above symbol
if (sym.label) { if (sym.label) {
ctx.fillStyle = '#e94560'; ctx.fillStyle = THEME.label.color;
ctx.font = '10px sans-serif'; ctx.font = THEME.label.font;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'bottom';
ctx.fillText(sym.label, cx, sym.y - 3); ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY);
} }
} }

View File

@ -1,5 +1,5 @@
import { layout } from './stores/layout.svelte.js'; 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 { deserializeSymbol } from './serialization.js';
import type { PlacedSymbol } from './types.js'; import type { PlacedSymbol } from './types.js';
@ -13,7 +13,7 @@ function downloadBlob(blob: Blob, filename: string) {
} }
async function buildEpcSvgElements(sym: PlacedSymbol): Promise<string> { async function buildEpcSvgElements(sym: PlacedSymbol): Promise<string> {
const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS; const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints;
const ox = sym.x; const ox = sym.x;
const oy = sym.y; const oy = sym.y;
const parts: string[] = []; const parts: string[] = [];
@ -21,18 +21,18 @@ async function buildEpcSvgElements(sym: PlacedSymbol): Promise<string> {
// Polyline // Polyline
if (waypoints.length >= 2) { if (waypoints.length >= 2) {
const points = waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' '); const points = waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' ');
parts.push(` <polyline points="${points}" fill="none" stroke="#000000" stroke-width="${EPC_LINE_WIDTH}" />`); parts.push(` <polyline points="${points}" fill="none" stroke="#000000" stroke-width="${EPC_CONFIG.lineWidth}" />`);
} }
if (waypoints.length >= 2) { if (waypoints.length >= 2) {
// Left icon — embed actual SVG, oriented along first segment // 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 p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y;
const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y; const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y;
const lAngle = Math.atan2(p1y - p0y, p1x - p0x) * 180 / Math.PI; const lAngle = Math.atan2(p1y - p0y, p1x - p0x) * 180 / Math.PI;
try { 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 doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
const svgEl = doc.documentElement; const svgEl = doc.documentElement;
const vb = svgEl.getAttribute('viewBox'); const vb = svgEl.getAttribute('viewBox');
@ -54,7 +54,7 @@ async function buildEpcSvgElements(sym: PlacedSymbol): Promise<string> {
const plx = ox + last.x, ply = oy + last.y; const plx = ox + last.x, ply = oy + last.y;
const ppx = ox + prev.x, ppy = oy + prev.y; const ppx = ox + prev.x, ppy = oy + prev.y;
const rAngle = Math.atan2(ply - ppy, plx - ppx) * 180 / Math.PI; const rAngle = Math.atan2(ply - ppy, plx - ppx) * 180 / Math.PI;
const rb = EPC_RIGHT_BOX; const rb = EPC_CONFIG.rightBox;
parts.push(` <rect x="0" y="${-rb.h / 2}" width="${rb.w}" height="${rb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})" />`); parts.push(` <rect x="0" y="${-rb.h / 2}" width="${rb.w}" height="${rb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})" />`);
} }
@ -83,10 +83,10 @@ export async function exportSVG() {
lines.push(await buildEpcSvgElements(sym as PlacedSymbol)); lines.push(await buildEpcSvgElements(sym as PlacedSymbol));
lines.push(' </g>'); lines.push(' </g>');
} else if (isInductionType(sym.symbolId)) { } else if (isInductionType(sym.symbolId)) {
const hw = INDUCTION_HEAD_W; const hw = INDUCTION_CONFIG.headWidth;
const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC; const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
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);
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`; 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(` <g${idAttr}>`); lines.push(` <g${idAttr}>`);
lines.push(` <path d="${d}" fill="#000000"${rotAttr} />`); lines.push(` <path d="${d}" fill="#000000"${rotAttr} />`);

View File

@ -10,10 +10,10 @@ class LayoutStore {
symbols = $state<PlacedSymbol[]>([]); symbols = $state<PlacedSymbol[]>([]);
nextId = $state(1); nextId = $state(1);
selectedIds = $state<Set<number>>(new Set()); selectedIds = $state<Set<number>>(new Set());
gridSize = $state(20); gridSize = $state(2);
minSpacing = $state(10); minSpacing = $state(2);
snapEnabled = $state(true); snapEnabled = $state(true);
showGrid = $state(true); showGrid = $state(false);
zoomLevel = $state(1); zoomLevel = $state(1);
panX = $state(0); panX = $state(0);
panY = $state(0); panY = $state(0);

View File

@ -87,14 +87,6 @@ export const SPACING_EXEMPT = new Set([
'photoeye', 'photoeye_v', '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 { export function getCurveBandWidth(symbolId: string): number {
if (symbolId.startsWith('curved_chute')) return CURVE_CONFIG.chuteBand; if (symbolId.startsWith('curved_chute')) return CURVE_CONFIG.chuteBand;
@ -104,44 +96,32 @@ export function getCurveBandWidth(symbolId: string): number {
const imageCache = new Map<string, HTMLImageElement>(); const imageCache = new Map<string, HTMLImageElement>();
const SVG_SCALE = 10; // Rasterize SVGs at 10x for crisp canvas rendering const SVG_SCALE = 10; // Rasterize SVGs at 10x for crisp canvas rendering
async function loadSvgImage(file: string): Promise<void> {
if (imageCache.has(file)) return;
try {
const resp = await fetch(file);
const svgText = await resp.text();
const scaled = svgText.replace(
/<svg([^>]*)\bwidth="([^"]*)"([^>]*)\bheight="([^"]*)"/,
(_, before, w, mid, h) =>
`<svg${before}width="${parseFloat(w) * SVG_SCALE}"${mid}height="${parseFloat(h) * SVG_SCALE}"`
);
const blob = new Blob([scaled], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
await new Promise<void>((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<void[]> { export function preloadSymbolImages(): Promise<void[]> {
const uniqueFiles = [...new Set(SYMBOLS.map(s => s.file)), EPC_ICON_FILE]; const uniqueFiles = [...new Set(SYMBOLS.map(s => s.file)), EPC_CONFIG.iconFile];
return Promise.all( return Promise.all(uniqueFiles.map(loadSvgImage));
uniqueFiles.map(
(file) =>
new Promise<void>(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(
/<svg([^>]*)\bwidth="([^"]*)"([^>]*)\bheight="([^"]*)"/,
(_, before, w, mid, h) =>
`<svg${before}width="${parseFloat(w) * SVG_SCALE}"${mid}height="${parseFloat(h) * SVG_SCALE}"`
);
const blob = new Blob([scaled], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
imageCache.set(file, img);
resolve();
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve();
};
img.src = url;
} catch {
resolve();
}
})
)
);
} }
export function getSymbolImage(file: string): HTMLImageElement | undefined { export function getSymbolImage(file: string): HTMLImageElement | undefined {
@ -168,11 +148,6 @@ export function isPhotoeyeType(symbolId: string): boolean {
return symbolId === 'photoeye' || symbolId === 'photoeye_v'; 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 { export function isResizable(symbolId: string): boolean {
return PRIORITY_TYPES.has(symbolId); return PRIORITY_TYPES.has(symbolId);