diff --git a/svelte-app/src/components/DeviceDock.svelte b/svelte-app/src/components/DeviceDock.svelte
index 9cf4bf8..ca71ab4 100644
--- a/svelte-app/src/components/DeviceDock.svelte
+++ b/svelte-app/src/components/DeviceDock.svelte
@@ -1,7 +1,7 @@
@@ -71,42 +100,102 @@
{#if !collapsed}
-
{/if}
@@ -154,6 +243,7 @@
.dock-content {
padding: 6px;
+ padding-top: 38px;
overflow: hidden;
flex: 1;
display: flex;
@@ -161,16 +251,45 @@
min-height: 0;
}
+ /* Tabs */
+ .tabs {
+ display: flex;
+ gap: 2px;
+ flex-shrink: 0;
+ margin-bottom: 4px;
+ }
+
+ .tab {
+ flex: 1;
+ padding: 5px 0;
+ background: #0d2847;
+ border: 1px solid #1a1a5e;
+ border-radius: 4px 4px 0 0;
+ color: #667;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ cursor: pointer;
+ transition: all 0.15s;
+ }
+
+ .tab:hover {
+ color: #aaa;
+ }
+
+ .tab.active {
+ background: #0f3460;
+ color: #e94560;
+ border-bottom-color: #0f3460;
+ }
+
.dock-header {
font-size: 10px;
- color: #e94560;
- text-transform: uppercase;
- letter-spacing: 1px;
- font-weight: 600;
- padding: 5px 6px 6px 30px;
+ padding: 2px 6px 4px;
flex-shrink: 0;
display: flex;
- justify-content: space-between;
+ justify-content: flex-end;
align-items: center;
}
@@ -180,6 +299,57 @@
font-weight: 400;
}
+ /* Search */
+ .search-box {
+ position: relative;
+ flex-shrink: 0;
+ margin-bottom: 4px;
+ }
+
+ .search-box input {
+ width: 100%;
+ padding: 5px 22px 5px 8px;
+ background: #0f3460;
+ border: 1px solid #1a1a5e;
+ color: #e0e0e0;
+ border-radius: 4px;
+ font-size: 11px;
+ outline: none;
+ transition: border-color 0.15s;
+ box-sizing: border-box;
+ }
+
+ .search-box input:focus {
+ border-color: #e94560;
+ }
+
+ .search-box input::placeholder {
+ color: #556;
+ }
+
+ .clear-search {
+ position: absolute;
+ right: 3px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ background: none;
+ border: none;
+ color: #667;
+ font-size: 11px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 2px;
+ }
+
+ .clear-search:hover {
+ color: #e94560;
+ }
+
.dock-empty {
font-size: 11px;
color: #556;
@@ -286,4 +456,43 @@
overflow: hidden;
text-overflow: ellipsis;
}
+
+ /* IDs tab */
+ .ids-hint {
+ font-size: 9px;
+ color: #556;
+ padding: 0 6px 4px;
+ flex-shrink: 0;
+ }
+
+ .id-drag-item {
+ display: block;
+ width: 100%;
+ padding: 4px 8px;
+ margin-bottom: 1px;
+ background: #0f3460;
+ border: 1px solid #1a1a5e;
+ border-radius: 3px;
+ cursor: grab;
+ user-select: none;
+ transition: all 0.15s ease;
+ color: #e0e0e0;
+ font-size: 10px;
+ font-weight: 500;
+ text-align: left;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .id-drag-item:hover {
+ background: #1a1a5e;
+ border-color: #e94560;
+ transform: translateX(-2px);
+ }
+
+ .id-drag-item:active {
+ cursor: grabbing;
+ transform: scale(0.98);
+ }
diff --git a/svelte-app/src/lib/canvas/hit-testing.ts b/svelte-app/src/lib/canvas/hit-testing.ts
index 7f31ad8..ae41b50 100644
--- a/svelte-app/src/lib/canvas/hit-testing.ts
+++ b/svelte-app/src/lib/canvas/hit-testing.ts
@@ -4,15 +4,19 @@ import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, EPC_
import { THEME } from './render-theme.js';
import type { PlacedSymbol } from '../types.js';
-/** Transform a point into a symbol's unrotated local space */
+/** Transform a point into a symbol's unrotated, unmirrored local space */
export function toSymbolLocal(px: number, py: number, sym: PlacedSymbol): { x: number; y: number } {
- if (!sym.rotation) return { x: px, y: py };
+ if (!sym.rotation && !sym.mirrored) 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 rad = (-(sym.rotation || 0) * 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 };
+ // Inverse of render transform: un-rotate then un-mirror
+ let rx = dx * cos - dy * sin;
+ const ry = dx * sin + dy * cos;
+ if (sym.mirrored) rx = -rx;
+ return { x: rx + scx, y: ry + scy };
}
export function pointInRect(px: number, py: number, rx: number, ry: number, rw: number, rh: number): boolean {
@@ -56,11 +60,37 @@ export function hitEpcSegmentMidpoint(cx: number, cy: number, sym: PlacedSymbol)
return -1;
}
+/** Find nearest EPC segment to a point. Returns insert index or -1.
+ * Used for right-click add-waypoint — matches any point near a segment, not just midpoints. */
+export function hitEpcSegmentNearest(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 maxDist = 12;
+ let bestIdx = -1;
+ let bestDist = maxDist;
+ for (let i = 0; i < waypoints.length - 1; i++) {
+ const ax = sym.x + waypoints[i].x, ay = sym.y + waypoints[i].y;
+ const bx = sym.x + waypoints[i + 1].x, by = sym.y + waypoints[i + 1].y;
+ const dx = bx - ax, dy = by - ay;
+ const len2 = dx * dx + dy * dy;
+ if (len2 === 0) continue;
+ const t = Math.max(0, Math.min(1, ((lx - ax) * dx + (ly - ay) * dy) / len2));
+ const nx = ax + t * dx, ny = ay + t * dy;
+ const dist = Math.sqrt((lx - nx) ** 2 + (ly - ny) ** 2);
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestIdx = i + 1;
+ }
+ }
+ return bestIdx;
+}
+
/** 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 hs = THEME.resizeHandle.hitSize;
const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym);
if (isCurvedType(sym.symbolId)) {
diff --git a/svelte-app/src/lib/canvas/interactions.ts b/svelte-app/src/lib/canvas/interactions.ts
index f8ea1f7..6895ba0 100644
--- a/svelte-app/src/lib/canvas/interactions.ts
+++ b/svelte-app/src/lib/canvas/interactions.ts
@@ -3,7 +3,7 @@ import { isCurvedType, isEpcType, SYMBOLS, EPC_CONFIG } from '../symbols.js';
import type { EpcWaypoint } from '../types.js';
import { getAABB, snapToGrid, clamp, findValidPosition } from './collision.js';
import { orientedBoxCorners, pastDragThreshold } from './geometry.js';
-import { toSymbolLocal, pointInRect, pointInTrapezoid, hitEpcWaypoint, hitEpcSegmentMidpoint, hitResizeHandle, hitTestSymbols } from './hit-testing.js';
+import { toSymbolLocal, pointInRect, pointInTrapezoid, hitEpcWaypoint, hitEpcSegmentMidpoint, hitEpcSegmentNearest, hitResizeHandle, hitTestSymbols } from './hit-testing.js';
/** Ensure an EPC symbol has its waypoints array initialized */
function ensureEpcWaypoints(sym: { epcWaypoints?: import('../types.js').EpcWaypoint[] }) {
@@ -22,7 +22,7 @@ const CONVEYOR_MIN_W = 40;
const DRAG_THRESHOLD = 4; // pixels before drag actually starts
interface DragState {
- type: 'palette' | 'move' | 'multi-move' | 'resize-left' | 'resize-right' | 'resize-spur-top' | 'resize-spur-bottom' | 'epc-waypoint' | 'pdf';
+ type: 'palette' | 'move' | 'multi-move' | 'resize-left' | 'resize-right' | 'resize-spur-top' | 'resize-spur-bottom' | 'epc-waypoint' | 'pdf' | 'assign-label';
placedId?: number;
offsetX?: number;
offsetY?: number;
@@ -254,6 +254,31 @@ function onCanvasMousedown(e: MouseEvent) {
function onContextMenu(e: MouseEvent) {
e.preventDefault();
const pos = screenToCanvas(e.clientX, e.clientY);
+
+ // Right-click on selected EPC: add waypoint at nearest segment
+ if (layout.selectedIds.size === 1) {
+ const selId = [...layout.selectedIds][0];
+ const sel = layout.symbols.find(s => s.id === selId);
+ if (sel && isEpcType(sel.symbolId)) {
+ const insertIdx = hitEpcSegmentNearest(pos.x, pos.y, sel);
+ if (insertIdx >= 0) {
+ layout.pushUndo();
+ ensureEpcWaypoints(sel);
+ const prev = sel.epcWaypoints![insertIdx - 1];
+ const next = sel.epcWaypoints![insertIdx];
+ const local = toSymbolLocal(pos.x, pos.y, sel);
+ sel.epcWaypoints!.splice(insertIdx, 0, {
+ x: local.x - sel.x,
+ y: local.y - sel.y,
+ });
+ recalcEpcBounds(sel);
+ layout.markDirty();
+ layout.saveMcmState();
+ return;
+ }
+ }
+ }
+
const hitId = hitTest(pos.x, pos.y);
if (hitId !== null && contextMenuCallback) {
contextMenuCallback(e.clientX, e.clientY, hitId);
@@ -419,38 +444,14 @@ function onMousemove(e: MouseEvent) {
let localX = local.x - sym.x;
let localY = local.y - sym.y;
- // Snap to grid (in local coords, grid aligns with symbol origin in world)
+ // Snap to grid
if (layout.snapEnabled && !e.ctrlKey) {
- // Snap the world position, then convert back to local
const worldSnappedX = Math.round((sym.x + localX) / layout.gridSize) * layout.gridSize;
const worldSnappedY = Math.round((sym.y + localY) / layout.gridSize) * layout.gridSize;
localX = worldSnappedX - sym.x;
localY = worldSnappedY - sym.y;
}
- // Constrain to 0/45/90 degree angles relative to adjacent waypoint
- const anchor = wpIdx > 0 ? wps[wpIdx - 1] : (wpIdx < wps.length - 1 ? wps[wpIdx + 1] : null);
- if (anchor) {
- const dx = localX - anchor.x;
- const dy = localY - anchor.y;
- const dist = Math.sqrt(dx * dx + dy * dy);
- if (dist > 0.1) {
- // Snap angle to nearest 15-degree increment
- const rawAngle = Math.atan2(dy, dx);
- const snappedAngle = Math.round(rawAngle / (Math.PI / 12)) * (Math.PI / 12);
- localX = anchor.x + dist * Math.cos(snappedAngle);
- localY = anchor.y + dist * Math.sin(snappedAngle);
-
- // Re-snap to grid after angle constraint
- if (layout.snapEnabled && !e.ctrlKey) {
- const worldSnappedX = Math.round((sym.x + localX) / layout.gridSize) * layout.gridSize;
- const worldSnappedY = Math.round((sym.y + localY) / layout.gridSize) * layout.gridSize;
- localX = worldSnappedX - sym.x;
- localY = worldSnappedY - sym.y;
- }
- }
- }
-
wps[wpIdx] = { x: localX, y: localY };
// Recalculate bounding box
recalcEpcBounds(sym);
@@ -463,6 +464,17 @@ function onMousemove(e: MouseEvent) {
dragState.ghost.style.top = (e.clientY - sym.h / 2) + 'px';
}
+ if (dragState.type === 'assign-label' && dragState.ghost) {
+ dragState.ghost.style.left = (e.clientX + 8) + 'px';
+ dragState.ghost.style.top = (e.clientY - 10) + 'px';
+ const pos = screenToCanvas(e.clientX, e.clientY);
+ const hoverId = hitTest(pos.x, pos.y);
+ if (layout.labelDropTarget !== hoverId) {
+ layout.labelDropTarget = hoverId;
+ layout.markDirty();
+ }
+ }
+
if (dragState.type === 'move' && dragState.placedId != null) {
const pos = screenToCanvas(e.clientX, e.clientY);
if (!dragState.dragActivated) {
@@ -544,6 +556,24 @@ function onMouseup(e: MouseEvent) {
return;
}
+ if (dragState.type === 'assign-label' && dragState.ghost && dragState.deviceLabel) {
+ dragState.ghost.remove();
+ layout.labelDropTarget = null;
+ const pos = screenToCanvas(e.clientX, e.clientY);
+ const hitId = hitTest(pos.x, pos.y);
+ if (hitId !== null) {
+ const sym = layout.symbols.find(s => s.id === hitId);
+ if (sym) {
+ layout.pushUndo();
+ sym.label = dragState.deviceLabel;
+ layout.markDirty();
+ layout.saveMcmState();
+ }
+ }
+ dragState = null;
+ return;
+ }
+
if (dragState.type === 'palette' && dragState.ghost && dragState.symbolDef) {
dragState.ghost.remove();
const sym = dragState.symbolDef;
@@ -679,3 +709,27 @@ export function startPaletteDrag(e: MouseEvent, symbolDef: (typeof SYMBOLS)[numb
dragState = { type: 'palette', symbolDef, ghost, deviceLabel };
}
+
+// Called from DeviceDock IDs tab to drag a label onto a placed symbol
+export function startLabelDrag(e: MouseEvent, label: string) {
+ const ghost = document.createElement('div');
+ ghost.style.cssText = `
+ position: fixed;
+ pointer-events: none;
+ z-index: 9999;
+ background: #e94560;
+ color: #fff;
+ padding: 2px 8px;
+ border-radius: 3px;
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+ opacity: 0.85;
+ `;
+ ghost.textContent = label;
+ ghost.style.left = (e.clientX + 8) + 'px';
+ ghost.style.top = (e.clientY - 10) + 'px';
+ document.body.appendChild(ghost);
+
+ dragState = { type: 'assign-label', ghost, deviceLabel: label };
+}
diff --git a/svelte-app/src/lib/canvas/render-theme.ts b/svelte-app/src/lib/canvas/render-theme.ts
index f8c873d..b89c7d8 100644
--- a/svelte-app/src/lib/canvas/render-theme.ts
+++ b/svelte-app/src/lib/canvas/render-theme.ts
@@ -7,19 +7,29 @@ export const THEME = {
},
selection: {
strokeColor: '#00ff88',
- lineWidth: 2,
- shadowColor: 'rgba(0, 255, 136, 0.4)',
- shadowBlur: 6,
+ lineWidth: 0.5,
+ pad: 1,
+ shadowColor: 'rgba(0, 255, 136, 0.3)',
+ shadowBlur: 2,
},
collision: {
strokeColor: '#ff0000',
- lineWidth: 2,
- shadowColor: 'rgba(255, 0, 0, 0.6)',
- shadowBlur: 8,
+ lineWidth: 0.5,
+ pad: 1,
+ shadowColor: 'rgba(255, 0, 0, 0.4)',
+ shadowBlur: 3,
},
hover: {
- strokeColor: 'rgba(233, 69, 96, 0.3)',
- lineWidth: 1,
+ strokeColor: 'rgba(233, 69, 96, 0.2)',
+ lineWidth: 0.5,
+ },
+ dropTarget: {
+ strokeColor: '#00ccff',
+ lineWidth: 1.5,
+ pad: 3,
+ shadowColor: 'rgba(0, 204, 255, 0.5)',
+ shadowBlur: 6,
+ fillColor: 'rgba(0, 204, 255, 0.08)',
},
label: {
color: '#e94560',
@@ -27,10 +37,11 @@ export const THEME = {
offsetY: -3,
},
resizeHandle: {
- size: 10,
+ size: 6,
+ hitSize: 14,
fillColor: '#00ff88',
strokeColor: '#009955',
- lineWidth: 1,
+ lineWidth: 0.5,
},
epcWaypoint: {
size: 6,
diff --git a/svelte-app/src/lib/canvas/renderer.ts b/svelte-app/src/lib/canvas/renderer.ts
index 18b2a36..e263975 100644
--- a/svelte-app/src/lib/canvas/renderer.ts
+++ b/svelte-app/src/lib/canvas/renderer.ts
@@ -407,6 +407,20 @@ function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boole
drawInductionSymbol(ctx, sym);
} else if (isCurvedType(sym.symbolId)) {
drawCurvedSymbol(ctx, sym);
+ } else if (isSpurType(sym.symbolId)) {
+ // Draw trapezoid programmatically so w and w2 are respected during resize
+ const w2 = sym.w2 ?? sym.w;
+ ctx.beginPath();
+ ctx.moveTo(sym.x, sym.y);
+ ctx.lineTo(sym.x + w2, sym.y);
+ ctx.lineTo(sym.x + sym.w, sym.y + sym.h);
+ ctx.lineTo(sym.x, sym.y + sym.h);
+ ctx.closePath();
+ ctx.fillStyle = '#000000';
+ ctx.strokeStyle = '#000000';
+ ctx.lineWidth = 0.5;
+ ctx.fill();
+ ctx.stroke();
} else {
const img = getSymbolImage(sym.file);
if (!img) return false;
@@ -429,7 +443,7 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.lineWidth = THEME.selection.lineWidth;
ctx.shadowColor = THEME.selection.shadowColor;
ctx.shadowBlur = THEME.selection.shadowBlur;
- strokeOutline(ctx, sym, 2);
+ strokeOutline(ctx, sym, THEME.selection.pad);
ctx.shadowBlur = 0;
if (layout.selectedIds.size === 1) {
if (isEpcType(sym.symbolId)) {
@@ -446,12 +460,28 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.lineWidth = THEME.collision.lineWidth;
ctx.shadowColor = THEME.collision.shadowColor;
ctx.shadowBlur = THEME.collision.shadowBlur;
- strokeOutline(ctx, sym, 2);
+ strokeOutline(ctx, sym, THEME.collision.pad);
+ ctx.shadowBlur = 0;
+ }
+
+ // Label drop-target highlight
+ if (layout.labelDropTarget === sym.id) {
+ ctx.fillStyle = THEME.dropTarget.fillColor;
+ ctx.strokeStyle = THEME.dropTarget.strokeColor;
+ ctx.lineWidth = THEME.dropTarget.lineWidth;
+ ctx.shadowColor = THEME.dropTarget.shadowColor;
+ ctx.shadowBlur = THEME.dropTarget.shadowBlur;
+ // Fill + stroke the outline
+ ctx.beginPath();
+ ctx.rect(sym.x - THEME.dropTarget.pad, sym.y - THEME.dropTarget.pad,
+ sym.w + THEME.dropTarget.pad * 2, sym.h + THEME.dropTarget.pad * 2);
+ ctx.fill();
+ ctx.stroke();
ctx.shadowBlur = 0;
}
// Hover border (non-selected)
- if (!isSelected) {
+ if (!isSelected && layout.labelDropTarget !== sym.id) {
ctx.strokeStyle = THEME.hover.strokeColor;
ctx.lineWidth = THEME.hover.lineWidth;
strokeOutline(ctx, sym, 0);
diff --git a/svelte-app/src/lib/export.ts b/svelte-app/src/lib/export.ts
index 9e8f351..0a517a0 100644
--- a/svelte-app/src/lib/export.ts
+++ b/svelte-app/src/lib/export.ts
@@ -1,5 +1,5 @@
import { layout } from './stores/layout.svelte.js';
-import { isEpcType, isInductionType, EPC_CONFIG, INDUCTION_CONFIG } from './symbols.js';
+import { isEpcType, isInductionType, isSpurType, isCurvedType, EPC_CONFIG, INDUCTION_CONFIG, getCurveGeometry } from './symbols.js';
import { deserializeSymbol } from './serialization.js';
import type { PlacedSymbol } from './types.js';
@@ -112,6 +112,32 @@ export async function exportSVG() {
lines.push(`
`);
lines.push(` `);
lines.push(' ');
+ } else if (isCurvedType(sym.symbolId)) {
+ const angle = sym.curveAngle || 90;
+ const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
+ const sweepRad = (angle * Math.PI) / 180;
+ // Outer arc: from angle=0 to angle=-sweep (CCW in SVG = large-arc with sweep-flag 0)
+ const outerEndX = arcCx + outerR * Math.cos(sweepRad);
+ const outerEndY = arcCy - outerR * Math.sin(sweepRad);
+ const innerEndX = arcCx + innerR * Math.cos(sweepRad);
+ const innerEndY = arcCy - innerR * Math.sin(sweepRad);
+ const largeArc = angle > 180 ? 1 : 0;
+ const d = [
+ `M ${arcCx + outerR},${arcCy}`,
+ `A ${outerR},${outerR} 0 ${largeArc},0 ${outerEndX},${outerEndY}`,
+ `L ${innerEndX},${innerEndY}`,
+ `A ${innerR},${innerR} 0 ${largeArc},1 ${arcCx + innerR},${arcCy}`,
+ 'Z',
+ ].join(' ');
+ lines.push(`
`);
+ lines.push(` `);
+ lines.push(' ');
+ } else if (isSpurType(sym.symbolId)) {
+ const w2 = sym.w2 ?? sym.w;
+ const points = `${sym.x},${sym.y} ${sym.x + w2},${sym.y} ${sym.x + sym.w},${sym.y + sym.h} ${sym.x},${sym.y + sym.h}`;
+ lines.push(`
`);
+ lines.push(` `);
+ lines.push(' ');
} else {
try {
const svgText = await (await fetch(sym.file)).text();
diff --git a/svelte-app/src/lib/stores/layout.svelte.ts b/svelte-app/src/lib/stores/layout.svelte.ts
index 9bfbea6..2352bed 100644
--- a/svelte-app/src/lib/stores/layout.svelte.ts
+++ b/svelte-app/src/lib/stores/layout.svelte.ts
@@ -32,6 +32,9 @@ class LayoutStore {
pdfLoaded = $state(false);
editingBackground = $state(false);
+ // Label-drag drop target highlight (symbol id or null)
+ labelDropTarget = $state
(null);
+
// Dirty flag for canvas re-render
dirty = $state(0);