5.9 KB210 lines
Blame
1/* Geometry utilities extracted from render.ts for reuse and testing */
2
3export interface P {
4 x: number;
5 y: number;
6}
7
8export interface RectLike {
9 x: number; // center x
10 y: number; // center y
11 width: number;
12 height: number;
13 padding?: number;
14}
15
16export interface NodeLike {
17 intersect?: (p: P) => P | null;
18}
19
20export const EPS = 1;
21export const PUSH_OUT = 10;
22
23export 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 */
46export 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
98export 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
108export 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
127export 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
140export 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
160export const fallbackIntersection = (bounds: RectLike, outside: P, center: P): P => {
161 const inside = makeInsidePoint(bounds, outside, center);
162 return intersection(bounds, outside, inside);
163};
164
165export 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
175export 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