/** * Obstacle-avoiding routing for Tech Tree edges. * * A prerequisite edge should not cut through technologies it is not connected * to. Given the polyline an edge would take (source -> optional waypoints -> * target) and the rectangles of every OTHER node, this inserts detour points * so each segment swings around the boxes it would otherwise cross. * * The detour is greedy: for the first box a segment hits, it tries that box's * four (inflated) corners and picks the one with the shortest total bend, * recursing on the two halves. Bounded depth keeps it cheap and terminating. */ export type RouteRect = { id: number; x: number; y: number; width: number; height: number }; export type RoutePoint = { x: number; y: number }; /** Clearance kept around a node when routing around it. */ export const ROUTE_CLEARANCE = 22; const MAX_DEPTH = 3; const EPS = 1; /** Does segment a->b cross the interior of rect r? (exported for tests) */ export function segmentCrossesRect(a: RoutePoint, b: RoutePoint, r: RouteRect): boolean { const x1 = r.x + EPS; const y1 = r.y + EPS; const x2 = r.x + r.width - EPS; const y2 = r.y + r.height - EPS; if (x2 <= x1 || y2 <= y1) return false; // Liang-Barsky clip of the segment against the (slightly inset) box. let t0 = 0; let t1 = 1; const dx = b.x - a.x; const dy = b.y - a.y; const p = [-dx, dx, -dy, dy]; const q = [a.x - x1, x2 - a.x, a.y - y1, y2 - a.y]; for (let i = 0; i < 4; i += 1) { if (p[i] === 0) { if (q[i] < 0) return false; } else { const t = q[i] / p[i]; if (p[i] < 0) { if (t > t1) return false; if (t > t0) t0 = t; } else { if (t < t0) return false; if (t < t1) t1 = t; } } } return true; } const crosses = segmentCrossesRect; function corners(r: RouteRect, margin: number): RoutePoint[] { const x1 = r.x - margin; const y1 = r.y - margin; const x2 = r.x + r.width + margin; const y2 = r.y + r.height + margin; return [ { x: x1, y: y1 }, { x: x2, y: y1 }, { x: x2, y: y2 }, { x: x1, y: y2 }, ]; } function inside(p: RoutePoint, r: RouteRect, margin: number): boolean { return ( p.x > r.x - margin && p.x < r.x + r.width + margin && p.y > r.y - margin && p.y < r.y + r.height + margin ); } function dist(a: RoutePoint, b: RoutePoint): number { return Math.hypot(b.x - a.x, b.y - a.y); } /** Detour points from a to b (excluding a, ending with b). */ function detour( a: RoutePoint, b: RoutePoint, obstacles: RouteRect[], margin: number, depth: number, ): RoutePoint[] { if (depth >= MAX_DEPTH) return [b]; const hit = obstacles.find((r) => crosses(a, b, r)); if (!hit) return [b]; let best: RoutePoint | null = null; let bestLen = Number.POSITIVE_INFINITY; for (const c of corners(hit, margin)) { // Skip a corner inside another box, or one not reachable from `a` without // still cutting through the box. The second half is left to the recursion, // which chains a second corner for wide boxes. if (obstacles.some((r) => r.id !== hit.id && inside(c, r, 0))) continue; if (crosses(a, c, hit)) continue; // A corner at the current point or the destination makes no progress. if (dist(a, c) < 1 || dist(c, b) < 1) continue; const len = dist(a, c) + dist(c, b); if (len < bestLen) { bestLen = len; best = c; } } if (!best) return [b]; return [ ...detour(a, best, obstacles, margin, depth + 1), ...detour(best, b, obstacles, margin, depth + 1), ]; } /** * Route a polyline around the given obstacle boxes. Returns a new point list; * returns the input unchanged when there is nothing to avoid. */ export function routeAroundObstacles( points: RoutePoint[], obstacles: RouteRect[], margin = ROUTE_CLEARANCE, ): RoutePoint[] { if (points.length < 2 || obstacles.length === 0) return points; const out: RoutePoint[] = [points[0]]; for (let i = 0; i < points.length - 1; i += 1) { for (const p of detour(points[i], points[i + 1], obstacles, margin, 0)) { const last = out[out.length - 1]; if (Math.abs(p.x - last.x) > 0.5 || Math.abs(p.y - last.y) > 0.5) out.push(p); } } return out; }