From d09ffd4a22d2d0b5fef2c1d083d54fabf033e8f5 Mon Sep 17 00:00:00 2001 From: igurielidze Date: Sat, 21 Mar 2026 19:05:53 +0400 Subject: [PATCH] Fix spur/induction collision polygons to account for mirrored state Collision detection now mirrors spur trapezoid and induction arrow vertices when the symbol is mirrored, matching the visual appearance. Previously the collision polygon stayed unmirrored causing false positives/misaligned collision zones. Co-Authored-By: Claude Opus 4.6 (1M context) --- svelte-app/src/lib/canvas/collision.ts | 85 +++++++++++++++-------- svelte-app/src/lib/canvas/grid-snap.ts | 6 +- svelte-app/src/lib/canvas/interactions.ts | 2 +- svelte-app/src/lib/canvas/renderer.ts | 2 +- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/svelte-app/src/lib/canvas/collision.ts b/svelte-app/src/lib/canvas/collision.ts index b9e6534..08cbfea 100644 --- a/svelte-app/src/lib/canvas/collision.ts +++ b/svelte-app/src/lib/canvas/collision.ts @@ -42,18 +42,29 @@ function obbOverlapWithSpacing( // ─── Convex polygon helpers ─── -/** Get the 4 vertices of a spur trapezoid in world space (with rotation) */ +/** Get the 4 vertices of a spur trapezoid in world space (with rotation and mirror) */ function getSpurVertices( - x: number, y: number, w: number, h: number, w2: number, rotation: number + x: number, y: number, w: number, h: number, w2: number, rotation: number, mirrored?: boolean ): [number, number][] { const cx = x + w / 2; const cy = y + h / 2; - const local: [number, number][] = [ - [x, y], // TL - [x + w2, y], // TR (top base end) - [x + w, y + h], // BR (bottom base end) - [x, y + h], // BL - ]; + let local: [number, number][]; + if (mirrored) { + // Mirrored: angled edge on left side instead of right + local = [ + [x + w - w2, y], // TL (narrow end, mirrored) + [x + w, y], // TR + [x + w, y + h], // BR + [x, y + h], // BL (wide end, mirrored) + ]; + } else { + local = [ + [x, y], // TL + [x + w2, y], // TR (top base end) + [x + w, y + h], // BR (bottom base end) + [x, y + h], // BL + ]; + } if (!rotation) return local; const rad = rotation * Math.PI / 180; const cos = Math.cos(rad), sin = Math.sin(rad); @@ -65,24 +76,38 @@ function getSpurVertices( /** Get the convex hull vertices of an induction shape (arrow + strip) in world space */ function getInductionVertices( - x: number, y: number, w: number, h: number, rotation: number + x: number, y: number, w: number, h: number, rotation: number, mirrored?: boolean ): [number, number][] { const hw = INDUCTION_CONFIG.headWidth; const stripTopY = y + h * INDUCTION_CONFIG.stripTopFrac; const stripBottomY = y + h * INDUCTION_CONFIG.stripBottomFrac; const arrowPts = INDUCTION_CONFIG.arrowPoints; - // Convex hull: top-right strip, arrow top, arrow left, arrow bottom, bottom-right strip - const local: [number, number][] = [ - [x + w, stripTopY], // strip top-right - [x + arrowPts[0][0] * hw, y + arrowPts[0][1] * h], // arrow top junction - [x + arrowPts[1][0] * hw, y + arrowPts[1][1] * h], // arrow top - [x + arrowPts[2][0] * hw, y + arrowPts[2][1] * h], // arrow left point - [x + arrowPts[3][0] * hw, y + arrowPts[3][1] * h], // arrow bottom - [x + arrowPts[5][0] * hw, y + arrowPts[5][1] * h], // arrow bottom junction - [x + w, stripBottomY], // strip bottom-right - ]; + const cx = x + w / 2; + let local: [number, number][]; + if (mirrored) { + // Mirror arrow x-coords around center + local = [ + [x, stripTopY], + [x + w - arrowPts[0][0] * hw, y + arrowPts[0][1] * h], + [x + w - arrowPts[1][0] * hw, y + arrowPts[1][1] * h], + [x + w - arrowPts[2][0] * hw, y + arrowPts[2][1] * h], + [x + w - arrowPts[3][0] * hw, y + arrowPts[3][1] * h], + [x + w - arrowPts[5][0] * hw, y + arrowPts[5][1] * h], + [x, stripBottomY], + ]; + } else { + local = [ + [x + w, stripTopY], + [x + arrowPts[0][0] * hw, y + arrowPts[0][1] * h], + [x + arrowPts[1][0] * hw, y + arrowPts[1][1] * h], + [x + arrowPts[2][0] * hw, y + arrowPts[2][1] * h], + [x + arrowPts[3][0] * hw, y + arrowPts[3][1] * h], + [x + arrowPts[5][0] * hw, y + arrowPts[5][1] * h], + [x, stripBottomY], + ]; + } if (!rotation) return local; - const cx = x + w / 2, cy = y + h / 2; + const cy = y + h / 2; const rad = rotation * Math.PI / 180; const cos = Math.cos(rad), sin = Math.sin(rad); return local.map(([px, py]) => { @@ -437,16 +462,16 @@ function anyQuadVsCurved( /** Get collision vertices for any non-curved, non-EPC symbol based on its actual shape */ function getShapeVertices( x: number, y: number, w: number, h: number, rotation: number, - symbolId: string, w2?: number + symbolId: string, w2?: number, mirrored?: boolean ): [number, number][] { - if (isSpurType(symbolId)) return getSpurVertices(x, y, w, h, w2 ?? w, rotation); - if (isInductionType(symbolId)) return getInductionVertices(x, y, w, h, rotation); + if (isSpurType(symbolId)) return getSpurVertices(x, y, w, h, w2 ?? w, rotation, mirrored); + if (isInductionType(symbolId)) return getInductionVertices(x, y, w, h, rotation, mirrored); return getOBBVertices(x, y, w, h, rotation); } export function checkSpacingViolation( id: number, x: number, y: number, w: number, h: number, rotation: number, - symbolId?: string, curveAngle?: number, w2?: number + symbolId?: string, curveAngle?: number, w2?: number, mirrored?: boolean ): boolean { if (symbolId && SPACING_EXEMPT.has(symbolId)) return false; @@ -497,29 +522,29 @@ export function checkSpacingViolation( // ── EPC vs non-curved/non-EPC ── if (isEpc && epcQuads && !symIsCurved && !symIsEpc) { - const bVerts = getShapeVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.w2); + const bVerts = getShapeVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.w2, sym.mirrored); if (anyQuadVsPolygon(epcQuads, bVerts, spacing)) return true; continue; } if (symIsEpc && symEpcQuads && !isCurved && !isEpc) { - const aVerts = getShapeVertices(x, y, w, h, rotation, symbolId!, w2); + const aVerts = getShapeVertices(x, y, w, h, rotation, symbolId!, w2, mirrored); if (anyQuadVsPolygon(symEpcQuads, aVerts, spacing)) return true; continue; } // ── Non-EPC, non-curved: shape polygon vs shape polygon ── if (!isCurved && !symIsCurved) { - const aVerts = getShapeVertices(x, y, w, h, rotation, symbolId!, w2); - const bVerts = getShapeVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.w2); + const aVerts = getShapeVertices(x, y, w, h, rotation, symbolId!, w2, mirrored); + const bVerts = getShapeVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.w2, sym.mirrored); if (polygonSATWithSpacing(aVerts, bVerts, spacing)) return true; } else if (isCurved && !symIsCurved) { - const bVerts = getShapeVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.w2); + const bVerts = getShapeVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.w2, sym.mirrored); if (checkCurvedVsSpur( { x, y, w, h, rotation, symbolId: symbolId!, curveAngle }, bVerts, spacing )) return true; } else if (!isCurved && symIsCurved) { - const aVerts = getShapeVertices(x, y, w, h, rotation, symbolId!, w2); + const aVerts = getShapeVertices(x, y, w, h, rotation, symbolId!, w2, mirrored); if (checkCurvedVsSpur(sym, aVerts, spacing)) return true; } else { // Both curved: use band AABBs as fallback diff --git a/svelte-app/src/lib/canvas/grid-snap.ts b/svelte-app/src/lib/canvas/grid-snap.ts index 181c1b6..4c1fff8 100644 --- a/svelte-app/src/lib/canvas/grid-snap.ts +++ b/svelte-app/src/lib/canvas/grid-snap.ts @@ -21,11 +21,11 @@ export function snapToGrid(x: number, y: number, w?: number, h?: number, rotatio export function findValidPosition( id: number, x: number, y: number, w: number, h: number, - symbolId: string, rotation: number, curveAngle?: number, w2?: number + symbolId: string, rotation: number, curveAngle?: number, w2?: number, mirrored?: boolean ): { x: number; y: number } { const snapped = snapToGrid(x, y, w, h, rotation); let sx = snapped.x, sy = snapped.y; - if (!checkSpacingViolation(id, sx, sy, w, h, rotation, symbolId, curveAngle, w2)) return { x: sx, y: sy }; + if (!checkSpacingViolation(id, sx, sy, w, h, rotation, symbolId, curveAngle, w2, mirrored)) return { x: sx, y: sy }; const step = layout.snapEnabled ? layout.gridSize : 1; const searchRadius = 20; @@ -37,7 +37,7 @@ export function findValidPosition( const cy = sy + dy * step; const bb = getAABB(cx, cy, w, h, rotation); if (bb.x < 0 || bb.y < 0 || bb.x + bb.w > layout.canvasW || bb.y + bb.h > layout.canvasH) continue; - if (!checkSpacingViolation(id, cx, cy, w, h, rotation, symbolId, curveAngle, w2)) return { x: cx, y: cy }; + if (!checkSpacingViolation(id, cx, cy, w, h, rotation, symbolId, curveAngle, w2, mirrored)) return { x: cx, y: cy }; } } } diff --git a/svelte-app/src/lib/canvas/interactions.ts b/svelte-app/src/lib/canvas/interactions.ts index 3264abb..3849315 100644 --- a/svelte-app/src/lib/canvas/interactions.ts +++ b/svelte-app/src/lib/canvas/interactions.ts @@ -659,7 +659,7 @@ function onMouseup(e: MouseEvent) { const sym = layout.symbols.find(s => s.id === dragState!.placedId); if (sym) { if (!e.ctrlKey) { - const valid = findValidPosition(sym.id, sym.x, sym.y, sym.w, sym.h, sym.symbolId, sym.rotation, sym.curveAngle, sym.w2); + const valid = findValidPosition(sym.id, sym.x, sym.y, sym.w, sym.h, sym.symbolId, sym.rotation, sym.curveAngle, sym.w2, sym.mirrored); sym.x = valid.x; sym.y = valid.y; } diff --git a/svelte-app/src/lib/canvas/renderer.ts b/svelte-app/src/lib/canvas/renderer.ts index f6b633e..eeff2af 100644 --- a/svelte-app/src/lib/canvas/renderer.ts +++ b/svelte-app/src/lib/canvas/renderer.ts @@ -470,7 +470,7 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { } // Collision highlight - if (checkSpacingViolation(sym.id, sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.curveAngle, sym.w2)) { + if (checkSpacingViolation(sym.id, sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.curveAngle, sym.w2, sym.mirrored)) { ctx.strokeStyle = THEME.collision.strokeColor; ctx.lineWidth = THEME.collision.lineWidth; ctx.shadowColor = THEME.collision.shadowColor;