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 ───
/** 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

View File

@ -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 };
}
}
}

View File

@ -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;
}

View File

@ -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;