@davidsouther/agathon 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +19 -4
- package/src/assert.ts +67 -0
- package/src/geometry/box.ts +76 -0
- package/src/geometry/circle.ts +296 -0
- package/src/geometry/geometry.ts +318 -0
- package/src/geometry/path.ts +315 -0
- package/src/geometry/quadtree.ts +321 -0
- package/src/geometry/rectangle.ts +129 -0
- package/src/geometry/rngon.ts +132 -0
- package/src/geometry/types.ts +102 -0
- package/src/geometry.ts +61 -0
- package/src/highlight/highlight.ts +85 -0
- package/src/index.ts +5 -0
- package/src/layout/grid/manager.ts +55 -0
- package/src/layout/grid.ts +1 -0
- package/src/layout/hierarchic/README.md +21 -0
- package/src/layout/hierarchic/acyclic.ts +249 -0
- package/src/layout/hierarchic/children.ts +169 -0
- package/src/layout/hierarchic/debug.ts +186 -0
- package/src/layout/hierarchic/layout.ts +266 -0
- package/src/layout/hierarchic/manager.ts +65 -0
- package/src/layout/hierarchic/normalize.ts +177 -0
- package/src/layout/hierarchic/order.ts +321 -0
- package/src/layout/hierarchic/position.ts +514 -0
- package/src/layout/hierarchic/rank.ts +629 -0
- package/src/layout/hierarchic.ts +2 -0
- package/src/layout/hulls/arrange.ts +94 -0
- package/src/layout/hulls/hulls.ts +243 -0
- package/src/layout/hulls/segments.ts +174 -0
- package/src/layout/hulls.ts +3 -0
- package/src/layout/manager/child_manager.ts +42 -0
- package/src/layout/manager/drag_manager.ts +112 -0
- package/src/layout/manager/manager.ts +425 -0
- package/src/layout/manager/pan_manager.ts +146 -0
- package/src/layout/manager/visibility_manager.ts +84 -0
- package/src/layout/manager/zoom_manager.ts +263 -0
- package/src/layout/manager.ts +6 -0
- package/src/layout/properties/defaults.ts +99 -0
- package/src/layout/properties/symbol.ts +99 -0
- package/src/layout/properties/weak_map.ts +73 -0
- package/src/layout/properties.ts +111 -0
- package/src/layout/types.ts +470 -0
- package/src/layout/util.ts +444 -0
- package/src/layout.ts +6 -0
- package/src/samples/diff_graph.ts +15 -0
- package/src/samples/edge_cases.ts +168 -0
- package/src/samples/gansner_with_states.ts +67 -0
- package/src/samples/load_generator.ts +213 -0
- package/src/samples/search_graph.ts +35 -0
- package/src/tsabre/algo.ts +126 -0
- package/src/tsabre/builder.ts +97 -0
- package/src/tsabre/compress.ts +15 -0
- package/src/tsabre/dijkstra/dijkstra.ts +76 -0
- package/src/tsabre/dijkstra/heap.ts +83 -0
- package/src/tsabre/graph.ts +808 -0
- package/src/tsabre/parse.ts +634 -0
- package/src/tsabre/print.ts +188 -0
- package/src/tsabre/sourcebuilder/code_content_buffer.ts +275 -0
- package/src/tsabre/sourcebuilder/printable.ts +33 -0
- package/src/tsabre.ts +6 -0
- package/src/types.ts +424 -0
- package/README.md +0 -3
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type { Bounds, Point } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
import { lineIntersectsBox } from "./box.ts";
|
|
4
|
+
import type { Geometry, Line } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** Math.EPSILON is much to small for graphical geometry. */
|
|
7
|
+
export const EPSILON = 1e-2;
|
|
8
|
+
|
|
9
|
+
/** The base radius for Agathon geometry shapes. */
|
|
10
|
+
export const BASE_RADIUS = 20;
|
|
11
|
+
|
|
12
|
+
/** Origin point for default positions. */
|
|
13
|
+
export function origin(): Point {
|
|
14
|
+
return { x: 0, y: 0 };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Parameters for a base geometry. */
|
|
18
|
+
export interface GeometryParams {
|
|
19
|
+
point: Point;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* BaseGeometry provides an implementation of clip, which provides convenience
|
|
24
|
+
* at the possible expense of small object performance.
|
|
25
|
+
*/
|
|
26
|
+
export abstract class BaseGeometry implements Geometry {
|
|
27
|
+
protected position: Point = { x: 0, y: 0 };
|
|
28
|
+
protected dragPt: Point = { x: 0, y: 0 };
|
|
29
|
+
protected movePt: Point = { x: 0, y: 0 };
|
|
30
|
+
protected bounds: Bounds = { height: 0, width: 0 };
|
|
31
|
+
|
|
32
|
+
get point(): Readonly<Point> {
|
|
33
|
+
return this.position;
|
|
34
|
+
}
|
|
35
|
+
get x(): number {
|
|
36
|
+
return this.position.x;
|
|
37
|
+
}
|
|
38
|
+
get y(): number {
|
|
39
|
+
return this.position.y;
|
|
40
|
+
}
|
|
41
|
+
get radius(): number {
|
|
42
|
+
return Math.sqrt(this.height * this.height + this.width * this.width) / 2;
|
|
43
|
+
}
|
|
44
|
+
get height(): number {
|
|
45
|
+
return this.bounds.height;
|
|
46
|
+
}
|
|
47
|
+
get width(): number {
|
|
48
|
+
return this.bounds.width;
|
|
49
|
+
}
|
|
50
|
+
get left(): number {
|
|
51
|
+
return this.x - this.bounds.width / 2;
|
|
52
|
+
}
|
|
53
|
+
get right(): number {
|
|
54
|
+
return this.x + this.bounds.width / 2;
|
|
55
|
+
}
|
|
56
|
+
get bottom(): number {
|
|
57
|
+
return this.y - this.bounds.height / 2;
|
|
58
|
+
}
|
|
59
|
+
get top(): number {
|
|
60
|
+
return this.y + this.bounds.height / 2;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
constructor({ point = origin() }: Readonly<Partial<GeometryParams>>) {
|
|
64
|
+
// Defensive copy of the point
|
|
65
|
+
this.movePt.x = point.x;
|
|
66
|
+
this.movePt.y = point.y;
|
|
67
|
+
this.place();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private place(): void {
|
|
71
|
+
this.position.x = this.movePt.x + this.dragPt.x;
|
|
72
|
+
this.position.y = this.movePt.y + this.dragPt.y;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
drag({ x: dx, y: dy }: Point): void {
|
|
76
|
+
this.dragPt.x += dx;
|
|
77
|
+
this.dragPt.y += dy;
|
|
78
|
+
this.place();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
dragTo({ x, y }: Point): void {
|
|
82
|
+
this.dragPt.x = x;
|
|
83
|
+
this.dragPt.y = y;
|
|
84
|
+
this.place();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
move({ x: dx, y: dy }: Point): void {
|
|
88
|
+
this.movePt.x += dx;
|
|
89
|
+
this.movePt.y += dy;
|
|
90
|
+
this.place();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
moveTo({ x, y }: Point): void {
|
|
94
|
+
this.movePt.x = x;
|
|
95
|
+
this.movePt.y = y;
|
|
96
|
+
this.place();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
abstract resize(size: number | Bounds): void;
|
|
100
|
+
|
|
101
|
+
abstract clipi(line: Line): void;
|
|
102
|
+
|
|
103
|
+
abstract makeClone(): Geometry;
|
|
104
|
+
|
|
105
|
+
clone(): Geometry {
|
|
106
|
+
const geom = this.makeClone();
|
|
107
|
+
geom.moveTo(this.movePt);
|
|
108
|
+
geom.dragTo(this.dragPt);
|
|
109
|
+
return geom;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
clip(line: Readonly<Line>): Point {
|
|
113
|
+
const [{ x: ax, y: ay }, { x: bx, y: by }] = line;
|
|
114
|
+
const b: Point = { x: bx, y: by };
|
|
115
|
+
const newLine: Line = [{ x: ax, y: ay }, b];
|
|
116
|
+
this.clipi(newLine);
|
|
117
|
+
return b;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
intersects(geometry: Geometry): boolean {
|
|
121
|
+
// Check if the geometries' AABBs overlap on either X or Y axes.
|
|
122
|
+
// clang-format off
|
|
123
|
+
return !(
|
|
124
|
+
geometry.bottom > this.top ||
|
|
125
|
+
geometry.top < this.bottom ||
|
|
126
|
+
geometry.left > this.right ||
|
|
127
|
+
geometry.right < this.left
|
|
128
|
+
);
|
|
129
|
+
// clang-format on
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
lineIntersects(a: Point, b: Point): boolean {
|
|
133
|
+
return lineIntersectsBox(a, b, { x: this.left, y: this.bottom }, this);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
contains(geometry: Geometry): boolean {
|
|
137
|
+
// treating both geometries as AABBs, is geometry contained inside this?
|
|
138
|
+
// clang-format off
|
|
139
|
+
return (
|
|
140
|
+
geometry.left >= this.left &&
|
|
141
|
+
geometry.right <= this.right &&
|
|
142
|
+
geometry.top <= this.top &&
|
|
143
|
+
geometry.bottom >= this.bottom
|
|
144
|
+
);
|
|
145
|
+
// clang-format on
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* For this geometry type, calculate the tightest bounds that would fit these
|
|
150
|
+
* children. Naively, calculates the rectangular bounds. Circle, for instance,
|
|
151
|
+
* could calculate a tighter bounds.
|
|
152
|
+
*/
|
|
153
|
+
tightestFit(children: Iterable<Geometry>): Bounds & Point {
|
|
154
|
+
let minX = Number.MAX_SAFE_INTEGER;
|
|
155
|
+
let minY = Number.MAX_SAFE_INTEGER;
|
|
156
|
+
let maxX = Number.MIN_SAFE_INTEGER;
|
|
157
|
+
let maxY = Number.MIN_SAFE_INTEGER;
|
|
158
|
+
|
|
159
|
+
for (const { x, y, width, height } of children) {
|
|
160
|
+
const halfWidth = width / 2;
|
|
161
|
+
const halfHeight = height / 2;
|
|
162
|
+
minX = Math.min(minX, x - halfWidth);
|
|
163
|
+
minY = Math.min(minY, y - halfHeight);
|
|
164
|
+
maxX = Math.max(maxX, x + halfWidth);
|
|
165
|
+
maxY = Math.max(maxY, y + halfHeight);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (minX > maxX) [minX, maxX] = [maxX, minX];
|
|
169
|
+
if (minY > maxY) [minY, maxY] = [maxY, minY];
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
// Divide and add, for numerical stability.
|
|
173
|
+
x: maxX / 2 + minX / 2,
|
|
174
|
+
y: maxY / 2 + minY / 2,
|
|
175
|
+
width: maxX - minX,
|
|
176
|
+
height: maxY - minY,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fitTo(children: Iterable<Geometry>, padding = 0): void {
|
|
181
|
+
const fit = this.tightestFit(children);
|
|
182
|
+
this.resize({
|
|
183
|
+
height: fit.height + padding * 2,
|
|
184
|
+
width: fit.width + padding * 2,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// fit is new center point of geometry bounds. Normalize children by moving
|
|
188
|
+
// them by -fit, and adjust this geometry by fit to compensate the center.
|
|
189
|
+
this.move(fit);
|
|
190
|
+
const delta: Point = { x: -fit.x, y: -fit.y };
|
|
191
|
+
for (const child of children) child.move(delta);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Adjust second point in `source` to lie on `target`. If the lines are
|
|
197
|
+
* nonintersecting, source is unchanged.
|
|
198
|
+
*/
|
|
199
|
+
export function clipi(source: Line, target: Line): void {
|
|
200
|
+
const [{ x: ax1, y: ay1 }, { x: ax2, y: ay2 }] = source;
|
|
201
|
+
const [{ x: bx1, y: by1 }, { x: bx2, y: by2 }] = target;
|
|
202
|
+
|
|
203
|
+
const axd = ax2 - ax1;
|
|
204
|
+
const ayd = ay2 - ay1;
|
|
205
|
+
|
|
206
|
+
const bxd = bx2 - bx1;
|
|
207
|
+
const byd = by2 - by1;
|
|
208
|
+
|
|
209
|
+
const sxd = bx1 - ax1;
|
|
210
|
+
const syd = by1 - ay1;
|
|
211
|
+
|
|
212
|
+
// Adapted from en.wikipedia.org/wiki/Intersection_(Euclidean_geometry)#Two_line_segments
|
|
213
|
+
|
|
214
|
+
// In parametric form:
|
|
215
|
+
// ax(s) = ax1 + s * axd
|
|
216
|
+
// ay(s) = ay1 + s * ayd
|
|
217
|
+
// bx(t) = bx1 + t * bxd
|
|
218
|
+
// by(t) = by1 + t * byd
|
|
219
|
+
|
|
220
|
+
// Results in the linear equations
|
|
221
|
+
// s * axd - t * bxd = sxd
|
|
222
|
+
// s * ayd - t * byd = syd
|
|
223
|
+
|
|
224
|
+
// Solving s, t by Cramer's rule
|
|
225
|
+
const s = (sxd * -byd - syd * -bxd) / (axd * -byd - ayd * -bxd);
|
|
226
|
+
const t = (axd * syd - ayd * sxd) / (axd * -byd - ayd * -bxd);
|
|
227
|
+
|
|
228
|
+
if (0 <= s && t <= 1) {
|
|
229
|
+
// There is an intersection! Update source parametrically
|
|
230
|
+
source[1].x = ax1 + axd * s;
|
|
231
|
+
source[1].y = ay1 + ayd * s;
|
|
232
|
+
}
|
|
233
|
+
// no intersection, all that to do nothing
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Given a line and a circle, update the second point on the line to lie on the
|
|
238
|
+
* circle closest to the first point on the line.
|
|
239
|
+
*/
|
|
240
|
+
export function clipicircle(line: Line, point: Point, radius: number): void {
|
|
241
|
+
let [{ x: x1, y: y1 }, { x: x2, y: y2 }] = line;
|
|
242
|
+
|
|
243
|
+
// Normalize so that circle is at (0, 0)
|
|
244
|
+
x1 -= point.x;
|
|
245
|
+
x2 -= point.x;
|
|
246
|
+
y1 -= point.y;
|
|
247
|
+
y2 -= point.y;
|
|
248
|
+
|
|
249
|
+
// Line is:
|
|
250
|
+
// x(t) = x1 + t dx
|
|
251
|
+
// y(t) = y1 + t dy - r^
|
|
252
|
+
//
|
|
253
|
+
// Circle is: (a^ shorthand for a squared)
|
|
254
|
+
//
|
|
255
|
+
// 0 = x^ + y^ - r^
|
|
256
|
+
//
|
|
257
|
+
// Substituting gives
|
|
258
|
+
//
|
|
259
|
+
// = (x1 + t dx)^ + (y1 + t dy)^ - r^
|
|
260
|
+
//
|
|
261
|
+
// Simplifying gives
|
|
262
|
+
// = x1^ + 2 dx x1 t + dx^ t^ + y1^ + 2 dy y1 t + dy^ t^ - r^
|
|
263
|
+
// = dx^ dy^ t^ + 2 (dx x1 + dy y1) t + x1^ + y1^ - r^
|
|
264
|
+
//
|
|
265
|
+
// Quadratic equation setup for t is therefore
|
|
266
|
+
//
|
|
267
|
+
// a = dx^ + dy^
|
|
268
|
+
// b = 2 (dx x1 + dy y1)
|
|
269
|
+
// c = x1^ + y1^ - r^2
|
|
270
|
+
|
|
271
|
+
const dx = x2 - x1;
|
|
272
|
+
const dy = y2 - y1;
|
|
273
|
+
|
|
274
|
+
const a = dx * dx + dy * dy;
|
|
275
|
+
const b = 2 * (dx * x1 + dy * y1);
|
|
276
|
+
const c = x1 * x1 + y1 * y1 - radius * radius;
|
|
277
|
+
|
|
278
|
+
const det = b * b - 4 * a * c;
|
|
279
|
+
|
|
280
|
+
let t = 0;
|
|
281
|
+
if (det < 0) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const aa = 2 * a;
|
|
285
|
+
if (det < EPSILON) {
|
|
286
|
+
t = -b / aa;
|
|
287
|
+
} else {
|
|
288
|
+
// Use stable quadratic from
|
|
289
|
+
// http://people.csail.mit.edu/bkph/articles/Quadratics.pdf
|
|
290
|
+
const basis = -b - Math.sqrt(det);
|
|
291
|
+
let t1: number;
|
|
292
|
+
let t2: number;
|
|
293
|
+
if (b >= 0) {
|
|
294
|
+
t1 = basis / (2 * a);
|
|
295
|
+
t2 = (2 * c) / basis;
|
|
296
|
+
} else {
|
|
297
|
+
t1 = (2 * c) / basis;
|
|
298
|
+
t2 = basis / (2 * a);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Use best t (smallest t between 0 and 1). All return cases are for
|
|
302
|
+
// intersections outside the line segment.
|
|
303
|
+
if (t1 < 0) {
|
|
304
|
+
if (t2 < 0) return;
|
|
305
|
+
if (t2 > 1) return;
|
|
306
|
+
t = t2;
|
|
307
|
+
} else if (t1 > 1) {
|
|
308
|
+
if (t2 < 0) return;
|
|
309
|
+
if (t2 > 1) return;
|
|
310
|
+
t = t2;
|
|
311
|
+
} else {
|
|
312
|
+
t = Math.min(t1, t2);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
line[1].x = line[0].x + t * dx;
|
|
317
|
+
line[1].y = line[0].y + t * dy;
|
|
318
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// d3 has path libraries, however, they don't work on ReadonlyArrays or
|
|
2
|
+
// Readonly<PointTuple>, and I'm trying to keep the amount of copying from
|
|
3
|
+
// various Point forms down. Additionally, they use arbitrary precision
|
|
4
|
+
// floating point arithmetic, when really most of the time there's no need for
|
|
5
|
+
// more than 1 or 2 decimals of precision. Further significant digits only
|
|
6
|
+
// serve to make the path more difficult for humans to read.
|
|
7
|
+
|
|
8
|
+
import type { Box, Point } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
/** A box that everything fits into. */
|
|
11
|
+
export const FULL_BOX = {
|
|
12
|
+
x: -Number.MAX_VALUE,
|
|
13
|
+
y: -Number.MAX_VALUE,
|
|
14
|
+
width: Infinity,
|
|
15
|
+
height: Infinity,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// An enum that indicates whether each side of a pair of points are inside or
|
|
19
|
+
// outside a box. the first half indicates the location of the first point
|
|
20
|
+
// (inside or outside), and the second word indicates the location of the second
|
|
21
|
+
// point. See `testInBox` for usage.
|
|
22
|
+
const IntersectionPoint = {
|
|
23
|
+
OUTSIDE_OUTSIDE: 0,
|
|
24
|
+
OUTSIDE_INSIDE: 1,
|
|
25
|
+
INSIDE_OUTSIDE: 2,
|
|
26
|
+
INSIDE_INSIDE: 3,
|
|
27
|
+
UNSET: Number.MAX_VALUE,
|
|
28
|
+
} as const;
|
|
29
|
+
type IntersectionPoint = (typeof IntersectionPoint)[keyof typeof IntersectionPoint];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create an SVG `d` attribute encoding a straight-line path between absolutely
|
|
33
|
+
* positioned points, truncating their decimal values to a certain precision. If
|
|
34
|
+
* points lie outside the given box, use non-drawing `Move` commands.
|
|
35
|
+
*/
|
|
36
|
+
export function straightLinePath(
|
|
37
|
+
points: ReadonlyArray<Readonly<Point>>,
|
|
38
|
+
box: Box = FULL_BOX,
|
|
39
|
+
precision = 2,
|
|
40
|
+
): string {
|
|
41
|
+
// This method uses a combination of the interpreter pattern and a register-
|
|
42
|
+
// based execution machine. The input tape is the list of `Point` values. The
|
|
43
|
+
// output is held internally in the `commands` array, which holds a list of
|
|
44
|
+
// SVG d path commands:
|
|
45
|
+
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
|
|
46
|
+
//
|
|
47
|
+
// The commands available for the interpreter are `move`, `line`, `inBox`,
|
|
48
|
+
// `testInOutIntersection`, and `testOutInIntersection`. "Registers" are
|
|
49
|
+
// documented inline.
|
|
50
|
+
|
|
51
|
+
// The output tap, a list of `d` drawing commands.
|
|
52
|
+
const commands: string[] = [];
|
|
53
|
+
|
|
54
|
+
// lastX and lastY track where the command being calculated will end. They are
|
|
55
|
+
// updated at various points in the algorithm, before calling `command` to
|
|
56
|
+
// generate a line command using lastX, lastY as the <x, y> values for that
|
|
57
|
+
// command.
|
|
58
|
+
let lastX = points[0].x;
|
|
59
|
+
let lastY = points[0].y;
|
|
60
|
+
|
|
61
|
+
// Start by moving to the first point in the list.
|
|
62
|
+
move();
|
|
63
|
+
|
|
64
|
+
// And end there if we only have a single point.
|
|
65
|
+
if (points.length === 1) return commands[0];
|
|
66
|
+
|
|
67
|
+
// left, right, top, bottom are the box constants, used to test for ray/box
|
|
68
|
+
// intersections.
|
|
69
|
+
const boxLeft = box.x;
|
|
70
|
+
const boxRight = boxLeft + box.width;
|
|
71
|
+
const boxBottom = box.y;
|
|
72
|
+
const boxTop = boxBottom + box.height;
|
|
73
|
+
|
|
74
|
+
if (boxRight <= boxLeft || boxTop <= boxBottom) {
|
|
75
|
+
// If you end up here debugging, then this probably should have been an
|
|
76
|
+
// error case. The following string will help you determine what's going on.
|
|
77
|
+
// `path::straightLinePath: degenerate box: ${boxString()}`
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
//
|
|
82
|
+
// Error Reporting Utilities
|
|
83
|
+
//
|
|
84
|
+
|
|
85
|
+
function boxString(): string {
|
|
86
|
+
return `t ${boxTop} r ${boxRight} b ${boxBottom} l ${boxLeft}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function doThrow(message: string): never {
|
|
90
|
+
const last = `${lastX},${lastY}`;
|
|
91
|
+
const current = `${currentX},${currentY}`;
|
|
92
|
+
const fullMessage = `${message} <from ${last} to ${current} with box ${boxString()}>`;
|
|
93
|
+
throw new Error(`path::straighLinePath: ${fullMessage}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
//
|
|
97
|
+
// Drawing commands
|
|
98
|
+
//
|
|
99
|
+
|
|
100
|
+
// set lastX and lastY to the current position & increment i for the next loop
|
|
101
|
+
function advance() {
|
|
102
|
+
// set last point to current point
|
|
103
|
+
lastX = currentX;
|
|
104
|
+
lastY = currentY;
|
|
105
|
+
// increment i
|
|
106
|
+
i++;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Write an M or L command using lastX lastY at a given precision.
|
|
110
|
+
function command(c: "M" | "L") {
|
|
111
|
+
commands.push(`${c}${lastX.toFixed(precision)},${lastY.toFixed(precision)}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function move() {
|
|
115
|
+
// If the last command was a (M)ove, replace it with this (M)ove.
|
|
116
|
+
if (commands.length > 0 && commands[commands.length - 1][0] === "M") {
|
|
117
|
+
commands.pop();
|
|
118
|
+
}
|
|
119
|
+
command("M");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function line() {
|
|
123
|
+
command("L");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
//
|
|
127
|
+
// Interesction & Box Tests
|
|
128
|
+
//
|
|
129
|
+
|
|
130
|
+
function inBox(x: number, y: number): boolean {
|
|
131
|
+
return x >= boxLeft && x <= boxRight && y <= boxTop && y >= boxBottom;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Set up the "registers" for running the drawing operations
|
|
135
|
+
// testInteresection uses a register inspired approach. The setup for each
|
|
136
|
+
// test is the same, but to cut down on parameter passing, these local
|
|
137
|
+
// registers keep the test values.
|
|
138
|
+
let directionX = 0;
|
|
139
|
+
let directionY = 0;
|
|
140
|
+
let magnitude = 0;
|
|
141
|
+
let normalX = 0;
|
|
142
|
+
let normalY = 0;
|
|
143
|
+
let invX = 0;
|
|
144
|
+
let invY = 0;
|
|
145
|
+
let tTop = 0;
|
|
146
|
+
let tLeft = 0;
|
|
147
|
+
let tRight = 0;
|
|
148
|
+
let tBottom = 0;
|
|
149
|
+
let tBest = 0;
|
|
150
|
+
|
|
151
|
+
function testInBox() {
|
|
152
|
+
if (isNaN(lastX) || isNaN(lastY) || isNaN(currentX) || isNaN(currentY)) {
|
|
153
|
+
inBoxTest = IntersectionPoint.UNSET;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// The IntersectionPoint is a bit enum, with bit 0 determining whether
|
|
157
|
+
// the current point is in the box, and bit 1 controlling whether the
|
|
158
|
+
// last point is in the box. Adding them together gives the four states.
|
|
159
|
+
const lastIn = inBox(lastX, lastY) ? 2 : 0;
|
|
160
|
+
const currentIn = inBox(currentX, currentY) ? 1 : 0;
|
|
161
|
+
inBoxTest = (lastIn + currentIn) as IntersectionPoint;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// In normalized coordinates, find the t parameter for intersection of the
|
|
165
|
+
// four edges of the box.
|
|
166
|
+
function setupIntersectionTest() {
|
|
167
|
+
directionX = currentX - lastX;
|
|
168
|
+
directionY = currentY - lastY;
|
|
169
|
+
|
|
170
|
+
magnitude = Math.sqrt(directionX * directionX + directionY * directionY);
|
|
171
|
+
if (magnitude === 0) {
|
|
172
|
+
throw new Error(`path: last <${lastX}, ${lastY}> set to current <${currentX}, ${currentY}>`);
|
|
173
|
+
}
|
|
174
|
+
normalX = directionX / magnitude;
|
|
175
|
+
normalY = directionY / magnitude;
|
|
176
|
+
|
|
177
|
+
invX = 1 / normalX;
|
|
178
|
+
invY = 1 / normalY;
|
|
179
|
+
|
|
180
|
+
tLeft = (boxLeft - lastX) * invX;
|
|
181
|
+
tTop = (boxTop - lastY) * invY;
|
|
182
|
+
tRight = (boxRight - lastX) * invX;
|
|
183
|
+
tBottom = (boxBottom - lastY) * invY;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// After calculated a new tBest based on the normal, update lastX lastY to
|
|
187
|
+
// be at the point of intersection.
|
|
188
|
+
function updateToPointOfIntersection() {
|
|
189
|
+
lastX += normalX * tBest;
|
|
190
|
+
lastY += normalY * tBest;
|
|
191
|
+
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// find the point of intersection with the box closest to l0 towards l1. If
|
|
196
|
+
// it exists, update last to that point and return true. Otherwise, return
|
|
197
|
+
// false. Algorithm for testing ray/box intersection is available many
|
|
198
|
+
// places, eg
|
|
199
|
+
// https://www.scratchapixel.com/lessons/3d-basic-rendering/minimal-ray-tracer-rendering-simple-shapes/ray-box-intersection
|
|
200
|
+
// https://medium.com/@bromanz/another-view-on-the-classic-ray-aabb-intersection-algorithm-for-bvh-traversal-41125138b525
|
|
201
|
+
function testOutInIntersection(): boolean {
|
|
202
|
+
setupIntersectionTest();
|
|
203
|
+
|
|
204
|
+
const tsmallx = Math.min(tLeft, tRight);
|
|
205
|
+
const tsmally = Math.min(tTop, tBottom);
|
|
206
|
+
const tbigx = Math.max(tLeft, tRight);
|
|
207
|
+
const tbigy = Math.max(tTop, tBottom);
|
|
208
|
+
|
|
209
|
+
tBest = Math.max(0, Math.max(tsmallx, tsmally));
|
|
210
|
+
const tMax = Math.min(tbigx, tbigy);
|
|
211
|
+
|
|
212
|
+
if (tBest > tMax) return false;
|
|
213
|
+
if (tBest <= 0) {
|
|
214
|
+
if (tMax > 0) {
|
|
215
|
+
tBest = tMax;
|
|
216
|
+
} else {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// If the best point is beyond the current point, this doesn't intersect.
|
|
221
|
+
if (tBest / magnitude > 1) return false;
|
|
222
|
+
|
|
223
|
+
return updateToPointOfIntersection();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Find point of intersection from within the box closest to l0 towards l1.
|
|
227
|
+
// This is a slightly different algorithm than above, because
|
|
228
|
+
// testOutInIntersection is optimized for a ray intersecting the exterior (a
|
|
229
|
+
// convex polygon), while testInOutIntersection must look for the interior (a
|
|
230
|
+
// concave polycon).
|
|
231
|
+
function testInOutIntersection(): boolean {
|
|
232
|
+
setupIntersectionTest();
|
|
233
|
+
|
|
234
|
+
// Find the smallest intersection t that is greater than 0.
|
|
235
|
+
tBest = Number.MAX_VALUE;
|
|
236
|
+
if (tLeft > 0 && tLeft < tBest) tBest = tLeft;
|
|
237
|
+
if (tRight > 0 && tRight < tBest) tBest = tRight;
|
|
238
|
+
if (tTop > 0 && tTop < tBest) tBest = tTop;
|
|
239
|
+
if (tBottom > 0 && tBottom < tBest) tBest = tBottom;
|
|
240
|
+
|
|
241
|
+
if (tBest === Number.MAX_VALUE) return false;
|
|
242
|
+
|
|
243
|
+
return updateToPointOfIntersection();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
//
|
|
247
|
+
// Main
|
|
248
|
+
//
|
|
249
|
+
|
|
250
|
+
// The index into `points` for the current <x, y>.
|
|
251
|
+
let i = 1;
|
|
252
|
+
// The {x, y} values at points[i].
|
|
253
|
+
let currentX: number, currentY: number;
|
|
254
|
+
let inBoxTest: IntersectionPoint = IntersectionPoint.UNSET;
|
|
255
|
+
|
|
256
|
+
// This loop walks through the list of points. The path cursor has already
|
|
257
|
+
// been (M)oved to the first point in the path. From here, it will attempt to
|
|
258
|
+
// (L)ine from point to point while inside the box, (M)ove from point to point
|
|
259
|
+
// while outside the box, and create new points at the path/bounding box
|
|
260
|
+
// intersection.
|
|
261
|
+
while (i < points.length) {
|
|
262
|
+
currentX = points[i].x;
|
|
263
|
+
currentY = points[i].y;
|
|
264
|
+
|
|
265
|
+
testInBox();
|
|
266
|
+
|
|
267
|
+
switch (inBoxTest as IntersectionPoint) {
|
|
268
|
+
case IntersectionPoint.OUTSIDE_OUTSIDE:
|
|
269
|
+
// line from last to current intersects the box
|
|
270
|
+
if (testOutInIntersection()) {
|
|
271
|
+
// testOutInIntersection set last point to intersection point
|
|
272
|
+
move();
|
|
273
|
+
} else {
|
|
274
|
+
// No intersection, so the line doesn't cross the box. Advance to the
|
|
275
|
+
// current (still outside) box point, basically throwing away this
|
|
276
|
+
// point and continue the next iteration.
|
|
277
|
+
advance();
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
case IntersectionPoint.OUTSIDE_INSIDE:
|
|
281
|
+
if (!testOutInIntersection()) {
|
|
282
|
+
return doThrow("no outside to inside intersection");
|
|
283
|
+
}
|
|
284
|
+
// testOutInIntersection set last point to intersection point. Move the
|
|
285
|
+
// line to start at the edge of the box.
|
|
286
|
+
move();
|
|
287
|
+
break;
|
|
288
|
+
case IntersectionPoint.INSIDE_INSIDE:
|
|
289
|
+
advance();
|
|
290
|
+
// L to current point
|
|
291
|
+
line();
|
|
292
|
+
break;
|
|
293
|
+
case IntersectionPoint.INSIDE_OUTSIDE:
|
|
294
|
+
if (!testInOutIntersection()) {
|
|
295
|
+
return doThrow("no inside to outside intersection");
|
|
296
|
+
}
|
|
297
|
+
// L to point of intersection
|
|
298
|
+
line();
|
|
299
|
+
advance();
|
|
300
|
+
break;
|
|
301
|
+
default:
|
|
302
|
+
return doThrow(
|
|
303
|
+
`invalid box test state ${
|
|
304
|
+
Object.keys(IntersectionPoint).find(
|
|
305
|
+
(k) => IntersectionPoint[k as keyof typeof IntersectionPoint] === inBoxTest,
|
|
306
|
+
) ?? inBoxTest
|
|
307
|
+
}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// If we only have one command, that means the line isn't in the box and
|
|
313
|
+
// there is nothing to display.
|
|
314
|
+
return commands.length > 1 ? commands.join(" ") : "";
|
|
315
|
+
}
|