igurielidze e4c67b165b Fix EPC end box collision and bounds to match 90° rotation
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>
2026-03-30 15:38:37 +04:00

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