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;