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:
parent
2ab5509607
commit
c5bb986a82
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
116
svelte-app/src/lib/canvas/hit-testing.ts
Normal file
116
svelte-app/src/lib/canvas/hit-testing.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
55
svelte-app/src/lib/canvas/render-theme.ts
Normal file
55
svelte-app/src/lib/canvas/render-theme.ts
Normal 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;
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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} />`);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user