diff --git a/svelte-app/src/lib/canvas/hit-testing.ts b/svelte-app/src/lib/canvas/hit-testing.ts index 9751426..fc96f20 100644 --- a/svelte-app/src/lib/canvas/hit-testing.ts +++ b/svelte-app/src/lib/canvas/hit-testing.ts @@ -1,6 +1,6 @@ /** Pure hit-testing functions — no module state, no side effects */ -import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, getSymbolGroup, EPC_CONFIG, INDUCTION_CONFIG, getCurveGeometry } from '../symbols.js'; +import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, getSymbolGroup, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG, getCurveGeometry } from '../symbols.js'; import { layout } from '../stores/layout.svelte.js'; import { THEME } from './render-theme.js'; import type { PlacedSymbol } from '../types.js'; @@ -170,7 +170,7 @@ function pointInEpc(px: number, py: number, sym: PlacedSymbol): boolean { const last = waypoints[waypoints.length - 1]; const prev = waypoints[waypoints.length - 2]; const plx = ox + last.x, ply = oy + last.y; - const angle = Math.atan2(ply - (oy + prev.y), plx - (ox + prev.x)) - Math.PI / 2; + const angle = Math.atan2(ply - (oy + prev.y), plx - (ox + prev.x)); const cos = Math.cos(-angle), sin = Math.sin(-angle); const dx = px - plx, dy = py - ply; const lx = dx * cos - dy * sin, ly = dx * sin + dy * cos; @@ -193,12 +193,28 @@ function pointInEpc(px: number, py: number, sym: PlacedSymbol): boolean { return false; } +/** Check if a point is inside the photoeye shape (arrow + beam + receiver) with margin */ +function pointInPhotoeye(px: number, py: number, sym: PlacedSymbol): boolean { + const { leftCap, rightCap } = PHOTOEYE_CONFIG; + const x = sym.x, y = sym.y, w = sym.w, h = sym.h; + const m = 4; // hit margin + + // Arrow (left transmitter) + if (px >= x - m && px <= x + leftCap + m && py >= y + h * 0.05 - m && py <= y + h * 0.948 + m) return true; + // Beam (middle) + if (px >= x + leftCap - m && px <= x + w - rightCap + m && py >= y + h * 0.42 - m && py <= y + h * 0.585 + m) return true; + // Receiver (right cap) + if (px >= x + w - rightCap - m && px <= x + w + m && py >= y + h * 0.181 - m && py <= y + h * 0.826 + m) return true; + return false; +} + /** Hit test a single symbol against its actual shape */ function pointInSymbol(px: number, py: number, sym: PlacedSymbol): boolean { if (isCurvedType(sym.symbolId)) return pointInArcBand(px, py, sym); if (isSpurType(sym.symbolId)) return pointInTrapezoid(px, py, sym); if (isInductionType(sym.symbolId)) return pointInInduction(px, py, sym); if (isEpcType(sym.symbolId)) return pointInEpc(px, py, sym); + if (isPhotoeyeType(sym.symbolId)) return pointInPhotoeye(px, py, sym); return pointInRect(px, py, sym.x, sym.y, sym.w, sym.h); } diff --git a/svelte-app/src/lib/canvas/interactions.ts b/svelte-app/src/lib/canvas/interactions.ts index b2c1fe1..25f5f2d 100644 --- a/svelte-app/src/lib/canvas/interactions.ts +++ b/svelte-app/src/lib/canvas/interactions.ts @@ -510,12 +510,16 @@ function onMousemove(e: MouseEvent) { if (!pastDragThreshold(pos.x, pos.y, dragState.startX!, dragState.startY!, DRAG_THRESHOLD)) return; dragState.dragActivated = true; } - // Shift: constrain to orthogonal axis — lock once, don't re-evaluate + // Shift: constrain to orthogonal axis — lock after enough movement if (e.shiftKey) { - if (!dragState.shiftAxis) { - dragState.shiftAxis = Math.abs(pos.x - dragState.startX!) >= Math.abs(pos.y - dragState.startY!) ? 'h' : 'v'; + const dx = Math.abs(pos.x - dragState.startX!); + const dy = Math.abs(pos.y - dragState.startY!); + if (!dragState.shiftAxis && Math.max(dx, dy) >= 8) { + dragState.shiftAxis = dx > dy ? 'h' : 'v'; } if (dragState.shiftAxis === 'h') pos.y = dragState.startY!; + else if (dragState.shiftAxis === 'v') pos.x = dragState.startX!; + else if (dx > dy) pos.y = dragState.startY!; else pos.x = dragState.startX!; } else { dragState.shiftAxis = undefined; @@ -538,12 +542,16 @@ function onMousemove(e: MouseEvent) { if (!pastDragThreshold(pos.x, pos.y, dragState.startX!, dragState.startY!, DRAG_THRESHOLD)) return; dragState.dragActivated = true; } - // Shift: constrain to orthogonal axis — lock once, don't re-evaluate + // Shift: constrain to orthogonal axis — lock after enough movement if (e.shiftKey) { - if (!dragState.shiftAxis) { - dragState.shiftAxis = Math.abs(pos.x - dragState.startX!) >= Math.abs(pos.y - dragState.startY!) ? 'h' : 'v'; + const dx = Math.abs(pos.x - dragState.startX!); + const dy = Math.abs(pos.y - dragState.startY!); + if (!dragState.shiftAxis && Math.max(dx, dy) >= 8) { + dragState.shiftAxis = dx > dy ? 'h' : 'v'; } if (dragState.shiftAxis === 'h') pos.y = dragState.startY!; + else if (dragState.shiftAxis === 'v') pos.x = dragState.startX!; + else if (dx > dy) pos.y = dragState.startY!; else pos.x = dragState.startX!; } else { dragState.shiftAxis = undefined; diff --git a/svelte-app/src/lib/canvas/renderer.ts b/svelte-app/src/lib/canvas/renderer.ts index 012ebc0..febadf3 100644 --- a/svelte-app/src/lib/canvas/renderer.ts +++ b/svelte-app/src/lib/canvas/renderer.ts @@ -296,6 +296,28 @@ function traceInductionPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pa ctx.closePath(); } +/** Trace photoeye outline path */ +function tracePhotoeyePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { + const { leftCap, rightCap } = PHOTOEYE_CONFIG; + const x = sym.x, y = sym.y, w = sym.w, h = sym.h; + const p = pad; + + ctx.beginPath(); + ctx.moveTo(x + leftCap + p, y + h * 0.42 - p); + ctx.lineTo(x + leftCap + p, y + h * 0.248 - p); + ctx.lineTo(x - p, y + h * 0.05 - p); + ctx.lineTo(x - p, y + h * 0.948 + p); + ctx.lineTo(x + leftCap + p, y + h * 0.744 + p); + ctx.lineTo(x + leftCap + p, y + h * 0.585 + p); + ctx.lineTo(x + w - rightCap - p, y + h * 0.585 + p); + ctx.lineTo(x + w - rightCap - p, y + h * 0.826 + p); + ctx.lineTo(x + w + p, y + h * 0.826 + p); + ctx.lineTo(x + w + p, y + h * 0.181 - p); + ctx.lineTo(x + w - rightCap - p, y + h * 0.181 - p); + ctx.lineTo(x + w - rightCap - p, y + h * 0.42 - p); + ctx.closePath(); +} + /** Stroke an outline around a symbol — uses arc path for curved, trapezoid for spur, EPC shape for EPC, induction shape for induction, rect for straight */ function strokeOutline(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) { if (isCurvedType(sym.symbolId)) { @@ -309,6 +331,9 @@ function strokeOutline(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: nu } else if (isInductionType(sym.symbolId)) { traceInductionPath(ctx, sym, pad); ctx.stroke(); + } else if (isPhotoeyeType(sym.symbolId)) { + tracePhotoeyePath(ctx, sym, pad); + ctx.stroke(); } else { ctx.strokeRect(sym.x - pad, sym.y - pad, sym.w + pad * 2, sym.h + pad * 2); }