| 1 | /* Geometry utilities extracted from render.ts for reuse and testing */ |
| 2 | |
| 3 | export interface P { |
| 4 | x: number; |
| 5 | y: number; |
| 6 | } |
| 7 | |
| 8 | export interface RectLike { |
| 9 | x: number; // center x |
| 10 | y: number; // center y |
| 11 | width: number; |
| 12 | height: number; |
| 13 | padding?: number; |
| 14 | } |
| 15 | |
| 16 | export interface NodeLike { |
| 17 | intersect?: (p: P) => P | null; |
| 18 | } |
| 19 | |
| 20 | export const EPS = 1; |
| 21 | export const PUSH_OUT = 10; |
| 22 | |
| 23 | export const onBorder = (bounds: RectLike, p: P, tol = 0.5): boolean => { |
| 24 | const halfW = bounds.width / 2; |
| 25 | const halfH = bounds.height / 2; |
| 26 | const left = bounds.x - halfW; |
| 27 | const right = bounds.x + halfW; |
| 28 | const top = bounds.y - halfH; |
| 29 | const bottom = bounds.y + halfH; |
| 30 | |
| 31 | const onLeft = Math.abs(p.x - left) <= tol && p.y >= top - tol && p.y <= bottom + tol; |
| 32 | const onRight = Math.abs(p.x - right) <= tol && p.y >= top - tol && p.y <= bottom + tol; |
| 33 | const onTop = Math.abs(p.y - top) <= tol && p.x >= left - tol && p.x <= right + tol; |
| 34 | const onBottom = Math.abs(p.y - bottom) <= tol && p.x >= left - tol && p.x <= right + tol; |
| 35 | return onLeft || onRight || onTop || onBottom; |
| 36 | }; |
| 37 | |
| 38 | /** |
| 39 | * Compute intersection between a rectangle (center x/y, width/height) and the line |
| 40 | * segment from insidePoint -\> outsidePoint. Returns the point on the rectangle border. |
| 41 | * |
| 42 | * This version avoids snapping to outsidePoint when certain variables evaluate to 0 |
| 43 | * (previously caused vertical top/bottom cases to miss the border). It only enforces |
| 44 | * axis-constant behavior for purely vertical/horizontal approaches. |
| 45 | */ |
| 46 | export const intersection = (node: RectLike, outsidePoint: P, insidePoint: P): P => { |
| 47 | const x = node.x; |
| 48 | const y = node.y; |
| 49 | |
| 50 | const dx = Math.abs(x - insidePoint.x); |
| 51 | const w = node.width / 2; |
| 52 | let r = insidePoint.x < outsidePoint.x ? w - dx : w + dx; |
| 53 | const h = node.height / 2; |
| 54 | |
| 55 | const Q = Math.abs(outsidePoint.y - insidePoint.y); |
| 56 | const R = Math.abs(outsidePoint.x - insidePoint.x); |
| 57 | |
| 58 | if (Math.abs(y - outsidePoint.y) * w > Math.abs(x - outsidePoint.x) * h) { |
| 59 | // Intersection is top or bottom of rect. |
| 60 | const q = insidePoint.y < outsidePoint.y ? outsidePoint.y - h - y : y - h - outsidePoint.y; |
| 61 | r = (R * q) / Q; |
| 62 | const res = { |
| 63 | x: insidePoint.x < outsidePoint.x ? insidePoint.x + r : insidePoint.x - R + r, |
| 64 | y: insidePoint.y < outsidePoint.y ? insidePoint.y + Q - q : insidePoint.y - Q + q, |
| 65 | }; |
| 66 | |
| 67 | // Keep axis-constant special-cases only |
| 68 | if (R === 0) { |
| 69 | res.x = outsidePoint.x; |
| 70 | } |
| 71 | if (Q === 0) { |
| 72 | res.y = outsidePoint.y; |
| 73 | } |
| 74 | return res; |
| 75 | } else { |
| 76 | // Intersection on sides of rect |
| 77 | if (insidePoint.x < outsidePoint.x) { |
| 78 | r = outsidePoint.x - w - x; |
| 79 | } else { |
| 80 | r = x - w - outsidePoint.x; |
| 81 | } |
| 82 | const q = (Q * r) / R; |
| 83 | let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : insidePoint.x - R + r; |
| 84 | let _y = insidePoint.y < outsidePoint.y ? insidePoint.y + q : insidePoint.y - q; |
| 85 | |
| 86 | // Only handle axis-constant cases |
| 87 | if (R === 0) { |
| 88 | _x = outsidePoint.x; |
| 89 | } |
| 90 | if (Q === 0) { |
| 91 | _y = outsidePoint.y; |
| 92 | } |
| 93 | |
| 94 | return { x: _x, y: _y }; |
| 95 | } |
| 96 | }; |
| 97 | |
| 98 | export const outsideNode = (node: RectLike, point: P): boolean => { |
| 99 | const x = node.x; |
| 100 | const y = node.y; |
| 101 | const dx = Math.abs(point.x - x); |
| 102 | const dy = Math.abs(point.y - y); |
| 103 | const w = node.width / 2; |
| 104 | const h = node.height / 2; |
| 105 | return dx >= w || dy >= h; |
| 106 | }; |
| 107 | |
| 108 | export const ensureTrulyOutside = (bounds: RectLike, p: P, push = PUSH_OUT): P => { |
| 109 | const dx = Math.abs(p.x - bounds.x); |
| 110 | const dy = Math.abs(p.y - bounds.y); |
| 111 | const w = bounds.width / 2; |
| 112 | const h = bounds.height / 2; |
| 113 | if (Math.abs(dx - w) < EPS || Math.abs(dy - h) < EPS) { |
| 114 | const dirX = p.x - bounds.x; |
| 115 | const dirY = p.y - bounds.y; |
| 116 | const len = Math.sqrt(dirX * dirX + dirY * dirY); |
| 117 | if (len > 0) { |
| 118 | return { |
| 119 | x: bounds.x + (dirX / len) * (len + push), |
| 120 | y: bounds.y + (dirY / len) * (len + push), |
| 121 | }; |
| 122 | } |
| 123 | } |
| 124 | return p; |
| 125 | }; |
| 126 | |
| 127 | export const makeInsidePoint = (bounds: RectLike, outside: P, center: P): P => { |
| 128 | const isVertical = Math.abs(outside.x - bounds.x) < EPS; |
| 129 | const isHorizontal = Math.abs(outside.y - bounds.y) < EPS; |
| 130 | return { |
| 131 | x: isVertical |
| 132 | ? outside.x |
| 133 | : outside.x < bounds.x |
| 134 | ? bounds.x - bounds.width / 4 |
| 135 | : bounds.x + bounds.width / 4, |
| 136 | y: isHorizontal ? outside.y : center.y, |
| 137 | }; |
| 138 | }; |
| 139 | |
| 140 | export const tryNodeIntersect = (node: NodeLike, bounds: RectLike, outside: P): P | null => { |
| 141 | if (!node?.intersect) { |
| 142 | return null; |
| 143 | } |
| 144 | const res = node.intersect(outside); |
| 145 | if (!res) { |
| 146 | return null; |
| 147 | } |
| 148 | const wrongSide = |
| 149 | (outside.x < bounds.x && res.x > bounds.x) || (outside.x > bounds.x && res.x < bounds.x); |
| 150 | if (wrongSide) { |
| 151 | return null; |
| 152 | } |
| 153 | const dist = Math.hypot(outside.x - res.x, outside.y - res.y); |
| 154 | if (dist <= EPS) { |
| 155 | return null; |
| 156 | } |
| 157 | return res; |
| 158 | }; |
| 159 | |
| 160 | export const fallbackIntersection = (bounds: RectLike, outside: P, center: P): P => { |
| 161 | const inside = makeInsidePoint(bounds, outside, center); |
| 162 | return intersection(bounds, outside, inside); |
| 163 | }; |
| 164 | |
| 165 | export const computeNodeIntersection = ( |
| 166 | node: NodeLike, |
| 167 | bounds: RectLike, |
| 168 | outside: P, |
| 169 | center: P |
| 170 | ): P => { |
| 171 | const outside2 = ensureTrulyOutside(bounds, outside); |
| 172 | return tryNodeIntersect(node, bounds, outside2) ?? fallbackIntersection(bounds, outside2, center); |
| 173 | }; |
| 174 | |
| 175 | export const replaceEndpoint = ( |
| 176 | points: P[], |
| 177 | which: 'start' | 'end', |
| 178 | value: P | null | undefined, |
| 179 | tol = 0.1 |
| 180 | ) => { |
| 181 | if (!value || points.length === 0) { |
| 182 | return; |
| 183 | } |
| 184 | |
| 185 | if (which === 'start') { |
| 186 | if ( |
| 187 | points.length > 0 && |
| 188 | Math.abs(points[0].x - value.x) < tol && |
| 189 | Math.abs(points[0].y - value.y) < tol |
| 190 | ) { |
| 191 | // duplicate start remove it |
| 192 | points.shift(); |
| 193 | } else { |
| 194 | points[0] = value; |
| 195 | } |
| 196 | } else { |
| 197 | const last = points.length - 1; |
| 198 | if ( |
| 199 | points.length > 0 && |
| 200 | Math.abs(points[last].x - value.x) < tol && |
| 201 | Math.abs(points[last].y - value.y) < tol |
| 202 | ) { |
| 203 | // duplicate end remove it |
| 204 | points.pop(); |
| 205 | } else { |
| 206 | points[last] = value; |
| 207 | } |
| 208 | } |
| 209 | }; |
| 210 | |