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