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) <noreply@anthropic.com>
This commit is contained in:
igurielidze 2026-03-21 19:05:53 +04:00
parent 31ea4c0908
commit d09ffd4a22
4 changed files with 60 additions and 35 deletions

View File

@ -42,18 +42,29 @@ function obbOverlapWithSpacing(
// ─── Convex polygon helpers ─── // ─── 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( 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][] { ): [number, number][] {
const cx = x + w / 2; const cx = x + w / 2;
const cy = y + h / 2; const cy = y + h / 2;
const local: [number, number][] = [ let local: [number, number][];
[x, y], // TL if (mirrored) {
[x + w2, y], // TR (top base end) // Mirrored: angled edge on left side instead of right
[x + w, y + h], // BR (bottom base end) local = [
[x, y + h], // BL [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; if (!rotation) return local;
const rad = rotation * Math.PI / 180; const rad = rotation * Math.PI / 180;
const cos = Math.cos(rad), sin = Math.sin(rad); 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 */ /** Get the convex hull vertices of an induction shape (arrow + strip) in world space */
function getInductionVertices( 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][] { ): [number, number][] {
const hw = INDUCTION_CONFIG.headWidth; const hw = INDUCTION_CONFIG.headWidth;
const stripTopY = y + h * INDUCTION_CONFIG.stripTopFrac; const stripTopY = y + h * INDUCTION_CONFIG.stripTopFrac;
const stripBottomY = y + h * INDUCTION_CONFIG.stripBottomFrac; const stripBottomY = y + h * INDUCTION_CONFIG.stripBottomFrac;
const arrowPts = INDUCTION_CONFIG.arrowPoints; const arrowPts = INDUCTION_CONFIG.arrowPoints;
// Convex hull: top-right strip, arrow top, arrow left, arrow bottom, bottom-right strip const cx = x + w / 2;
const local: [number, number][] = [ let local: [number, number][];
[x + w, stripTopY], // strip top-right if (mirrored) {
[x + arrowPts[0][0] * hw, y + arrowPts[0][1] * h], // arrow top junction // Mirror arrow x-coords around center
[x + arrowPts[1][0] * hw, y + arrowPts[1][1] * h], // arrow top local = [
[x + arrowPts[2][0] * hw, y + arrowPts[2][1] * h], // arrow left point [x, stripTopY],
[x + arrowPts[3][0] * hw, y + arrowPts[3][1] * h], // arrow bottom [x + w - arrowPts[0][0] * hw, y + arrowPts[0][1] * h],
[x + arrowPts[5][0] * hw, y + arrowPts[5][1] * h], // arrow bottom junction [x + w - arrowPts[1][0] * hw, y + arrowPts[1][1] * h],
[x + w, stripBottomY], // strip bottom-right [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; 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 rad = rotation * Math.PI / 180;
const cos = Math.cos(rad), sin = Math.sin(rad); const cos = Math.cos(rad), sin = Math.sin(rad);
return local.map(([px, py]) => { 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 */ /** Get collision vertices for any non-curved, non-EPC symbol based on its actual shape */
function getShapeVertices( function getShapeVertices(
x: number, y: number, w: number, h: number, rotation: number, x: number, y: number, w: number, h: number, rotation: number,
symbolId: string, w2?: number symbolId: string, w2?: number, mirrored?: boolean
): [number, number][] { ): [number, number][] {
if (isSpurType(symbolId)) return getSpurVertices(x, y, w, h, w2 ?? w, rotation); if (isSpurType(symbolId)) return getSpurVertices(x, y, w, h, w2 ?? w, rotation, mirrored);
if (isInductionType(symbolId)) return getInductionVertices(x, y, w, h, rotation); if (isInductionType(symbolId)) return getInductionVertices(x, y, w, h, rotation, mirrored);
return getOBBVertices(x, y, w, h, rotation); return getOBBVertices(x, y, w, h, rotation);
} }
export function checkSpacingViolation( export function checkSpacingViolation(
id: number, x: number, y: number, w: number, h: number, rotation: number, 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 { ): boolean {
if (symbolId && SPACING_EXEMPT.has(symbolId)) return false; if (symbolId && SPACING_EXEMPT.has(symbolId)) return false;
@ -497,29 +522,29 @@ export function checkSpacingViolation(
// ── EPC vs non-curved/non-EPC ── // ── EPC vs non-curved/non-EPC ──
if (isEpc && epcQuads && !symIsCurved && !symIsEpc) { 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; if (anyQuadVsPolygon(epcQuads, bVerts, spacing)) return true;
continue; continue;
} }
if (symIsEpc && symEpcQuads && !isCurved && !isEpc) { 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; if (anyQuadVsPolygon(symEpcQuads, aVerts, spacing)) return true;
continue; continue;
} }
// ── Non-EPC, non-curved: shape polygon vs shape polygon ── // ── Non-EPC, non-curved: shape polygon vs shape polygon ──
if (!isCurved && !symIsCurved) { if (!isCurved && !symIsCurved) {
const aVerts = getShapeVertices(x, y, w, h, rotation, symbolId!, 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); 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; if (polygonSATWithSpacing(aVerts, bVerts, spacing)) return true;
} else if (isCurved && !symIsCurved) { } 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( if (checkCurvedVsSpur(
{ x, y, w, h, rotation, symbolId: symbolId!, curveAngle }, { x, y, w, h, rotation, symbolId: symbolId!, curveAngle },
bVerts, spacing bVerts, spacing
)) return true; )) return true;
} else if (!isCurved && symIsCurved) { } 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; if (checkCurvedVsSpur(sym, aVerts, spacing)) return true;
} else { } else {
// Both curved: use band AABBs as fallback // Both curved: use band AABBs as fallback

View File

@ -21,11 +21,11 @@ export function snapToGrid(x: number, y: number, w?: number, h?: number, rotatio
export function findValidPosition( export function findValidPosition(
id: number, x: number, y: number, w: number, h: number, 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 } { ): { x: number; y: number } {
const snapped = snapToGrid(x, y, w, h, rotation); const snapped = snapToGrid(x, y, w, h, rotation);
let sx = snapped.x, sy = snapped.y; 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 step = layout.snapEnabled ? layout.gridSize : 1;
const searchRadius = 20; const searchRadius = 20;
@ -37,7 +37,7 @@ export function findValidPosition(
const cy = sy + dy * step; const cy = sy + dy * step;
const bb = getAABB(cx, cy, w, h, rotation); 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 (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 };
} }
} }
} }

View File

@ -659,7 +659,7 @@ function onMouseup(e: MouseEvent) {
const sym = layout.symbols.find(s => s.id === dragState!.placedId); const sym = layout.symbols.find(s => s.id === dragState!.placedId);
if (sym) { if (sym) {
if (!e.ctrlKey) { 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.x = valid.x;
sym.y = valid.y; sym.y = valid.y;
} }

View File

@ -470,7 +470,7 @@ 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, sym.mirrored)) {
ctx.strokeStyle = THEME.collision.strokeColor; ctx.strokeStyle = THEME.collision.strokeColor;
ctx.lineWidth = THEME.collision.lineWidth; ctx.lineWidth = THEME.collision.lineWidth;
ctx.shadowColor = THEME.collision.shadowColor; ctx.shadowColor = THEME.collision.shadowColor;