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