@js-draw/math 1.17.0 → 1.18.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/dist/cjs/Mat33.js +6 -1
- package/dist/cjs/Vec3.d.ts +2 -1
- package/dist/cjs/Vec3.js +5 -7
- package/dist/cjs/lib.d.ts +2 -1
- package/dist/cjs/lib.js +5 -1
- package/dist/cjs/shapes/BezierJSWrapper.d.ts +4 -0
- package/dist/cjs/shapes/BezierJSWrapper.js +35 -0
- package/dist/cjs/shapes/LineSegment2.d.ts +11 -0
- package/dist/cjs/shapes/LineSegment2.js +26 -1
- package/dist/cjs/shapes/Parameterized2DShape.d.ts +6 -1
- package/dist/cjs/shapes/Parameterized2DShape.js +6 -1
- package/dist/cjs/shapes/Path.d.ts +96 -12
- package/dist/cjs/shapes/Path.js +338 -15
- package/dist/cjs/shapes/QuadraticBezier.d.ts +2 -3
- package/dist/cjs/shapes/QuadraticBezier.js +2 -3
- package/dist/cjs/shapes/Rect2.d.ts +6 -1
- package/dist/cjs/shapes/Rect2.js +5 -1
- package/dist/cjs/utils/convexHull2Of.d.ts +9 -0
- package/dist/cjs/utils/convexHull2Of.js +61 -0
- package/dist/cjs/utils/convexHull2Of.test.d.ts +1 -0
- package/dist/mjs/Mat33.mjs +6 -1
- package/dist/mjs/Vec3.d.ts +2 -1
- package/dist/mjs/Vec3.mjs +5 -7
- package/dist/mjs/lib.d.ts +2 -1
- package/dist/mjs/lib.mjs +2 -1
- package/dist/mjs/shapes/BezierJSWrapper.d.ts +4 -0
- package/dist/mjs/shapes/BezierJSWrapper.mjs +35 -0
- package/dist/mjs/shapes/LineSegment2.d.ts +11 -0
- package/dist/mjs/shapes/LineSegment2.mjs +26 -1
- package/dist/mjs/shapes/Parameterized2DShape.d.ts +6 -1
- package/dist/mjs/shapes/Parameterized2DShape.mjs +6 -1
- package/dist/mjs/shapes/Path.d.ts +96 -12
- package/dist/mjs/shapes/Path.mjs +335 -14
- package/dist/mjs/shapes/QuadraticBezier.d.ts +2 -3
- package/dist/mjs/shapes/QuadraticBezier.mjs +2 -3
- package/dist/mjs/shapes/Rect2.d.ts +6 -1
- package/dist/mjs/shapes/Rect2.mjs +5 -1
- package/dist/mjs/utils/convexHull2Of.d.ts +9 -0
- package/dist/mjs/utils/convexHull2Of.mjs +59 -0
- package/dist/mjs/utils/convexHull2Of.test.d.ts +1 -0
- package/package.json +2 -2
- package/src/Mat33.ts +8 -2
- package/src/Vec3.test.ts +16 -0
- package/src/Vec3.ts +7 -8
- package/src/lib.ts +3 -0
- package/src/shapes/BezierJSWrapper.ts +41 -0
- package/src/shapes/LineSegment2.test.ts +26 -0
- package/src/shapes/LineSegment2.ts +31 -1
- package/src/shapes/Parameterized2DShape.ts +6 -1
- package/src/shapes/Path.test.ts +173 -5
- package/src/shapes/Path.ts +390 -18
- package/src/shapes/QuadraticBezier.test.ts +21 -0
- package/src/shapes/QuadraticBezier.ts +2 -3
- package/src/shapes/Rect2.ts +6 -2
- package/src/utils/convexHull2Of.test.ts +43 -0
- package/src/utils/convexHull2Of.ts +71 -0
package/src/shapes/Path.ts
CHANGED
@@ -8,6 +8,8 @@ import PointShape2D from './PointShape2D';
|
|
8
8
|
import toRoundedString from '../rounding/toRoundedString';
|
9
9
|
import toStringOfSamePrecision from '../rounding/toStringOfSamePrecision';
|
10
10
|
import Parameterized2DShape from './Parameterized2DShape';
|
11
|
+
import BezierJSWrapper from './BezierJSWrapper';
|
12
|
+
import convexHull2Of from '../utils/convexHull2Of';
|
11
13
|
|
12
14
|
export enum PathCommandType {
|
13
15
|
LineTo,
|
@@ -47,13 +49,22 @@ export interface IntersectionResult {
|
|
47
49
|
// @internal
|
48
50
|
curveIndex: number;
|
49
51
|
|
50
|
-
/** Parameter value for the closest point **on** the path to the intersection. @internal
|
51
|
-
parameterValue
|
52
|
+
/** Parameter value for the closest point **on** the path to the intersection. @internal */
|
53
|
+
parameterValue: number;
|
52
54
|
|
53
55
|
/** Point at which the intersection occured. */
|
54
56
|
point: Point2;
|
55
57
|
}
|
56
58
|
|
59
|
+
/** Options for {@link Path.splitNear} and {@link Path.splitAt} */
|
60
|
+
export interface PathSplitOptions {
|
61
|
+
/**
|
62
|
+
* Allows mapping points on newly added segments. This is useful, for example,
|
63
|
+
* to round points to prevent long decimals when later saving.
|
64
|
+
*/
|
65
|
+
mapNewPoint?: (point: Point2)=>Point2;
|
66
|
+
}
|
67
|
+
|
57
68
|
/**
|
58
69
|
* Allows indexing a particular part of a path.
|
59
70
|
*
|
@@ -64,8 +75,64 @@ export interface CurveIndexRecord {
|
|
64
75
|
parameterValue: number;
|
65
76
|
}
|
66
77
|
|
78
|
+
/** Returns a positive number if `a` comes after `b`, 0 if equal, and negative otherwise. */
|
79
|
+
export const compareCurveIndices = (a: CurveIndexRecord, b: CurveIndexRecord) => {
|
80
|
+
const indexCompare = a.curveIndex - b.curveIndex;
|
81
|
+
if (indexCompare === 0) {
|
82
|
+
return a.parameterValue - b.parameterValue;
|
83
|
+
} else {
|
84
|
+
return indexCompare;
|
85
|
+
}
|
86
|
+
};
|
87
|
+
|
88
|
+
/**
|
89
|
+
* Returns a version of `index` with its parameter value incremented by `stepBy`
|
90
|
+
* (which can be either positive or negative).
|
91
|
+
*/
|
92
|
+
export const stepCurveIndexBy = (index: CurveIndexRecord, stepBy: number): CurveIndexRecord => {
|
93
|
+
if (index.parameterValue + stepBy > 1) {
|
94
|
+
return { curveIndex: index.curveIndex + 1, parameterValue: index.parameterValue + stepBy - 1 };
|
95
|
+
}
|
96
|
+
if (index.parameterValue + stepBy < 0) {
|
97
|
+
if (index.curveIndex === 0) {
|
98
|
+
return { curveIndex: 0, parameterValue: 0 };
|
99
|
+
}
|
100
|
+
return { curveIndex: index.curveIndex - 1, parameterValue: index.parameterValue + stepBy + 1 };
|
101
|
+
}
|
102
|
+
|
103
|
+
return { curveIndex: index.curveIndex, parameterValue: index.parameterValue + stepBy };
|
104
|
+
};
|
105
|
+
|
67
106
|
/**
|
68
107
|
* Represents a union of lines and curves.
|
108
|
+
*
|
109
|
+
* To create a path from a string, see {@link fromString}.
|
110
|
+
*
|
111
|
+
* @example
|
112
|
+
* ```ts,runnable,console
|
113
|
+
* import {Path, Mat33, Vec2, LineSegment2} from '@js-draw/math';
|
114
|
+
*
|
115
|
+
* // Creates a path from an SVG path string.
|
116
|
+
* // In this case,
|
117
|
+
* // 1. Move to (0,0)
|
118
|
+
* // 2. Line to (100,0)
|
119
|
+
* const path = Path.fromString('M0,0 L100,0');
|
120
|
+
*
|
121
|
+
* // Logs the distance from (10,0) to the curve 1 unit
|
122
|
+
* // away from path. This curve forms a stroke with the path at
|
123
|
+
* // its center.
|
124
|
+
* const strokeRadius = 1;
|
125
|
+
* console.log(path.signedDistance(Vec2.of(10,0), strokeRadius));
|
126
|
+
*
|
127
|
+
* // Log a version of the path that's scaled by a factor of 4.
|
128
|
+
* console.log(path.transformedBy(Mat33.scaling2D(4)).toString());
|
129
|
+
*
|
130
|
+
* // Log all intersections of a stroked version of the path with
|
131
|
+
* // a vertical line segment.
|
132
|
+
* // (Try removing the `strokeRadius` parameter).
|
133
|
+
* const segment = new LineSegment2(Vec2.of(5, -100), Vec2.of(5, 100));
|
134
|
+
* console.log(path.intersection(segment, strokeRadius).map(i => i.point));
|
135
|
+
* ```
|
69
136
|
*/
|
70
137
|
export class Path {
|
71
138
|
/**
|
@@ -100,6 +167,12 @@ export class Path {
|
|
100
167
|
}
|
101
168
|
}
|
102
169
|
|
170
|
+
/**
|
171
|
+
* Computes and returns the full bounding box for this path.
|
172
|
+
*
|
173
|
+
* If a slight over-estimate of a path's bounding box is sufficient, use
|
174
|
+
* {@link bbox} instead.
|
175
|
+
*/
|
103
176
|
public getExactBBox(): Rect2 {
|
104
177
|
const bboxes: Rect2[] = [];
|
105
178
|
for (const part of this.geometry) {
|
@@ -249,7 +322,20 @@ export class Path {
|
|
249
322
|
return Rect2.bboxOf(points);
|
250
323
|
}
|
251
324
|
|
252
|
-
/**
|
325
|
+
/**
|
326
|
+
* Returns the signed distance between `point` and a curve `strokeRadius` units
|
327
|
+
* away from this path.
|
328
|
+
*
|
329
|
+
* This returns the **signed distance**, which means that points inside this shape
|
330
|
+
* have their distance negated. For example,
|
331
|
+
* ```ts,runnable,console
|
332
|
+
* import {Path, Vec2} from '@js-draw/math';
|
333
|
+
* console.log(Path.fromString('m0,0 L100,0').signedDistance(Vec2.zero, 1));
|
334
|
+
* ```
|
335
|
+
* would print `-1` because (0,0) is on `m0,0 L100,0` and thus one unit away from its boundary.
|
336
|
+
*
|
337
|
+
* **Note**: `strokeRadius = strokeWidth / 2`
|
338
|
+
*/
|
253
339
|
public signedDistance(point: Point2, strokeRadius: number) {
|
254
340
|
let minDist = Infinity;
|
255
341
|
|
@@ -367,7 +453,7 @@ export class Path {
|
|
367
453
|
|
368
454
|
|
369
455
|
// Raymarch:
|
370
|
-
const maxRaymarchSteps =
|
456
|
+
const maxRaymarchSteps = 8;
|
371
457
|
|
372
458
|
// Start raymarching from each of these points. This allows detection of multiple
|
373
459
|
// intersections.
|
@@ -458,7 +544,7 @@ export class Path {
|
|
458
544
|
if (lastPart && isOnLineSegment && Math.abs(lastDist) < stoppingThreshold) {
|
459
545
|
result.push({
|
460
546
|
point: currentPoint,
|
461
|
-
parameterValue:
|
547
|
+
parameterValue: lastPart.nearestPointTo(currentPoint).parameterValue,
|
462
548
|
curve: lastPart,
|
463
549
|
curveIndex: this.geometry.indexOf(lastPart),
|
464
550
|
});
|
@@ -507,6 +593,10 @@ export class Path {
|
|
507
593
|
return [];
|
508
594
|
}
|
509
595
|
|
596
|
+
if (this.parts.length === 0) {
|
597
|
+
return new Path(this.startPoint, [{ kind: PathCommandType.MoveTo, point: this.startPoint }]).intersection(line, strokeRadius);
|
598
|
+
}
|
599
|
+
|
510
600
|
let index = 0;
|
511
601
|
for (const part of this.geometry) {
|
512
602
|
const intersections = part.argIntersectsLineSegment(line);
|
@@ -538,9 +628,6 @@ export class Path {
|
|
538
628
|
|
539
629
|
/**
|
540
630
|
* @returns the nearest point on this path to the given `point`.
|
541
|
-
*
|
542
|
-
* @internal
|
543
|
-
* @beta
|
544
631
|
*/
|
545
632
|
public nearestPointTo(point: Point2): IntersectionResult {
|
546
633
|
// Find the closest point on this
|
@@ -570,6 +657,9 @@ export class Path {
|
|
570
657
|
}
|
571
658
|
|
572
659
|
public at(index: CurveIndexRecord) {
|
660
|
+
if (index.curveIndex === 0 && index.parameterValue === 0) {
|
661
|
+
return this.startPoint;
|
662
|
+
}
|
573
663
|
return this.geometry[index.curveIndex].at(index.parameterValue);
|
574
664
|
}
|
575
665
|
|
@@ -577,6 +667,246 @@ export class Path {
|
|
577
667
|
return this.geometry[index.curveIndex].tangentAt(index.parameterValue);
|
578
668
|
}
|
579
669
|
|
670
|
+
/** Splits this path in two near the given `point`. */
|
671
|
+
public splitNear(point: Point2, options?: PathSplitOptions) {
|
672
|
+
const nearest = this.nearestPointTo(point);
|
673
|
+
return this.splitAt(nearest, options);
|
674
|
+
}
|
675
|
+
|
676
|
+
/**
|
677
|
+
* Returns a copy of this path with `deleteFrom` until `deleteUntil` replaced with `insert`.
|
678
|
+
*
|
679
|
+
* This method is analogous to {@link Array.toSpliced}.
|
680
|
+
*/
|
681
|
+
public spliced(deleteFrom: CurveIndexRecord, deleteTo: CurveIndexRecord, insert: Path|undefined, options?: PathSplitOptions): Path {
|
682
|
+
const isBeforeOrEqual = (a: CurveIndexRecord, b: CurveIndexRecord) => {
|
683
|
+
return a.curveIndex < b.curveIndex || (a.curveIndex === b.curveIndex && a.parameterValue <= b.parameterValue);
|
684
|
+
};
|
685
|
+
|
686
|
+
if (isBeforeOrEqual(deleteFrom, deleteTo)) {
|
687
|
+
// deleteFrom deleteTo
|
688
|
+
// <---------| |-------------->
|
689
|
+
// x x
|
690
|
+
// startPoint endPoint
|
691
|
+
const firstSplit = this.splitAt(deleteFrom, options);
|
692
|
+
const secondSplit = this.splitAt(deleteTo, options);
|
693
|
+
const before = firstSplit[0];
|
694
|
+
const after = secondSplit[secondSplit.length - 1];
|
695
|
+
return insert ? before.union(insert).union(after) : before.union(after);
|
696
|
+
} else {
|
697
|
+
// In this case, we need to handle wrapping at the start/end.
|
698
|
+
// deleteTo deleteFrom
|
699
|
+
// <---------| keep |-------------->
|
700
|
+
// x x
|
701
|
+
// startPoint endPoint
|
702
|
+
const splitAtFrom = this.splitAt([deleteFrom], options);
|
703
|
+
const beforeFrom = splitAtFrom[0];
|
704
|
+
|
705
|
+
// We need splitNear, rather than splitAt, because beforeFrom does not have
|
706
|
+
// the same indexing as this.
|
707
|
+
const splitAtTo = beforeFrom.splitNear(this.at(deleteTo), options);
|
708
|
+
|
709
|
+
const betweenBoth = splitAtTo[splitAtTo.length - 1];
|
710
|
+
return insert ? betweenBoth.union(insert) : betweenBoth;
|
711
|
+
}
|
712
|
+
}
|
713
|
+
|
714
|
+
public splitAt(at: CurveIndexRecord, options?: PathSplitOptions): [Path]|[Path, Path];
|
715
|
+
public splitAt(at: CurveIndexRecord[], options?: PathSplitOptions): Path[];
|
716
|
+
|
717
|
+
// @internal
|
718
|
+
public splitAt(splitAt: CurveIndexRecord[]|CurveIndexRecord, options?: PathSplitOptions): Path[] {
|
719
|
+
if (!Array.isArray(splitAt)) {
|
720
|
+
splitAt = [splitAt];
|
721
|
+
}
|
722
|
+
|
723
|
+
splitAt = [...splitAt];
|
724
|
+
splitAt.sort(compareCurveIndices);
|
725
|
+
|
726
|
+
//
|
727
|
+
// Bounds checking & reversal.
|
728
|
+
//
|
729
|
+
|
730
|
+
while (
|
731
|
+
splitAt.length > 0
|
732
|
+
&& splitAt[splitAt.length - 1].curveIndex >= this.parts.length - 1
|
733
|
+
&& splitAt[splitAt.length - 1].parameterValue >= 1
|
734
|
+
) {
|
735
|
+
splitAt.pop();
|
736
|
+
}
|
737
|
+
|
738
|
+
splitAt.reverse(); // .reverse() <-- We're `.pop`ing from the end
|
739
|
+
|
740
|
+
while (
|
741
|
+
splitAt.length > 0
|
742
|
+
&& splitAt[splitAt.length - 1].curveIndex <= 0
|
743
|
+
&& splitAt[splitAt.length - 1].parameterValue <= 0
|
744
|
+
) {
|
745
|
+
splitAt.pop();
|
746
|
+
}
|
747
|
+
|
748
|
+
if (splitAt.length === 0 || this.parts.length === 0) {
|
749
|
+
return [this];
|
750
|
+
}
|
751
|
+
|
752
|
+
const expectedSplitCount = splitAt.length + 1;
|
753
|
+
const mapNewPoint = options?.mapNewPoint ?? ((p: Point2)=>p);
|
754
|
+
|
755
|
+
const result: Path[] = [];
|
756
|
+
let currentStartPoint = this.startPoint;
|
757
|
+
let currentPath: PathCommand[] = [];
|
758
|
+
|
759
|
+
//
|
760
|
+
// Splitting
|
761
|
+
//
|
762
|
+
|
763
|
+
let { curveIndex, parameterValue } = splitAt.pop()!;
|
764
|
+
|
765
|
+
for (let i = 0; i < this.parts.length; i ++) {
|
766
|
+
if (i !== curveIndex) {
|
767
|
+
currentPath.push(this.parts[i]);
|
768
|
+
} else {
|
769
|
+
let part = this.parts[i];
|
770
|
+
let geom = this.geometry[i];
|
771
|
+
while (i === curveIndex) {
|
772
|
+
let newPathStart: Point2;
|
773
|
+
const newPath: PathCommand[] = [];
|
774
|
+
|
775
|
+
switch (part.kind) {
|
776
|
+
case PathCommandType.MoveTo:
|
777
|
+
currentPath.push({
|
778
|
+
kind: part.kind,
|
779
|
+
point: part.point,
|
780
|
+
});
|
781
|
+
newPathStart = part.point;
|
782
|
+
break;
|
783
|
+
case PathCommandType.LineTo:
|
784
|
+
{
|
785
|
+
const split = (geom as LineSegment2).splitAt(parameterValue);
|
786
|
+
currentPath.push({
|
787
|
+
kind: part.kind,
|
788
|
+
point: mapNewPoint(split[0].p2),
|
789
|
+
});
|
790
|
+
newPathStart = split[0].p2;
|
791
|
+
if (split.length > 1) {
|
792
|
+
console.assert(split.length === 2);
|
793
|
+
newPath.push({
|
794
|
+
kind: part.kind,
|
795
|
+
|
796
|
+
// Don't map: For lines, the end point of the split is
|
797
|
+
// the same as the end point of the original:
|
798
|
+
point: split[1]!.p2,
|
799
|
+
});
|
800
|
+
geom = split[1]!;
|
801
|
+
}
|
802
|
+
}
|
803
|
+
break;
|
804
|
+
case PathCommandType.QuadraticBezierTo:
|
805
|
+
case PathCommandType.CubicBezierTo:
|
806
|
+
{
|
807
|
+
const split = (geom as BezierJSWrapper).splitAt(parameterValue);
|
808
|
+
let isFirstPart = split.length === 2;
|
809
|
+
for (const segment of split) {
|
810
|
+
geom = segment;
|
811
|
+
const targetArray = isFirstPart ? currentPath : newPath;
|
812
|
+
const controlPoints = segment.getPoints();
|
813
|
+
if (part.kind === PathCommandType.CubicBezierTo) {
|
814
|
+
targetArray.push({
|
815
|
+
kind: part.kind,
|
816
|
+
controlPoint1: mapNewPoint(controlPoints[1]),
|
817
|
+
controlPoint2: mapNewPoint(controlPoints[2]),
|
818
|
+
endPoint: mapNewPoint(controlPoints[3]),
|
819
|
+
});
|
820
|
+
} else {
|
821
|
+
targetArray.push({
|
822
|
+
kind: part.kind,
|
823
|
+
controlPoint: mapNewPoint(controlPoints[1]),
|
824
|
+
endPoint: mapNewPoint(controlPoints[2]),
|
825
|
+
});
|
826
|
+
}
|
827
|
+
|
828
|
+
// We want the start of the new path to match the start of the
|
829
|
+
// FIRST Bézier in the NEW path.
|
830
|
+
if (!isFirstPart) {
|
831
|
+
newPathStart = controlPoints[0];
|
832
|
+
}
|
833
|
+
isFirstPart = false;
|
834
|
+
}
|
835
|
+
}
|
836
|
+
break;
|
837
|
+
default: {
|
838
|
+
const exhaustivenessCheck: never = part;
|
839
|
+
return exhaustivenessCheck;
|
840
|
+
}
|
841
|
+
}
|
842
|
+
|
843
|
+
result.push(new Path(currentStartPoint, [...currentPath]));
|
844
|
+
currentStartPoint = mapNewPoint(newPathStart!);
|
845
|
+
console.assert(!!currentStartPoint, 'should have a start point');
|
846
|
+
currentPath = newPath;
|
847
|
+
part = newPath[newPath.length - 1] ?? part;
|
848
|
+
|
849
|
+
const nextSplit = splitAt.pop();
|
850
|
+
if (!nextSplit) {
|
851
|
+
break;
|
852
|
+
} else {
|
853
|
+
curveIndex = nextSplit.curveIndex;
|
854
|
+
if (i === curveIndex) {
|
855
|
+
const originalPoint = this.at(nextSplit);
|
856
|
+
parameterValue = geom.nearestPointTo(originalPoint).parameterValue;
|
857
|
+
currentPath = [];
|
858
|
+
} else {
|
859
|
+
parameterValue = nextSplit.parameterValue;
|
860
|
+
}
|
861
|
+
}
|
862
|
+
}
|
863
|
+
}
|
864
|
+
}
|
865
|
+
|
866
|
+
result.push(new Path(currentStartPoint, currentPath));
|
867
|
+
|
868
|
+
console.assert(
|
869
|
+
result.length === expectedSplitCount,
|
870
|
+
`should split into splitAt.length + 1 splits (was ${result.length}, expected ${expectedSplitCount})`
|
871
|
+
);
|
872
|
+
return result;
|
873
|
+
}
|
874
|
+
|
875
|
+
/**
|
876
|
+
* Replaces all `MoveTo` commands with `LineTo` commands and connects the end point of this
|
877
|
+
* path to the start point.
|
878
|
+
*/
|
879
|
+
public asClosed() {
|
880
|
+
const newParts: PathCommand[] = [];
|
881
|
+
let hasChanges = false;
|
882
|
+
for (const part of this.parts) {
|
883
|
+
if (part.kind === PathCommandType.MoveTo) {
|
884
|
+
newParts.push({
|
885
|
+
kind: PathCommandType.LineTo,
|
886
|
+
point: part.point,
|
887
|
+
});
|
888
|
+
hasChanges = true;
|
889
|
+
} else {
|
890
|
+
newParts.push(part);
|
891
|
+
}
|
892
|
+
}
|
893
|
+
if (!this.getEndPoint().eq(this.startPoint)) {
|
894
|
+
newParts.push({
|
895
|
+
kind: PathCommandType.LineTo,
|
896
|
+
point: this.startPoint,
|
897
|
+
});
|
898
|
+
hasChanges = true;
|
899
|
+
}
|
900
|
+
|
901
|
+
if (!hasChanges) {
|
902
|
+
return this;
|
903
|
+
}
|
904
|
+
|
905
|
+
const result = new Path(this.startPoint, newParts);
|
906
|
+
console.assert(result.getEndPoint().eq(result.startPoint));
|
907
|
+
return result;
|
908
|
+
}
|
909
|
+
|
580
910
|
private static mapPathCommand(part: PathCommand, mapping: (point: Point2)=> Point2): PathCommand {
|
581
911
|
switch (part.kind) {
|
582
912
|
case PathCommandType.MoveTo:
|
@@ -626,9 +956,25 @@ export class Path {
|
|
626
956
|
return this.mapPoints(point => affineTransfm.transformVec2(point));
|
627
957
|
}
|
628
958
|
|
959
|
+
/**
|
960
|
+
* @internal
|
961
|
+
*/
|
962
|
+
public closedContainsPoint(point: Point2) {
|
963
|
+
const bbox = this.getExactBBox();
|
964
|
+
if (!bbox.containsPoint(point)) {
|
965
|
+
return false;
|
966
|
+
}
|
967
|
+
|
968
|
+
const pointOutside = point.plus(Vec2.of(bbox.width, 0));
|
969
|
+
const asClosed = this.asClosed();
|
970
|
+
|
971
|
+
const lineToOutside = new LineSegment2(point, pointOutside);
|
972
|
+
return asClosed.intersection(lineToOutside).length % 2 === 1;
|
973
|
+
}
|
974
|
+
|
629
975
|
// Creates a new path by joining [other] to the end of this path
|
630
976
|
public union(
|
631
|
-
other: Path|null,
|
977
|
+
other: Path|PathCommand[]|null,
|
632
978
|
|
633
979
|
// allowReverse: true iff reversing other or this is permitted if it means
|
634
980
|
// no moveTo command is necessary when unioning the paths.
|
@@ -637,6 +983,9 @@ export class Path {
|
|
637
983
|
if (!other) {
|
638
984
|
return this;
|
639
985
|
}
|
986
|
+
if (Array.isArray(other)) {
|
987
|
+
return new Path(this.startPoint, [...this.parts, ...other]);
|
988
|
+
}
|
640
989
|
|
641
990
|
const thisEnd = this.getEndPoint();
|
642
991
|
|
@@ -711,7 +1060,8 @@ export class Path {
|
|
711
1060
|
return new Path(newStart, newParts);
|
712
1061
|
}
|
713
1062
|
|
714
|
-
|
1063
|
+
/** Computes and returns the end point of this path */
|
1064
|
+
public getEndPoint() {
|
715
1065
|
if (this.parts.length === 0) {
|
716
1066
|
return this.startPoint;
|
717
1067
|
}
|
@@ -766,10 +1116,12 @@ export class Path {
|
|
766
1116
|
return false;
|
767
1117
|
}
|
768
1118
|
|
769
|
-
|
770
|
-
|
771
|
-
|
772
|
-
|
1119
|
+
/**
|
1120
|
+
* Treats this as a closed path and returns true if part of `rect` is *roughly* within
|
1121
|
+
* this path's interior.
|
1122
|
+
*
|
1123
|
+
* **Note**: Assumes that this is a closed, non-self-intersecting path.
|
1124
|
+
*/
|
773
1125
|
public closedRoughlyIntersects(rect: Rect2): boolean {
|
774
1126
|
if (rect.containsRect(this.bbox)) {
|
775
1127
|
return true;
|
@@ -1035,10 +1387,8 @@ export class Path {
|
|
1035
1387
|
/**
|
1036
1388
|
* Create a `Path` from a subset of the SVG path specification.
|
1037
1389
|
*
|
1038
|
-
*
|
1039
|
-
*
|
1040
|
-
* - Elliptical arcs are currently unsupported.
|
1041
|
-
* - TODO: Support `s`,`t` commands shorthands.
|
1390
|
+
* Currently, this does not support elliptical arcs or `s` and `t` command
|
1391
|
+
* shorthands. See https://github.com/personalizedrefrigerator/js-draw/pull/19.
|
1042
1392
|
*
|
1043
1393
|
* @example
|
1044
1394
|
* ```ts,runnable,console
|
@@ -1049,6 +1399,8 @@ export class Path {
|
|
1049
1399
|
* ```
|
1050
1400
|
*/
|
1051
1401
|
public static fromString(pathString: string): Path {
|
1402
|
+
// TODO: Support elliptical arcs, and the `s`, `t` command shorthands.
|
1403
|
+
//
|
1052
1404
|
// See the MDN reference:
|
1053
1405
|
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
|
1054
1406
|
// and
|
@@ -1236,6 +1588,26 @@ export class Path {
|
|
1236
1588
|
return result;
|
1237
1589
|
}
|
1238
1590
|
|
1591
|
+
public static fromConvexHullOf(points: Point2[]) {
|
1592
|
+
if (points.length === 0) {
|
1593
|
+
return Path.empty;
|
1594
|
+
}
|
1595
|
+
|
1596
|
+
const hull = convexHull2Of(points);
|
1597
|
+
|
1598
|
+
const commands = hull.slice(1).map((p): LinePathCommand => ({
|
1599
|
+
kind: PathCommandType.LineTo,
|
1600
|
+
point: p,
|
1601
|
+
}));
|
1602
|
+
// Close -- connect back to the start
|
1603
|
+
commands.push({
|
1604
|
+
kind: PathCommandType.LineTo,
|
1605
|
+
point: hull[0],
|
1606
|
+
});
|
1607
|
+
|
1608
|
+
return new Path(hull[0], commands);
|
1609
|
+
}
|
1610
|
+
|
1239
1611
|
// @internal TODO: At present, this isn't really an empty path.
|
1240
1612
|
public static empty: Path = new Path(Vec2.zero, []);
|
1241
1613
|
}
|
@@ -37,6 +37,9 @@ describe('QuadraticBezier', () => {
|
|
37
37
|
// Should not return an out-of-range parameter
|
38
38
|
[ new QuadraticBezier(Vec2.zero, Vec2.of(0, 0.5), Vec2.unitY), Vec2.of(0, -1000), 0 ],
|
39
39
|
[ new QuadraticBezier(Vec2.zero, Vec2.of(0, 0.5), Vec2.unitY), Vec2.of(0, 1000), 1 ],
|
40
|
+
|
41
|
+
// Edge case -- just a point
|
42
|
+
[ new QuadraticBezier(Vec2.zero, Vec2.zero, Vec2.zero), Vec2.of(0, 1000), 0 ],
|
40
43
|
])('nearestPointTo should return the nearest point and parameter value on %s to %s', (bezier, point, expectedParameter) => {
|
41
44
|
const nearest = bezier.nearestPointTo(point);
|
42
45
|
expect(nearest.parameterValue).toBeCloseTo(expectedParameter, 0.0001);
|
@@ -64,4 +67,22 @@ describe('QuadraticBezier', () => {
|
|
64
67
|
}
|
65
68
|
}
|
66
69
|
});
|
70
|
+
|
71
|
+
test.each([
|
72
|
+
new QuadraticBezier(Vec2.zero, Vec2.unitY, Vec2.unitY.times(2)),
|
73
|
+
new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY),
|
74
|
+
new QuadraticBezier(Vec2.zero, Vec2.unitY, Vec2.unitX),
|
75
|
+
])('.derivativeAt should return a derivative vector with the correct direction (curve: %s)', (curve) => {
|
76
|
+
for (let t = 0; t < 1; t += 0.1) {
|
77
|
+
const derivative = curve.derivativeAt(t);
|
78
|
+
const derivativeApprox = curve.at(t + 0.001).minus(curve.at(t - 0.001));
|
79
|
+
expect(derivativeApprox.normalized()).objEq(derivative.normalized(), 0.01);
|
80
|
+
}
|
81
|
+
});
|
82
|
+
|
83
|
+
test('should support Bezier-Bezier intersections', () => {
|
84
|
+
const b1 = new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY);
|
85
|
+
const b2 = new QuadraticBezier(Vec2.of(-1, 0.5), Vec2.of(0, 0.6), Vec2.of(1, 0.4));
|
86
|
+
expect(b1.intersectsBezier(b2)).toHaveLength(1);
|
87
|
+
});
|
67
88
|
});
|
@@ -4,10 +4,9 @@ import BezierJSWrapper from './BezierJSWrapper';
|
|
4
4
|
import Rect2 from './Rect2';
|
5
5
|
|
6
6
|
/**
|
7
|
-
*
|
7
|
+
* Represents a 2D Bézier curve.
|
8
8
|
*
|
9
|
-
*
|
10
|
-
* without loading it at all (e.g. `normal`, `at`, and `approximateDistance`).
|
9
|
+
* **Note**: Many Bézier operations use `bezier-js`'s.
|
11
10
|
*/
|
12
11
|
export class QuadraticBezier extends BezierJSWrapper {
|
13
12
|
public constructor(
|
package/src/shapes/Rect2.ts
CHANGED
@@ -4,7 +4,7 @@ import { Point2, Vec2 } from '../Vec2';
|
|
4
4
|
import Abstract2DShape from './Abstract2DShape';
|
5
5
|
import Vec3 from '../Vec3';
|
6
6
|
|
7
|
-
/** An object that can be converted to a Rect2. */
|
7
|
+
/** An object that can be converted to a {@link Rect2}. */
|
8
8
|
export interface RectTemplate {
|
9
9
|
x: number;
|
10
10
|
y: number;
|
@@ -14,7 +14,11 @@ export interface RectTemplate {
|
|
14
14
|
height?: number;
|
15
15
|
}
|
16
16
|
|
17
|
-
|
17
|
+
/**
|
18
|
+
* Represents a rectangle in 2D space, parallel to the XY axes.
|
19
|
+
*
|
20
|
+
* `invariant: w ≥ 0, h ≥ 0, immutable`
|
21
|
+
*/
|
18
22
|
export class Rect2 extends Abstract2DShape {
|
19
23
|
// Derived state:
|
20
24
|
|
@@ -0,0 +1,43 @@
|
|
1
|
+
import { Vec2 } from '../Vec2';
|
2
|
+
import { Rect2 } from '../shapes/Rect2';
|
3
|
+
import convexHull2Of from './convexHull2Of';
|
4
|
+
|
5
|
+
describe('convexHull2Of', () => {
|
6
|
+
it.each([
|
7
|
+
[ [ Vec2.of(1, 1) ] , [ Vec2.of(1, 1) ] ],
|
8
|
+
|
9
|
+
// Line
|
10
|
+
[ [ Vec2.of(1, 1), Vec2.of(2, 2) ] , [ Vec2.of(1, 1), Vec2.of(2, 2) ] ],
|
11
|
+
|
12
|
+
// Just a triangle
|
13
|
+
[ [ Vec2.of(1, 1), Vec2.of(4, 2), Vec2.of(3, 3) ] , [ Vec2.of(1, 1), Vec2.of(4, 2), Vec2.of(3, 3) ]],
|
14
|
+
|
15
|
+
// Triangle with an extra point
|
16
|
+
[ [ Vec2.of(1, 1), Vec2.of(2, 20), Vec2.of(3, 5), Vec2.of(4, 3) ] , [ Vec2.of(1, 1), Vec2.of(4, 3), Vec2.of(2, 20) ]],
|
17
|
+
|
18
|
+
// Points within a triangle
|
19
|
+
[
|
20
|
+
[ Vec2.of(28, 5), Vec2.of(4, 5), Vec2.of(-100, -100), Vec2.of(7, 120), Vec2.of(1, 8), Vec2.of(100, -100), Vec2.of(2, 4), Vec2.of(3, 4), Vec2.of(4, 5) ],
|
21
|
+
[ Vec2.of(-100, -100), Vec2.of(100, -100), Vec2.of(7, 120) ],
|
22
|
+
],
|
23
|
+
|
24
|
+
// Points within a triangle (repeated vertex)
|
25
|
+
[
|
26
|
+
[ Vec2.of(28, 5), Vec2.of(4, 5), Vec2.of(-100, -100), Vec2.of(-100, -100), Vec2.of(7, 120), Vec2.of(1, 8), Vec2.of(100, -100), Vec2.of(2, 4), Vec2.of(3, 4), Vec2.of(4, 5) ],
|
27
|
+
[ Vec2.of(-100, -100), Vec2.of(100, -100), Vec2.of(7, 120) ],
|
28
|
+
],
|
29
|
+
|
30
|
+
// Points within a square
|
31
|
+
[
|
32
|
+
[ Vec2.of(28, 5), Vec2.of(4, 5), Vec2.of(-100, -100), Vec2.of(100, 100), Vec2.of(7, 100), Vec2.of(1, 8), Vec2.of(-100, 100), Vec2.of(100, -100), Vec2.of(2, 4), Vec2.of(3, 4), Vec2.of(4, 5) ],
|
33
|
+
[ Vec2.of(-100, -100), Vec2.of(100, -100), Vec2.of(100, 100), Vec2.of(-100, 100) ],
|
34
|
+
],
|
35
|
+
|
36
|
+
[
|
37
|
+
Rect2.unitSquare.corners,
|
38
|
+
[ Vec2.of(1, 0), Vec2.of(1, 1), Vec2.of(0, 1), Vec2.of(0, 0) ],
|
39
|
+
]
|
40
|
+
])('should compute the convex hull of a set of points (%j)', (points, expected) => {
|
41
|
+
expect(convexHull2Of(points)).toMatchObject(expected);
|
42
|
+
});
|
43
|
+
});
|