Rotate the direction vector -90° for the right box in collision detection and bounds calculation to match the perpendicular rendering orientation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
564 lines
20 KiB
TypeScript
564 lines
20 KiB
TypeScript
import type { AABB, EpcWaypoint } from '../types.js';
|
|
import { SPACING_EXEMPT, isCurvedType, isSpurType, isEpcType, isInductionType, getCurveGeometry, EPC_CONFIG, INDUCTION_CONFIG } from '../symbols.js';
|
|
import { layout } from '../stores/layout.svelte.js';
|
|
import { orientedBoxCorners, createCurveTransforms } from './geometry.js';
|
|
import { getAABB, distPointToSegment, distToAnnularSector, pointToOBBDist, pointInConvexPolygon, pointToConvexPolygonDist, edgeDistance } from './distance.js';
|
|
|
|
// Re-export for consumers that import from collision.ts
|
|
export { getAABB, clamp } from './distance.js';
|
|
export { snapToGrid, findValidPosition } from './grid-snap.js';
|
|
|
|
// ─── OBB collision via Separating Axis Theorem ───
|
|
|
|
function obbOverlapWithSpacing(
|
|
ax: number, ay: number, aw: number, ah: number, arot: number,
|
|
bx: number, by: number, bw: number, bh: number, brot: number,
|
|
spacing: number
|
|
): boolean {
|
|
const acx = ax + aw / 2, acy = ay + ah / 2;
|
|
const bcx = bx + bw / 2, bcy = by + bh / 2;
|
|
const ahw = aw / 2 + spacing, ahh = ah / 2 + spacing;
|
|
const bhw = bw / 2, bhh = bh / 2;
|
|
|
|
const arad = (arot || 0) * Math.PI / 180;
|
|
const brad = (brot || 0) * Math.PI / 180;
|
|
const cosA = Math.cos(arad), sinA = Math.sin(arad);
|
|
const cosB = Math.cos(brad), sinB = Math.sin(brad);
|
|
const tx = bcx - acx, ty = bcy - acy;
|
|
|
|
const axes: [number, number][] = [
|
|
[cosA, sinA], [-sinA, cosA],
|
|
[cosB, sinB], [-sinB, cosB],
|
|
];
|
|
|
|
for (const [nx, ny] of axes) {
|
|
const projA = ahw * Math.abs(cosA * nx + sinA * ny) + ahh * Math.abs(-sinA * nx + cosA * ny);
|
|
const projB = bhw * Math.abs(cosB * nx + sinB * ny) + bhh * Math.abs(-sinB * nx + cosB * ny);
|
|
const dist = Math.abs(tx * nx + ty * ny);
|
|
if (dist > projA + projB) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ─── Convex polygon helpers ───
|
|
|
|
/** 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, mirrored?: boolean
|
|
): [number, number][] {
|
|
const cx = x + w / 2;
|
|
const cy = y + h / 2;
|
|
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);
|
|
return local.map(([px, py]) => {
|
|
const dx = px - cx, dy = py - cy;
|
|
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos] as [number, number];
|
|
});
|
|
}
|
|
|
|
/** 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, 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;
|
|
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 cy = y + h / 2;
|
|
const rad = rotation * Math.PI / 180;
|
|
const cos = Math.cos(rad), sin = Math.sin(rad);
|
|
return local.map(([px, py]) => {
|
|
const dx = px - cx, dy = py - cy;
|
|
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos] as [number, number];
|
|
});
|
|
}
|
|
|
|
/** Get the 4 vertices of an OBB in world space */
|
|
function getOBBVertices(
|
|
x: number, y: number, w: number, h: number, rotation: number
|
|
): [number, number][] {
|
|
const cx = x + w / 2, cy = y + h / 2;
|
|
const rad = (rotation || 0) * Math.PI / 180;
|
|
const cos = Math.cos(rad), sin = Math.sin(rad);
|
|
const hw = w / 2, hh = h / 2;
|
|
return [
|
|
[cx - hw * cos + hh * sin, cy - hw * sin - hh * cos], // TL
|
|
[cx + hw * cos + hh * sin, cy + hw * sin - hh * cos], // TR
|
|
[cx + hw * cos - hh * sin, cy + hw * sin + hh * cos], // BR
|
|
[cx - hw * cos - hh * sin, cy - hw * sin + hh * cos], // BL
|
|
];
|
|
}
|
|
|
|
/** General SAT for two convex polygons with spacing tolerance */
|
|
function polygonSATWithSpacing(
|
|
aVerts: [number, number][],
|
|
bVerts: [number, number][],
|
|
spacing: number
|
|
): boolean {
|
|
const allVerts = [aVerts, bVerts];
|
|
for (const verts of allVerts) {
|
|
for (let i = 0; i < verts.length; i++) {
|
|
const j = (i + 1) % verts.length;
|
|
const ex = verts[j][0] - verts[i][0];
|
|
const ey = verts[j][1] - verts[i][1];
|
|
const len = Math.sqrt(ex * ex + ey * ey);
|
|
if (len === 0) continue;
|
|
const nx = -ey / len, ny = ex / len;
|
|
|
|
let aMin = Infinity, aMax = -Infinity;
|
|
for (const [px, py] of aVerts) {
|
|
const proj = px * nx + py * ny;
|
|
aMin = Math.min(aMin, proj);
|
|
aMax = Math.max(aMax, proj);
|
|
}
|
|
|
|
let bMin = Infinity, bMax = -Infinity;
|
|
for (const [px, py] of bVerts) {
|
|
const proj = px * nx + py * ny;
|
|
bMin = Math.min(bMin, proj);
|
|
bMax = Math.max(bMax, proj);
|
|
}
|
|
|
|
const gap = Math.max(bMin - aMax, aMin - bMax);
|
|
if (gap > spacing) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Check if point is inside a convex polygon (CCW or CW winding) */
|
|
// ─── Curved vs OBB: true geometric check ───
|
|
|
|
interface CurveParams {
|
|
x: number; y: number; w: number; h: number;
|
|
rotation: number; symbolId: string; curveAngle?: number;
|
|
}
|
|
|
|
/**
|
|
* Check if an OBB violates spacing against an annular sector (curved conveyor).
|
|
* Uses true geometric distance instead of AABB approximation.
|
|
*/
|
|
function checkCurvedVsOBB(
|
|
curve: CurveParams,
|
|
bx: number, by: number, bw: number, bh: number, brot: number,
|
|
spacing: number
|
|
): boolean {
|
|
const angle = curve.curveAngle || 90;
|
|
const { arcCx: acx, arcCy: acy, outerR, innerR } = getCurveGeometry(curve.symbolId, curve.x, curve.y, curve.w, curve.h);
|
|
const curveRot = (curve.rotation || 0) * Math.PI / 180;
|
|
const sweepRad = (angle * Math.PI) / 180;
|
|
|
|
const scx = curve.x + curve.w / 2;
|
|
const scy = curve.y + curve.h / 2;
|
|
|
|
const { toLocal, toWorld } = createCurveTransforms(acx, acy, scx, scy, curveRot);
|
|
|
|
const otherCx = bx + bw / 2, otherCy = by + bh / 2;
|
|
const oRad = (brot || 0) * Math.PI / 180;
|
|
const oCos = Math.cos(oRad), oSin = Math.sin(oRad);
|
|
const ohw = bw / 2, ohh = bh / 2;
|
|
|
|
const corners: [number, number][] = [
|
|
[otherCx + ohw * oCos - ohh * oSin, otherCy + ohw * oSin + ohh * oCos],
|
|
[otherCx - ohw * oCos - ohh * oSin, otherCy - ohw * oSin + ohh * oCos],
|
|
[otherCx - ohw * oCos + ohh * oSin, otherCy - ohw * oSin - ohh * oCos],
|
|
[otherCx + ohw * oCos + ohh * oSin, otherCy + ohw * oSin - ohh * oCos],
|
|
];
|
|
|
|
// Check OBB corners against the annular sector
|
|
for (const [wx, wy] of corners) {
|
|
const [lx, ly] = toLocal(wx, wy);
|
|
if (distToAnnularSector(lx, ly, innerR, outerR, sweepRad) < spacing) return true;
|
|
}
|
|
|
|
// Check edge midpoints and quarter-points for better coverage on long edges
|
|
for (let i = 0; i < 4; i++) {
|
|
const [x1, y1] = corners[i];
|
|
const [x2, y2] = corners[(i + 1) % 4];
|
|
for (const t of [0.25, 0.5, 0.75]) {
|
|
const [lx, ly] = toLocal(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t);
|
|
if (distToAnnularSector(lx, ly, innerR, outerR, sweepRad) < spacing) return true;
|
|
}
|
|
}
|
|
|
|
// Check arc sample points against the OBB
|
|
const SAMPLES = Math.max(4, Math.ceil(angle / 10));
|
|
for (let i = 0; i <= SAMPLES; i++) {
|
|
const a = (i / SAMPLES) * sweepRad;
|
|
const cos = Math.cos(a), sin = Math.sin(a);
|
|
|
|
const [owx, owy] = toWorld(acx + outerR * cos, acy - outerR * sin);
|
|
if (pointToOBBDist(owx, owy, bx, by, bw, bh, brot) < spacing) return true;
|
|
|
|
if (innerR > 0) {
|
|
const [iwx, iwy] = toWorld(acx + innerR * cos, acy - innerR * sin);
|
|
if (pointToOBBDist(iwx, iwy, bx, by, bw, bh, brot) < spacing) return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ─── Curved vs Spur: geometric check ───
|
|
|
|
function checkCurvedVsSpur(
|
|
curve: CurveParams,
|
|
spurVerts: [number, number][],
|
|
spacing: number
|
|
): boolean {
|
|
const angle = curve.curveAngle || 90;
|
|
const { arcCx: acx, arcCy: acy, outerR, innerR } = getCurveGeometry(curve.symbolId, curve.x, curve.y, curve.w, curve.h);
|
|
const curveRot = (curve.rotation || 0) * Math.PI / 180;
|
|
const sweepRad = (angle * Math.PI) / 180;
|
|
|
|
const scx = curve.x + curve.w / 2;
|
|
const scy = curve.y + curve.h / 2;
|
|
|
|
const { toLocal, toWorld } = createCurveTransforms(acx, acy, scx, scy, curveRot);
|
|
|
|
// Check spur vertices + edge sample points against annular sector
|
|
for (const [wx, wy] of spurVerts) {
|
|
const [lx, ly] = toLocal(wx, wy);
|
|
if (distToAnnularSector(lx, ly, innerR, outerR, sweepRad) < spacing) return true;
|
|
}
|
|
for (let i = 0; i < spurVerts.length; i++) {
|
|
const [x1, y1] = spurVerts[i];
|
|
const [x2, y2] = spurVerts[(i + 1) % spurVerts.length];
|
|
for (const t of [0.25, 0.5, 0.75]) {
|
|
const [lx, ly] = toLocal(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t);
|
|
if (distToAnnularSector(lx, ly, innerR, outerR, sweepRad) < spacing) return true;
|
|
}
|
|
}
|
|
|
|
// Check arc sample points against spur polygon
|
|
const SAMPLES = Math.max(4, Math.ceil(angle / 10));
|
|
for (let i = 0; i <= SAMPLES; i++) {
|
|
const a = (i / SAMPLES) * sweepRad;
|
|
const cos = Math.cos(a), sin = Math.sin(a);
|
|
|
|
const [owx, owy] = toWorld(acx + outerR * cos, acy - outerR * sin);
|
|
if (pointToConvexPolygonDist(owx, owy, spurVerts) < spacing) return true;
|
|
|
|
if (innerR > 0) {
|
|
const [iwx, iwy] = toWorld(acx + innerR * cos, acy - innerR * sin);
|
|
if (pointToConvexPolygonDist(iwx, iwy, spurVerts) < spacing) return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ─── Curved band AABBs (only for curved vs curved fallback) ───
|
|
|
|
function getCurvedBandAABBs(sym: CurveParams): AABB[] {
|
|
const angle = sym.curveAngle || 90;
|
|
const { arcCx: acx, arcCy: acy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
|
|
const rot = (sym.rotation || 0) * Math.PI / 180;
|
|
const sweepRad = (angle * Math.PI) / 180;
|
|
const scx = sym.x + sym.w / 2;
|
|
const scy = sym.y + sym.h / 2;
|
|
|
|
const cosR = Math.cos(rot), sinR = Math.sin(rot);
|
|
function rotatePoint(px: number, py: number): [number, number] {
|
|
if (!rot) return [px, py];
|
|
const dx = px - scx, dy = py - scy;
|
|
return [scx + dx * cosR - dy * sinR, scy + dx * sinR + dy * cosR];
|
|
}
|
|
|
|
const STEPS = Math.max(3, Math.ceil(angle / 10));
|
|
const stepRad = sweepRad / STEPS;
|
|
const boxes: AABB[] = [];
|
|
|
|
for (let i = 0; i < STEPS; i++) {
|
|
const a0 = i * stepRad, a1 = (i + 1) * stepRad;
|
|
const p1 = rotatePoint(acx + outerR * Math.cos(a0), acy - outerR * Math.sin(a0));
|
|
const p2 = rotatePoint(acx + outerR * Math.cos(a1), acy - outerR * Math.sin(a1));
|
|
const p3 = rotatePoint(acx + innerR * Math.cos(a0), acy - innerR * Math.sin(a0));
|
|
const p4 = rotatePoint(acx + innerR * Math.cos(a1), acy - innerR * Math.sin(a1));
|
|
const minX = Math.min(p1[0], p2[0], p3[0], p4[0]);
|
|
const minY = Math.min(p1[1], p2[1], p3[1], p4[1]);
|
|
const maxX = Math.max(p1[0], p2[0], p3[0], p4[0]);
|
|
const maxY = Math.max(p1[1], p2[1], p3[1], p4[1]);
|
|
boxes.push({ x: minX, y: minY, w: maxX - minX, h: maxY - minY });
|
|
}
|
|
return boxes;
|
|
}
|
|
|
|
// ─── EPC decomposition into oriented quads ───
|
|
|
|
/** Get all collision quads for an EPC symbol in world space */
|
|
function getEpcCollisionQuads(
|
|
symX: number, symY: number, symW: number, symH: number, rotation: number,
|
|
waypoints: EpcWaypoint[]
|
|
): [number, number][][] {
|
|
const quads: [number, number][][] = [];
|
|
if (waypoints.length < 2) return quads;
|
|
|
|
const rot = (rotation || 0) * Math.PI / 180;
|
|
const cosR = Math.cos(rot), sinR = Math.sin(rot);
|
|
const symCx = symX + symW / 2, symCy = symY + symH / 2;
|
|
|
|
function toWorld(lx: number, ly: number): [number, number] {
|
|
if (!rotation) return [lx, ly];
|
|
const dx = lx - symCx, dy = ly - symCy;
|
|
return [symCx + dx * cosR - dy * sinR, symCy + dx * sinR + dy * cosR];
|
|
}
|
|
|
|
/** Build an oriented quad from a segment (p0→p1) with given half-width */
|
|
function segmentQuad(
|
|
p0x: number, p0y: number, p1x: number, p1y: number, halfW: number
|
|
): [number, number][] {
|
|
const dx = p1x - p0x, dy = p1y - p0y;
|
|
const len = Math.sqrt(dx * dx + dy * dy);
|
|
if (len === 0) return [];
|
|
const nx = -dy / len * halfW, ny = dx / len * halfW;
|
|
return [
|
|
toWorld(p0x - nx, p0y - ny),
|
|
toWorld(p1x - nx, p1y - ny),
|
|
toWorld(p1x + nx, p1y + ny),
|
|
toWorld(p0x + nx, p0y + ny),
|
|
];
|
|
}
|
|
|
|
/** Build an oriented box quad using shared geometry, then transform to world space. */
|
|
function boxQuad(
|
|
anchorX: number, anchorY: number,
|
|
dirX: number, dirY: number,
|
|
boxW: number, boxH: number,
|
|
anchorSide: 'left' | 'right'
|
|
): [number, number][] {
|
|
const corners = orientedBoxCorners(anchorX, anchorY, dirX, dirY, boxW, boxH, anchorSide);
|
|
if (corners.length < 4) return [];
|
|
return corners.map(([cx, cy]) => toWorld(cx, cy)) as [number, number][];
|
|
}
|
|
|
|
const ox = symX, oy = symY;
|
|
|
|
// Line segment quads (use half-width of at least 1px for collision)
|
|
const segHalfW = Math.max(EPC_CONFIG.lineWidth / 2, 0.5);
|
|
for (let i = 0; i < waypoints.length - 1; i++) {
|
|
const q = segmentQuad(
|
|
ox + waypoints[i].x, oy + waypoints[i].y,
|
|
ox + waypoints[i + 1].x, oy + waypoints[i + 1].y,
|
|
segHalfW
|
|
);
|
|
if (q.length === 4) quads.push(q);
|
|
}
|
|
|
|
// Left box quad
|
|
const p0 = waypoints[0], p1 = waypoints[1];
|
|
const lbQ = boxQuad(
|
|
ox + p0.x, oy + p0.y,
|
|
p1.x - p0.x, p1.y - p0.y,
|
|
EPC_CONFIG.leftBox.w, EPC_CONFIG.leftBox.h,
|
|
'right'
|
|
);
|
|
if (lbQ.length === 4) quads.push(lbQ);
|
|
|
|
// Right box quad
|
|
const last = waypoints[waypoints.length - 1];
|
|
const prev = waypoints[waypoints.length - 2];
|
|
const rbDx = last.x - prev.x, rbDy = last.y - prev.y;
|
|
const rbQ = boxQuad(
|
|
ox + last.x, oy + last.y,
|
|
rbDy, -rbDx, // rotated -90° to match perpendicular end box
|
|
EPC_CONFIG.rightBox.w, EPC_CONFIG.rightBox.h,
|
|
'right'
|
|
);
|
|
if (rbQ.length === 4) quads.push(rbQ);
|
|
|
|
return quads;
|
|
}
|
|
|
|
/** Look up EPC waypoints for a symbol (from layout store or defaults) */
|
|
function getSymWaypoints(id: number): EpcWaypoint[] {
|
|
const sym = layout.symbols.find(s => s.id === id);
|
|
return sym?.epcWaypoints || EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp }));
|
|
}
|
|
|
|
// ─── Main spacing violation check ───
|
|
|
|
/** Check if any quad from a set collides with a single polygon */
|
|
function anyQuadVsPolygon(
|
|
quads: [number, number][][],
|
|
bVerts: [number, number][],
|
|
spacing: number
|
|
): boolean {
|
|
for (const q of quads) {
|
|
if (polygonSATWithSpacing(q, bVerts, spacing)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Check if any quad from set A collides with any quad from set B */
|
|
function anyQuadVsQuads(
|
|
aQuads: [number, number][][],
|
|
bQuads: [number, number][][],
|
|
spacing: number
|
|
): boolean {
|
|
for (const aq of aQuads) {
|
|
for (const bq of bQuads) {
|
|
if (polygonSATWithSpacing(aq, bq, spacing)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Check any EPC quad against a curved symbol */
|
|
function anyQuadVsCurved(
|
|
quads: [number, number][][],
|
|
curve: CurveParams,
|
|
spacing: number
|
|
): boolean {
|
|
// Use the curved-vs-spur logic: each quad is a convex polygon
|
|
for (const q of quads) {
|
|
if (checkCurvedVsSpur(curve, q, spacing)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** 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, mirrored?: boolean
|
|
): [number, number][] {
|
|
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, mirrored?: boolean
|
|
): boolean {
|
|
if (symbolId && SPACING_EXEMPT.has(symbolId)) return false;
|
|
|
|
const spacing = layout.minSpacing;
|
|
const isCurved = !!(symbolId && isCurvedType(symbolId));
|
|
const isSpur = !!(symbolId && isSpurType(symbolId));
|
|
const isEpc = !!(symbolId && isEpcType(symbolId));
|
|
const isInduction = !!(symbolId && isInductionType(symbolId));
|
|
|
|
// Get EPC quads for "this" symbol if it's an EPC
|
|
let epcQuads: [number, number][][] | null = null;
|
|
if (isEpc) {
|
|
const wps = getSymWaypoints(id);
|
|
epcQuads = getEpcCollisionQuads(x, y, w, h, rotation, wps);
|
|
}
|
|
|
|
for (const sym of layout.symbols) {
|
|
if (sym.id === id) continue;
|
|
if (SPACING_EXEMPT.has(sym.symbolId)) continue;
|
|
|
|
const symIsCurved = isCurvedType(sym.symbolId);
|
|
const symIsSpur = isSpurType(sym.symbolId);
|
|
const symIsEpc = isEpcType(sym.symbolId);
|
|
const symIsInduction = isInductionType(sym.symbolId);
|
|
|
|
// Get EPC quads for "other" symbol if it's EPC
|
|
let symEpcQuads: [number, number][][] | null = null;
|
|
if (symIsEpc) {
|
|
const symWps = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints;
|
|
symEpcQuads = getEpcCollisionQuads(sym.x, sym.y, sym.w, sym.h, sym.rotation, symWps);
|
|
}
|
|
|
|
// ── EPC vs EPC ──
|
|
if (isEpc && symIsEpc && epcQuads && symEpcQuads) {
|
|
if (anyQuadVsQuads(epcQuads, symEpcQuads, spacing)) return true;
|
|
continue;
|
|
}
|
|
|
|
// ── EPC vs Curved ──
|
|
if (isEpc && symIsCurved && epcQuads) {
|
|
if (anyQuadVsCurved(epcQuads, sym, spacing)) return true;
|
|
continue;
|
|
}
|
|
if (isCurved && symIsEpc && symEpcQuads) {
|
|
if (anyQuadVsCurved(symEpcQuads, { x, y, w, h, rotation, symbolId: symbolId!, curveAngle }, spacing)) return true;
|
|
continue;
|
|
}
|
|
|
|
// ── 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, 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, 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, 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, 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, mirrored);
|
|
if (checkCurvedVsSpur(sym, aVerts, spacing)) return true;
|
|
} else {
|
|
// Both curved: use band AABBs as fallback
|
|
const aBoxes = getCurvedBandAABBs({ x, y, w, h, rotation, symbolId: symbolId!, curveAngle });
|
|
const bBoxes = getCurvedBandAABBs(sym);
|
|
for (const a of aBoxes) {
|
|
for (const b of bBoxes) {
|
|
if (edgeDistance(a.x, a.y, a.w, a.h, b.x, b.y, b.w, b.h) < spacing) return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|