@js-draw/math 1.0.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/Color4.d.ts +40 -0
  3. package/dist/cjs/Color4.js +102 -0
  4. package/dist/cjs/Color4.test.d.ts +1 -0
  5. package/dist/cjs/Mat33.test.d.ts +1 -0
  6. package/dist/cjs/Vec2.test.d.ts +1 -0
  7. package/dist/cjs/Vec3.test.d.ts +1 -0
  8. package/dist/cjs/polynomial/solveQuadratic.test.d.ts +1 -0
  9. package/dist/cjs/rounding.test.d.ts +1 -0
  10. package/dist/cjs/shapes/LineSegment2.test.d.ts +1 -0
  11. package/dist/cjs/shapes/Path.fromString.test.d.ts +1 -0
  12. package/dist/cjs/shapes/Path.test.d.ts +1 -0
  13. package/dist/cjs/shapes/Path.toString.test.d.ts +1 -0
  14. package/dist/cjs/shapes/QuadraticBezier.test.d.ts +1 -0
  15. package/dist/cjs/shapes/Rect2.test.d.ts +1 -0
  16. package/dist/cjs/shapes/Triangle.test.d.ts +1 -0
  17. package/dist/mjs/Color4.d.ts +40 -0
  18. package/dist/mjs/Color4.mjs +102 -0
  19. package/dist/mjs/Color4.test.d.ts +1 -0
  20. package/dist/mjs/Mat33.test.d.ts +1 -0
  21. package/dist/mjs/Vec2.test.d.ts +1 -0
  22. package/dist/mjs/Vec3.test.d.ts +1 -0
  23. package/dist/mjs/polynomial/solveQuadratic.test.d.ts +1 -0
  24. package/dist/mjs/rounding.test.d.ts +1 -0
  25. package/dist/mjs/shapes/LineSegment2.test.d.ts +1 -0
  26. package/dist/mjs/shapes/Path.fromString.test.d.ts +1 -0
  27. package/dist/mjs/shapes/Path.test.d.ts +1 -0
  28. package/dist/mjs/shapes/Path.toString.test.d.ts +1 -0
  29. package/dist/mjs/shapes/QuadraticBezier.test.d.ts +1 -0
  30. package/dist/mjs/shapes/Rect2.test.d.ts +1 -0
  31. package/dist/mjs/shapes/Triangle.test.d.ts +1 -0
  32. package/dist-test/test_imports/package-lock.json +13 -0
  33. package/dist-test/test_imports/package.json +12 -0
  34. package/dist-test/test_imports/test-imports.js +15 -0
  35. package/dist-test/test_imports/test-require.cjs +15 -0
  36. package/package.json +4 -3
  37. package/src/Color4.test.ts +94 -0
  38. package/src/Color4.ts +430 -0
  39. package/src/Mat33.test.ts +244 -0
  40. package/src/Mat33.ts +450 -0
  41. package/src/Vec2.test.ts +30 -0
  42. package/src/Vec2.ts +49 -0
  43. package/src/Vec3.test.ts +51 -0
  44. package/src/Vec3.ts +245 -0
  45. package/src/lib.ts +42 -0
  46. package/src/polynomial/solveQuadratic.test.ts +39 -0
  47. package/src/polynomial/solveQuadratic.ts +43 -0
  48. package/src/rounding.test.ts +65 -0
  49. package/src/rounding.ts +167 -0
  50. package/src/shapes/Abstract2DShape.ts +63 -0
  51. package/src/shapes/BezierJSWrapper.ts +93 -0
  52. package/src/shapes/CubicBezier.ts +35 -0
  53. package/src/shapes/LineSegment2.test.ts +99 -0
  54. package/src/shapes/LineSegment2.ts +232 -0
  55. package/src/shapes/Path.fromString.test.ts +223 -0
  56. package/src/shapes/Path.test.ts +309 -0
  57. package/src/shapes/Path.toString.test.ts +77 -0
  58. package/src/shapes/Path.ts +963 -0
  59. package/src/shapes/PointShape2D.ts +33 -0
  60. package/src/shapes/QuadraticBezier.test.ts +31 -0
  61. package/src/shapes/QuadraticBezier.ts +142 -0
  62. package/src/shapes/Rect2.test.ts +209 -0
  63. package/src/shapes/Rect2.ts +346 -0
  64. package/src/shapes/Triangle.test.ts +61 -0
  65. package/src/shapes/Triangle.ts +139 -0
@@ -0,0 +1,963 @@
1
+ import { toRoundedString, toStringOfSamePrecision } from '../rounding';
2
+ import LineSegment2 from './LineSegment2';
3
+ import Mat33 from '../Mat33';
4
+ import Rect2 from './Rect2';
5
+ import { Point2, Vec2 } from '../Vec2';
6
+ import Abstract2DShape from './Abstract2DShape';
7
+ import CubicBezier from './CubicBezier';
8
+ import QuadraticBezier from './QuadraticBezier';
9
+ import PointShape2D from './PointShape2D';
10
+
11
+ export enum PathCommandType {
12
+ LineTo,
13
+ MoveTo,
14
+ CubicBezierTo,
15
+ QuadraticBezierTo,
16
+ }
17
+
18
+ export interface CubicBezierPathCommand {
19
+ kind: PathCommandType.CubicBezierTo;
20
+ controlPoint1: Point2;
21
+ controlPoint2: Point2;
22
+ endPoint: Point2;
23
+ }
24
+
25
+ export interface QuadraticBezierPathCommand {
26
+ kind: PathCommandType.QuadraticBezierTo;
27
+ controlPoint: Point2;
28
+ endPoint: Point2;
29
+ }
30
+
31
+ export interface LinePathCommand {
32
+ kind: PathCommandType.LineTo;
33
+ point: Point2;
34
+ }
35
+
36
+ export interface MoveToPathCommand {
37
+ kind: PathCommandType.MoveTo;
38
+ point: Point2;
39
+ }
40
+
41
+ export type PathCommand = CubicBezierPathCommand | QuadraticBezierPathCommand | MoveToPathCommand | LinePathCommand;
42
+
43
+ interface IntersectionResult {
44
+ // @internal
45
+ curve: Abstract2DShape;
46
+
47
+ /** @internal @deprecated */
48
+ parameterValue?: number;
49
+
50
+ // Point at which the intersection occured.
51
+ point: Point2;
52
+ }
53
+
54
+ type GeometryType = Abstract2DShape;
55
+ type GeometryArrayType = Array<GeometryType>;
56
+
57
+ /**
58
+ * Represents a union of lines and curves.
59
+ */
60
+ export class Path {
61
+ /**
62
+ * A rough estimate of the bounding box of the path.
63
+ * A slight overestimate.
64
+ * See {@link getExactBBox}
65
+ */
66
+ public readonly bbox: Rect2;
67
+
68
+ public constructor(public readonly startPoint: Point2, public readonly parts: PathCommand[]) {
69
+ // Initial bounding box contains one point: the start point.
70
+ this.bbox = Rect2.bboxOf([startPoint]);
71
+
72
+ // Convert into a representation of the geometry (cache for faster intersection
73
+ // calculation)
74
+ for (const part of parts) {
75
+ this.bbox = this.bbox.union(Path.computeBBoxForSegment(startPoint, part));
76
+ }
77
+ }
78
+
79
+ public getExactBBox(): Rect2 {
80
+ const bboxes: Rect2[] = [];
81
+ for (const part of this.geometry) {
82
+ bboxes.push(part.getTightBoundingBox());
83
+ }
84
+
85
+ return Rect2.union(...bboxes);
86
+ }
87
+
88
+ private cachedGeometry: GeometryArrayType|null = null;
89
+
90
+ // Lazy-loads and returns this path's geometry
91
+ public get geometry(): GeometryArrayType {
92
+ if (this.cachedGeometry) {
93
+ return this.cachedGeometry;
94
+ }
95
+
96
+ let startPoint = this.startPoint;
97
+ const geometry: GeometryArrayType = [];
98
+
99
+ for (const part of this.parts) {
100
+ switch (part.kind) {
101
+ case PathCommandType.CubicBezierTo:
102
+ geometry.push(
103
+ new CubicBezier(
104
+ startPoint, part.controlPoint1, part.controlPoint2, part.endPoint
105
+ )
106
+ );
107
+ startPoint = part.endPoint;
108
+ break;
109
+ case PathCommandType.QuadraticBezierTo:
110
+ geometry.push(
111
+ new QuadraticBezier(
112
+ startPoint, part.controlPoint, part.endPoint
113
+ )
114
+ );
115
+ startPoint = part.endPoint;
116
+ break;
117
+ case PathCommandType.LineTo:
118
+ geometry.push(
119
+ new LineSegment2(startPoint, part.point)
120
+ );
121
+ startPoint = part.point;
122
+ break;
123
+ case PathCommandType.MoveTo:
124
+ geometry.push(new PointShape2D(part.point));
125
+ startPoint = part.point;
126
+ break;
127
+ }
128
+ }
129
+
130
+ this.cachedGeometry = geometry;
131
+ return this.cachedGeometry;
132
+ }
133
+
134
+ private cachedPolylineApproximation: LineSegment2[]|null = null;
135
+
136
+ // Approximates this path with a group of line segments.
137
+ public polylineApproximation(): LineSegment2[] {
138
+ if (this.cachedPolylineApproximation) {
139
+ return this.cachedPolylineApproximation;
140
+ }
141
+
142
+ const points: Point2[] = [];
143
+
144
+ for (const part of this.parts) {
145
+ switch (part.kind) {
146
+ case PathCommandType.CubicBezierTo:
147
+ points.push(part.controlPoint1, part.controlPoint2, part.endPoint);
148
+ break;
149
+ case PathCommandType.QuadraticBezierTo:
150
+ points.push(part.controlPoint, part.endPoint);
151
+ break;
152
+ case PathCommandType.MoveTo:
153
+ case PathCommandType.LineTo:
154
+ points.push(part.point);
155
+ break;
156
+ }
157
+ }
158
+
159
+ const result: LineSegment2[] = [];
160
+ let prevPoint = this.startPoint;
161
+ for (const point of points) {
162
+ result.push(new LineSegment2(prevPoint, point));
163
+ prevPoint = point;
164
+ }
165
+
166
+ return result;
167
+ }
168
+
169
+ public static computeBBoxForSegment(startPoint: Point2, part: PathCommand): Rect2 {
170
+ const points = [startPoint];
171
+ let exhaustivenessCheck: never;
172
+ switch (part.kind) {
173
+ case PathCommandType.MoveTo:
174
+ case PathCommandType.LineTo:
175
+ points.push(part.point);
176
+ break;
177
+ case PathCommandType.CubicBezierTo:
178
+ points.push(part.controlPoint1, part.controlPoint2, part.endPoint);
179
+ break;
180
+ case PathCommandType.QuadraticBezierTo:
181
+ points.push(part.controlPoint, part.endPoint);
182
+ break;
183
+ default:
184
+ exhaustivenessCheck = part;
185
+ return exhaustivenessCheck;
186
+ }
187
+
188
+ return Rect2.bboxOf(points);
189
+ }
190
+
191
+ /**
192
+ * Let `S` be a closed path a distance `strokeRadius` from this path.
193
+ *
194
+ * @returns Approximate intersections of `line` with `S` using ray marching, starting from
195
+ * both end points of `line` and each point in `additionalRaymarchStartPoints`.
196
+ */
197
+ private raymarchIntersectionWith(
198
+ line: LineSegment2, strokeRadius: number, additionalRaymarchStartPoints: Point2[] = []
199
+ ): IntersectionResult[] {
200
+ // No intersection between bounding boxes: No possible intersection
201
+ // of the interior.
202
+ if (!line.bbox.intersects(this.bbox.grownBy(strokeRadius))) {
203
+ return [];
204
+ }
205
+
206
+ const lineLength = line.length;
207
+
208
+ type DistanceFunction = (point: Point2) => number;
209
+ type DistanceFunctionRecord = {
210
+ part: GeometryType,
211
+ bbox: Rect2,
212
+ distFn: DistanceFunction,
213
+ };
214
+ const partDistFunctionRecords: DistanceFunctionRecord[] = [];
215
+
216
+ // Determine distance functions for all parts that the given line could possibly intersect with
217
+ for (const part of this.geometry) {
218
+ const bbox = part.getTightBoundingBox().grownBy(strokeRadius);
219
+ if (!bbox.intersects(line.bbox)) {
220
+ continue;
221
+ }
222
+
223
+ // Signed distance function
224
+ const partDist: DistanceFunction = (point) => part.signedDistance(point);
225
+
226
+ // Part signed distance function (negative result implies `point` is
227
+ // inside the shape).
228
+ const partSdf = (point: Point2) => partDist(point) - strokeRadius;
229
+
230
+ // If the line can't possibly intersect the part,
231
+ if (partSdf(line.p1) > lineLength && partSdf(line.p2) > lineLength) {
232
+ continue;
233
+ }
234
+
235
+ partDistFunctionRecords.push({
236
+ part,
237
+ distFn: partDist,
238
+ bbox,
239
+ });
240
+ }
241
+
242
+ // If no distance functions, there are no intersections.
243
+ if (partDistFunctionRecords.length === 0) {
244
+ return [];
245
+ }
246
+
247
+ // Returns the minimum distance to a part in this stroke, where only parts that the given
248
+ // line could intersect are considered.
249
+ const sdf = (point: Point2): [GeometryType|null, number] => {
250
+ let minDist = Infinity;
251
+ let minDistPart: Abstract2DShape|null = null;
252
+
253
+ const uncheckedDistFunctions: DistanceFunctionRecord[] = [];
254
+
255
+ // First pass: only curves for which the current point is inside
256
+ // the bounding box.
257
+ for (const distFnRecord of partDistFunctionRecords) {
258
+ const { part, distFn, bbox } = distFnRecord;
259
+
260
+ // Check later if the current point isn't in the bounding box.
261
+ if (!bbox.containsPoint(point)) {
262
+ uncheckedDistFunctions.push(distFnRecord);
263
+ continue;
264
+ }
265
+
266
+ const currentDist = distFn(point);
267
+
268
+ if (currentDist <= minDist) {
269
+ minDist = currentDist;
270
+ minDistPart = part;
271
+ }
272
+ }
273
+
274
+ // Second pass: Everything else
275
+ for (const { part, distFn, bbox } of uncheckedDistFunctions) {
276
+ // Skip if impossible for the distance to the target to be lesser than
277
+ // the current minimum.
278
+ if (!bbox.grownBy(minDist).containsPoint(point)) {
279
+ continue;
280
+ }
281
+
282
+ const currentDist = distFn(point);
283
+
284
+ if (currentDist <= minDist) {
285
+ minDist = currentDist;
286
+ minDistPart = part;
287
+ }
288
+ }
289
+
290
+ return [ minDistPart, minDist - strokeRadius ];
291
+ };
292
+
293
+
294
+ // Raymarch:
295
+ const maxRaymarchSteps = 7;
296
+
297
+ // Start raymarching from each of these points. This allows detection of multiple
298
+ // intersections.
299
+ const startPoints = [
300
+ line.p1, ...additionalRaymarchStartPoints, line.p2
301
+ ];
302
+
303
+ // Converts a point ON THE LINE to a parameter
304
+ const pointToParameter = (point: Point2) => {
305
+ // Because line.direction is a unit vector, this computes the length
306
+ // of the projection of the vector(line.p1->point) onto line.direction.
307
+ //
308
+ // Note that this can be negative if the given point is outside of the given
309
+ // line segment.
310
+ return point.minus(line.p1).dot(line.direction);
311
+ };
312
+
313
+ // Sort start points by parameter on the line.
314
+ // This allows us to determine whether the current value of a parameter
315
+ // drops down to a value already tested.
316
+ startPoints.sort((a, b) => {
317
+ const t_a = pointToParameter(a);
318
+ const t_b = pointToParameter(b);
319
+
320
+ // Sort in increasing order
321
+ return t_a - t_b;
322
+ });
323
+
324
+ const result: IntersectionResult[] = [];
325
+
326
+ const stoppingThreshold = strokeRadius / 1000;
327
+
328
+ // Returns the maximum x value explored
329
+ const raymarchFrom = (
330
+ startPoint: Point2,
331
+
332
+ // Direction to march in (multiplies line.direction)
333
+ directionMultiplier: -1|1,
334
+
335
+ // Terminate if the current point corresponds to a parameter
336
+ // below this.
337
+ minimumLineParameter: number,
338
+ ): number|null => {
339
+ let currentPoint = startPoint;
340
+ let [lastPart, lastDist] = sdf(currentPoint);
341
+ let lastParameter = pointToParameter(currentPoint);
342
+
343
+ if (lastDist > lineLength) {
344
+ return lastParameter;
345
+ }
346
+
347
+ const direction = line.direction.times(directionMultiplier);
348
+
349
+ for (let i = 0; i < maxRaymarchSteps; i++) {
350
+ // Step in the direction of the edge of the shape.
351
+ const step = lastDist;
352
+ currentPoint = currentPoint.plus(direction.times(step));
353
+ lastParameter = pointToParameter(currentPoint);
354
+
355
+ // If we're below the minimum parameter, stop. We've already tried
356
+ // this.
357
+ if (lastParameter <= minimumLineParameter) {
358
+ return lastParameter;
359
+ }
360
+
361
+ const [currentPart, signedDist] = sdf(currentPoint);
362
+
363
+ // Ensure we're stepping in the correct direction.
364
+ // Note that because we could start with a negative distance and work towards a
365
+ // positive distance, we need absolute values here.
366
+ if (Math.abs(signedDist) > Math.abs(lastDist)) {
367
+ // If not, stop.
368
+ return null;
369
+ }
370
+
371
+ lastDist = signedDist;
372
+ lastPart = currentPart;
373
+
374
+ // Is the distance close enough that we can stop early?
375
+ if (Math.abs(lastDist) < stoppingThreshold) {
376
+ break;
377
+ }
378
+ }
379
+
380
+ // Ensure that the point we ended with is on the line.
381
+ const isOnLineSegment = lastParameter >= 0 && lastParameter <= lineLength;
382
+
383
+ if (lastPart && isOnLineSegment && Math.abs(lastDist) < stoppingThreshold) {
384
+ result.push({
385
+ point: currentPoint,
386
+ parameterValue: NaN,
387
+ curve: lastPart,
388
+ });
389
+ }
390
+
391
+ return lastParameter;
392
+ };
393
+
394
+ // The maximum value of the line's parameter explored so far (0 corresponds to
395
+ // line.p1)
396
+ let maxLineT = 0;
397
+
398
+ // Raymarch for each start point.
399
+ //
400
+ // Use a for (i from 0 to length) loop because startPoints may be added
401
+ // during iteration.
402
+ for (let i = 0; i < startPoints.length; i++) {
403
+ const startPoint = startPoints[i];
404
+
405
+ // Try raymarching in both directions.
406
+ maxLineT = Math.max(maxLineT, raymarchFrom(startPoint, 1, maxLineT) ?? maxLineT);
407
+ maxLineT = Math.max(maxLineT, raymarchFrom(startPoint, -1, maxLineT) ?? maxLineT);
408
+ }
409
+
410
+ return result;
411
+ }
412
+
413
+ /**
414
+ * Returns a list of intersections with this path. If `strokeRadius` is given,
415
+ * intersections are approximated with the surface `strokeRadius` away from this.
416
+ *
417
+ * If `strokeRadius > 0`, the resultant `parameterValue` has no defined value.
418
+ */
419
+ public intersection(line: LineSegment2, strokeRadius?: number): IntersectionResult[] {
420
+ let result: IntersectionResult[] = [];
421
+
422
+ // Is any intersection between shapes within the bounding boxes impossible?
423
+ if (!line.bbox.intersects(this.bbox.grownBy(strokeRadius ?? 0))) {
424
+ return [];
425
+ }
426
+
427
+ for (const part of this.geometry) {
428
+ const intersection = part.intersectsLineSegment(line);
429
+
430
+ if (intersection.length > 0) {
431
+ result.push({
432
+ curve: part,
433
+ point: intersection[0],
434
+ });
435
+ }
436
+ }
437
+
438
+ // If given a non-zero strokeWidth, attempt to raymarch.
439
+ // Even if raymarching, we need to collect starting points.
440
+ // We use the above-calculated intersections for this.
441
+ const doRaymarching = strokeRadius && strokeRadius > 1e-8;
442
+ if (doRaymarching) {
443
+ // Starting points for raymarching (in addition to the end points of the line).
444
+ const startPoints = result.map(intersection => intersection.point);
445
+ result = this.raymarchIntersectionWith(line, strokeRadius, startPoints);
446
+ }
447
+
448
+ return result;
449
+ }
450
+
451
+ private static mapPathCommand(part: PathCommand, mapping: (point: Point2)=> Point2): PathCommand {
452
+ switch (part.kind) {
453
+ case PathCommandType.MoveTo:
454
+ case PathCommandType.LineTo:
455
+ return {
456
+ kind: part.kind,
457
+ point: mapping(part.point),
458
+ };
459
+ break;
460
+ case PathCommandType.CubicBezierTo:
461
+ return {
462
+ kind: part.kind,
463
+ controlPoint1: mapping(part.controlPoint1),
464
+ controlPoint2: mapping(part.controlPoint2),
465
+ endPoint: mapping(part.endPoint),
466
+ };
467
+ break;
468
+ case PathCommandType.QuadraticBezierTo:
469
+ return {
470
+ kind: part.kind,
471
+ controlPoint: mapping(part.controlPoint),
472
+ endPoint: mapping(part.endPoint),
473
+ };
474
+ break;
475
+ }
476
+
477
+ const exhaustivenessCheck: never = part;
478
+ return exhaustivenessCheck;
479
+ }
480
+
481
+ public mapPoints(mapping: (point: Point2)=>Point2): Path {
482
+ const startPoint = mapping(this.startPoint);
483
+ const newParts: PathCommand[] = [];
484
+
485
+ for (const part of this.parts) {
486
+ newParts.push(Path.mapPathCommand(part, mapping));
487
+ }
488
+
489
+ return new Path(startPoint, newParts);
490
+ }
491
+
492
+ public transformedBy(affineTransfm: Mat33): Path {
493
+ if (affineTransfm.isIdentity()) {
494
+ return this;
495
+ }
496
+
497
+ return this.mapPoints(point => affineTransfm.transformVec2(point));
498
+ }
499
+
500
+ // Creates a new path by joining [other] to the end of this path
501
+ public union(other: Path|null): Path {
502
+ if (!other) {
503
+ return this;
504
+ }
505
+
506
+ return new Path(this.startPoint, [
507
+ ...this.parts,
508
+ {
509
+ kind: PathCommandType.MoveTo,
510
+ point: other.startPoint,
511
+ },
512
+ ...other.parts,
513
+ ]);
514
+ }
515
+
516
+ private getEndPoint() {
517
+ if (this.parts.length === 0) {
518
+ return this.startPoint;
519
+ }
520
+ const lastPart = this.parts[this.parts.length - 1];
521
+ if (lastPart.kind === PathCommandType.QuadraticBezierTo || lastPart.kind === PathCommandType.CubicBezierTo) {
522
+ return lastPart.endPoint;
523
+ } else {
524
+ return lastPart.point;
525
+ }
526
+ }
527
+
528
+ public roughlyIntersects(rect: Rect2, strokeWidth: number = 0) {
529
+ if (this.parts.length === 0) {
530
+ return rect.containsPoint(this.startPoint);
531
+ }
532
+ const isClosed = this.startPoint.eq(this.getEndPoint());
533
+
534
+ if (isClosed && strokeWidth === 0) {
535
+ return this.closedRoughlyIntersects(rect);
536
+ }
537
+
538
+ if (rect.containsRect(this.bbox)) {
539
+ return true;
540
+ }
541
+
542
+ // Does the rectangle intersect the bounding boxes of any of this' parts?
543
+ let startPoint = this.startPoint;
544
+ for (const part of this.parts) {
545
+ const bbox = Path.computeBBoxForSegment(startPoint, part).grownBy(strokeWidth);
546
+
547
+ if (part.kind === PathCommandType.LineTo || part.kind === PathCommandType.MoveTo) {
548
+ startPoint = part.point;
549
+ } else {
550
+ startPoint = part.endPoint;
551
+ }
552
+
553
+ if (rect.intersects(bbox)) {
554
+ return true;
555
+ }
556
+ }
557
+
558
+ return false;
559
+ }
560
+
561
+ // Treats this as a closed path and returns true if part of `rect` is roughly within
562
+ // this path's interior.
563
+ //
564
+ // Note: Assumes that this is a closed, non-self-intersecting path.
565
+ public closedRoughlyIntersects(rect: Rect2): boolean {
566
+ if (rect.containsRect(this.bbox)) {
567
+ return true;
568
+ }
569
+
570
+ // Choose a point outside of the path.
571
+ const startPt = this.bbox.topLeft.minus(Vec2.of(1, 1));
572
+ const testPts = rect.corners;
573
+ const polygon = this.polylineApproximation();
574
+
575
+ for (const point of testPts) {
576
+ const testLine = new LineSegment2(point, startPt);
577
+
578
+ let intersectionCount = 0;
579
+ for (const line of polygon) {
580
+ if (line.intersects(testLine)) {
581
+ intersectionCount ++;
582
+ }
583
+ }
584
+
585
+ // Odd? The point is within the polygon!
586
+ if (intersectionCount % 2 === 1) {
587
+ return true;
588
+ }
589
+ }
590
+
591
+ // Grow the rectangle for possible additional precision.
592
+ const grownRect = rect.grownBy(Math.min(rect.size.x, rect.size.y));
593
+ const edges: LineSegment2[] = [];
594
+ for (const subrect of grownRect.divideIntoGrid(4, 4)) {
595
+ edges.push(...subrect.getEdges());
596
+ }
597
+
598
+ for (const edge of edges) {
599
+ for (const line of polygon) {
600
+ if (edge.intersects(line)) {
601
+ return true;
602
+ }
603
+ }
604
+ }
605
+
606
+ // Even? Probably no intersection.
607
+ return false;
608
+ }
609
+
610
+ // Returns a path that outlines [rect]. If [lineWidth] is not given, the resultant path is
611
+ // the outline of [rect]. Otherwise, the resultant path represents a line of width [lineWidth]
612
+ // that traces [rect].
613
+ public static fromRect(rect: Rect2, lineWidth: number|null = null): Path {
614
+ const commands: PathCommand[] = [];
615
+
616
+ let corners;
617
+ let startPoint;
618
+
619
+ if (lineWidth !== null) {
620
+ // Vector from the top left corner or bottom right corner to the edge of the
621
+ // stroked region.
622
+ const cornerToEdge = Vec2.of(lineWidth, lineWidth).times(0.5);
623
+ const innerRect = Rect2.fromCorners(
624
+ rect.topLeft.plus(cornerToEdge),
625
+ rect.bottomRight.minus(cornerToEdge)
626
+ );
627
+ const outerRect = Rect2.fromCorners(
628
+ rect.topLeft.minus(cornerToEdge),
629
+ rect.bottomRight.plus(cornerToEdge)
630
+ );
631
+
632
+ corners = [
633
+ innerRect.corners[3],
634
+ ...innerRect.corners,
635
+ ...outerRect.corners.reverse(),
636
+ ];
637
+ startPoint = outerRect.corners[3];
638
+ } else {
639
+ corners = rect.corners.slice(1);
640
+ startPoint = rect.corners[0];
641
+ }
642
+
643
+ for (const corner of corners) {
644
+ commands.push({
645
+ kind: PathCommandType.LineTo,
646
+ point: corner,
647
+ });
648
+ }
649
+
650
+ // Close the shape
651
+ commands.push({
652
+ kind: PathCommandType.LineTo,
653
+ point: startPoint,
654
+ });
655
+
656
+ return new Path(startPoint, commands);
657
+ }
658
+
659
+ private cachedStringVersion: string|null = null;
660
+
661
+ public toString(useNonAbsCommands?: boolean): string {
662
+ if (this.cachedStringVersion) {
663
+ return this.cachedStringVersion;
664
+ }
665
+
666
+ if (useNonAbsCommands === undefined) {
667
+ // Hueristic: Try to determine whether converting absolute to relative commands is worth it.
668
+ useNonAbsCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10;
669
+ }
670
+
671
+ const result = Path.toString(this.startPoint, this.parts, !useNonAbsCommands);
672
+ this.cachedStringVersion = result;
673
+ return result;
674
+ }
675
+
676
+ public serialize(): string {
677
+ return this.toString();
678
+ }
679
+
680
+ // @param onlyAbsCommands - True if we should avoid converting absolute coordinates to relative offsets -- such
681
+ // conversions can lead to smaller output strings, but also take time.
682
+ public static toString(startPoint: Point2, parts: PathCommand[], onlyAbsCommands?: boolean): string {
683
+ const result: string[] = [];
684
+
685
+ let prevPoint: Point2|undefined;
686
+ const addCommand = (command: string, ...points: Point2[]) => {
687
+ const absoluteCommandParts: string[] = [];
688
+ const relativeCommandParts: string[] = [];
689
+ const makeAbsCommand = !prevPoint || onlyAbsCommands;
690
+ const roundedPrevX = prevPoint ? toRoundedString(prevPoint.x) : '';
691
+ const roundedPrevY = prevPoint ? toRoundedString(prevPoint.y) : '';
692
+
693
+ for (const point of points) {
694
+ const xComponent = toRoundedString(point.x);
695
+ const yComponent = toRoundedString(point.y);
696
+
697
+ // Relative commands are often shorter as strings than absolute commands.
698
+ if (!makeAbsCommand) {
699
+ const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint!.x, xComponent, roundedPrevX, roundedPrevY);
700
+ const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint!.y, yComponent, roundedPrevX, roundedPrevY);
701
+
702
+ // No need for an additional separator if it starts with a '-'
703
+ if (yComponentRelative.charAt(0) === '-') {
704
+ relativeCommandParts.push(`${xComponentRelative}${yComponentRelative}`);
705
+ } else {
706
+ relativeCommandParts.push(`${xComponentRelative},${yComponentRelative}`);
707
+ }
708
+ } else {
709
+ absoluteCommandParts.push(`${xComponent},${yComponent}`);
710
+ }
711
+ }
712
+
713
+ let commandString;
714
+ if (makeAbsCommand) {
715
+ commandString = `${command}${absoluteCommandParts.join(' ')}`;
716
+ } else {
717
+ commandString = `${command.toLowerCase()}${relativeCommandParts.join(' ')}`;
718
+ }
719
+
720
+ // Don't add no-ops.
721
+ if (commandString === 'l0,0' || commandString === 'm0,0') {
722
+ return;
723
+ }
724
+ result.push(commandString);
725
+
726
+ if (points.length > 0) {
727
+ prevPoint = points[points.length - 1];
728
+ }
729
+ };
730
+
731
+ // Don't add two moveTos in a row (this can happen if
732
+ // the start point corresponds to a moveTo _and_ the first command is
733
+ // also a moveTo)
734
+ if (parts[0]?.kind !== PathCommandType.MoveTo) {
735
+ addCommand('M', startPoint);
736
+ }
737
+
738
+ let exhaustivenessCheck: never;
739
+ for (let i = 0; i < parts.length; i++) {
740
+ const part = parts[i];
741
+
742
+ switch (part.kind) {
743
+ case PathCommandType.MoveTo:
744
+ addCommand('M', part.point);
745
+ break;
746
+ case PathCommandType.LineTo:
747
+ addCommand('L', part.point);
748
+ break;
749
+ case PathCommandType.CubicBezierTo:
750
+ addCommand('C', part.controlPoint1, part.controlPoint2, part.endPoint);
751
+ break;
752
+ case PathCommandType.QuadraticBezierTo:
753
+ addCommand('Q', part.controlPoint, part.endPoint);
754
+ break;
755
+ default:
756
+ exhaustivenessCheck = part;
757
+ return exhaustivenessCheck;
758
+ }
759
+ }
760
+
761
+ return result.join('');
762
+ }
763
+
764
+ /**
765
+ * Create a Path from a SVG path specification.
766
+ *
767
+ * ## To-do
768
+ * - TODO: Support a larger subset of SVG paths
769
+ * - Elliptical arcs are currently unsupported.
770
+ * - TODO: Support `s`,`t` commands shorthands.
771
+ */
772
+ public static fromString(pathString: string): Path {
773
+ // See the MDN reference:
774
+ // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
775
+ // and
776
+ // https://www.w3.org/TR/SVG2/paths.html
777
+
778
+ // Remove linebreaks
779
+ pathString = pathString.split('\n').join(' ');
780
+
781
+ let lastPos: Point2 = Vec2.zero;
782
+ let firstPos: Point2|null = null;
783
+ let startPos: Point2|null = null;
784
+ let isFirstCommand: boolean = true;
785
+ const commands: PathCommand[] = [];
786
+
787
+
788
+ const moveTo = (point: Point2) => {
789
+ // The first moveTo/lineTo is already handled by the [startPoint] parameter of the Path constructor.
790
+ if (isFirstCommand) {
791
+ isFirstCommand = false;
792
+ return;
793
+ }
794
+
795
+ commands.push({
796
+ kind: PathCommandType.MoveTo,
797
+ point,
798
+ });
799
+ };
800
+ const lineTo = (point: Point2) => {
801
+ if (isFirstCommand) {
802
+ isFirstCommand = false;
803
+ return;
804
+ }
805
+
806
+ commands.push({
807
+ kind: PathCommandType.LineTo,
808
+ point,
809
+ });
810
+ };
811
+ const cubicBezierTo = (cp1: Point2, cp2: Point2, end: Point2) => {
812
+ commands.push({
813
+ kind: PathCommandType.CubicBezierTo,
814
+ controlPoint1: cp1,
815
+ controlPoint2: cp2,
816
+ endPoint: end,
817
+ });
818
+ };
819
+ const quadraticBeierTo = (controlPoint: Point2, endPoint: Point2) => {
820
+ commands.push({
821
+ kind: PathCommandType.QuadraticBezierTo,
822
+ controlPoint,
823
+ endPoint,
824
+ });
825
+ };
826
+ const commandArgCounts: Record<string, number> = {
827
+ 'm': 1,
828
+ 'l': 1,
829
+ 'c': 3,
830
+ 'q': 2,
831
+ 'z': 0,
832
+ 'h': 1,
833
+ 'v': 1,
834
+ };
835
+
836
+ // Each command: Command character followed by anything that isn't a command character
837
+ const commandExp = /([MZLHVCSQTA])\s*([^MZLHVCSQTA]*)/ig;
838
+ let current;
839
+ while ((current = commandExp.exec(pathString)) !== null) {
840
+ const argParts = current[2].trim().split(/[^0-9Ee.-]/).filter(
841
+ part => part.length > 0
842
+ ).reduce((accumualtor: string[], current: string): string[] => {
843
+ // As of 09/2022, iOS Safari doesn't support support lookbehind in regular
844
+ // expressions. As such, we need an alternative.
845
+ // Because '-' can be used as a path separator, unless preceeded by an 'e' (as in 1e-5),
846
+ // we need special cases:
847
+ current = current.replace(/([^eE])[-]/g, '$1 -');
848
+ const parts = current.split(' -');
849
+ if (parts[0] !== '') {
850
+ accumualtor.push(parts[0]);
851
+ }
852
+ accumualtor.push(...parts.slice(1).map(part => `-${part}`));
853
+ return accumualtor;
854
+ }, []);
855
+
856
+ let numericArgs = argParts.map(arg => parseFloat(arg));
857
+
858
+ let commandChar = current[1].toLowerCase();
859
+ let uppercaseCommand = current[1] !== commandChar;
860
+
861
+ // Convert commands that don't take points into commands that do.
862
+ if (commandChar === 'v' || commandChar === 'h') {
863
+ numericArgs = numericArgs.reduce((accumulator: number[], current: number): number[] => {
864
+ if (commandChar === 'v') {
865
+ return accumulator.concat(uppercaseCommand ? lastPos.x : 0, current);
866
+ } else {
867
+ return accumulator.concat(current, uppercaseCommand ? lastPos.y : 0);
868
+ }
869
+ }, []);
870
+ commandChar = 'l';
871
+ } else if (commandChar === 'z') {
872
+ if (firstPos) {
873
+ numericArgs = [ firstPos.x, firstPos.y ];
874
+ firstPos = lastPos;
875
+ } else {
876
+ continue;
877
+ }
878
+
879
+ // 'z' always acts like an uppercase lineTo(startPos)
880
+ uppercaseCommand = true;
881
+ commandChar = 'l';
882
+ }
883
+
884
+
885
+ const commandArgCount: number = commandArgCounts[commandChar] ?? 0;
886
+ const allArgs = numericArgs.reduce((
887
+ accumulator: Point2[], current, index, parts
888
+ ): Point2[] => {
889
+ if (index % 2 !== 0) {
890
+ const currentAsFloat = current;
891
+ const prevAsFloat = parts[index - 1];
892
+ return accumulator.concat(Vec2.of(prevAsFloat, currentAsFloat));
893
+ } else {
894
+ return accumulator;
895
+ }
896
+ }, []).map((coordinate, index): Point2 => {
897
+ // Lowercase commands are relative, uppercase commands use absolute
898
+ // positioning
899
+ let newPos;
900
+ if (uppercaseCommand) {
901
+ newPos = coordinate;
902
+ } else {
903
+ newPos = lastPos.plus(coordinate);
904
+ }
905
+
906
+ if ((index + 1) % commandArgCount === 0) {
907
+ lastPos = newPos;
908
+ }
909
+
910
+ return newPos;
911
+ });
912
+
913
+ if (allArgs.length % commandArgCount !== 0) {
914
+ throw new Error([
915
+ `Incorrect number of arguments: got ${JSON.stringify(allArgs)} with a length of ${allArgs.length} ≠ ${commandArgCount}k, k ∈ ℤ.`,
916
+ `The number of arguments to ${commandChar} must be a multiple of ${commandArgCount}!`,
917
+ `Command: ${current[0]}`,
918
+ ].join('\n'));
919
+ }
920
+
921
+ for (let argPos = 0; argPos < allArgs.length; argPos += commandArgCount) {
922
+ const args = allArgs.slice(argPos, argPos + commandArgCount);
923
+
924
+ switch (commandChar.toLowerCase()) {
925
+ case 'm':
926
+ if (argPos === 0) {
927
+ moveTo(args[0]);
928
+ } else {
929
+ lineTo(args[0]);
930
+ }
931
+ break;
932
+ case 'l':
933
+ lineTo(args[0]);
934
+ break;
935
+ case 'c':
936
+ cubicBezierTo(args[0], args[1], args[2]);
937
+ break;
938
+ case 'q':
939
+ quadraticBeierTo(args[0], args[1]);
940
+ break;
941
+ default:
942
+ throw new Error(`Unknown path command ${commandChar}`);
943
+ }
944
+
945
+ isFirstCommand = false;
946
+ }
947
+
948
+ if (allArgs.length > 0) {
949
+ firstPos ??= allArgs[0];
950
+ startPos ??= firstPos;
951
+ lastPos = allArgs[allArgs.length - 1];
952
+ }
953
+ }
954
+
955
+ const result = new Path(startPos ?? Vec2.zero, commands);
956
+ result.cachedStringVersion = pathString;
957
+ return result;
958
+ }
959
+
960
+ // @internal TODO: At present, this isn't really an empty path.
961
+ public static empty: Path = new Path(Vec2.zero, []);
962
+ }
963
+ export default Path;