js-draw 0.0.1
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/.eslintrc.js +57 -0
- package/.husky/pre-commit +4 -0
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/__mocks__/coloris.ts +8 -0
- package/__mocks__/styleMock.js +1 -0
- package/dist/__mocks__/coloris.d.ts +2 -0
- package/dist/__mocks__/coloris.js +5 -0
- package/dist/build_tools/BundledFile.d.ts +12 -0
- package/dist/build_tools/BundledFile.js +153 -0
- package/dist/scripts/bundle.d.ts +1 -0
- package/dist/scripts/bundle.js +19 -0
- package/dist/scripts/watchBundle.d.ts +1 -0
- package/dist/scripts/watchBundle.js +9 -0
- package/dist/src/Color4.d.ts +23 -0
- package/dist/src/Color4.js +102 -0
- package/dist/src/Display.d.ts +22 -0
- package/dist/src/Display.js +93 -0
- package/dist/src/Editor.d.ts +55 -0
- package/dist/src/Editor.js +366 -0
- package/dist/src/EditorImage.d.ts +44 -0
- package/dist/src/EditorImage.js +243 -0
- package/dist/src/EventDispatcher.d.ts +11 -0
- package/dist/src/EventDispatcher.js +39 -0
- package/dist/src/Pointer.d.ts +22 -0
- package/dist/src/Pointer.js +57 -0
- package/dist/src/SVGLoader.d.ts +21 -0
- package/dist/src/SVGLoader.js +204 -0
- package/dist/src/StrokeBuilder.d.ts +35 -0
- package/dist/src/StrokeBuilder.js +275 -0
- package/dist/src/UndoRedoHistory.d.ts +17 -0
- package/dist/src/UndoRedoHistory.js +46 -0
- package/dist/src/Viewport.d.ts +39 -0
- package/dist/src/Viewport.js +134 -0
- package/dist/src/commands/Command.d.ts +15 -0
- package/dist/src/commands/Command.js +29 -0
- package/dist/src/commands/Erase.d.ts +11 -0
- package/dist/src/commands/Erase.js +37 -0
- package/dist/src/commands/localization.d.ts +19 -0
- package/dist/src/commands/localization.js +17 -0
- package/dist/src/components/AbstractComponent.d.ts +19 -0
- package/dist/src/components/AbstractComponent.js +46 -0
- package/dist/src/components/Stroke.d.ts +16 -0
- package/dist/src/components/Stroke.js +79 -0
- package/dist/src/components/UnknownSVGObject.d.ts +15 -0
- package/dist/src/components/UnknownSVGObject.js +25 -0
- package/dist/src/components/localization.d.ts +5 -0
- package/dist/src/components/localization.js +4 -0
- package/dist/src/geometry/LineSegment2.d.ts +19 -0
- package/dist/src/geometry/LineSegment2.js +100 -0
- package/dist/src/geometry/Mat33.d.ts +31 -0
- package/dist/src/geometry/Mat33.js +187 -0
- package/dist/src/geometry/Path.d.ts +55 -0
- package/dist/src/geometry/Path.js +364 -0
- package/dist/src/geometry/Rect2.d.ts +47 -0
- package/dist/src/geometry/Rect2.js +148 -0
- package/dist/src/geometry/Vec2.d.ts +13 -0
- package/dist/src/geometry/Vec2.js +13 -0
- package/dist/src/geometry/Vec3.d.ts +32 -0
- package/dist/src/geometry/Vec3.js +98 -0
- package/dist/src/localization.d.ts +12 -0
- package/dist/src/localization.js +5 -0
- package/dist/src/main.d.ts +3 -0
- package/dist/src/main.js +4 -0
- package/dist/src/rendering/AbstractRenderer.d.ts +38 -0
- package/dist/src/rendering/AbstractRenderer.js +108 -0
- package/dist/src/rendering/CanvasRenderer.d.ts +23 -0
- package/dist/src/rendering/CanvasRenderer.js +108 -0
- package/dist/src/rendering/DummyRenderer.d.ts +25 -0
- package/dist/src/rendering/DummyRenderer.js +65 -0
- package/dist/src/rendering/SVGRenderer.d.ts +27 -0
- package/dist/src/rendering/SVGRenderer.js +122 -0
- package/dist/src/testing/loadExpectExtensions.d.ts +17 -0
- package/dist/src/testing/loadExpectExtensions.js +27 -0
- package/dist/src/toolbar/HTMLToolbar.d.ts +12 -0
- package/dist/src/toolbar/HTMLToolbar.js +444 -0
- package/dist/src/toolbar/types.d.ts +17 -0
- package/dist/src/toolbar/types.js +5 -0
- package/dist/src/tools/BaseTool.d.ts +20 -0
- package/dist/src/tools/BaseTool.js +44 -0
- package/dist/src/tools/Eraser.d.ts +16 -0
- package/dist/src/tools/Eraser.js +53 -0
- package/dist/src/tools/PanZoom.d.ts +40 -0
- package/dist/src/tools/PanZoom.js +191 -0
- package/dist/src/tools/Pen.d.ts +25 -0
- package/dist/src/tools/Pen.js +97 -0
- package/dist/src/tools/SelectionTool.d.ts +49 -0
- package/dist/src/tools/SelectionTool.js +437 -0
- package/dist/src/tools/ToolController.d.ts +18 -0
- package/dist/src/tools/ToolController.js +110 -0
- package/dist/src/tools/ToolEnabledGroup.d.ts +6 -0
- package/dist/src/tools/ToolEnabledGroup.js +11 -0
- package/dist/src/tools/localization.d.ts +10 -0
- package/dist/src/tools/localization.js +9 -0
- package/dist/src/types.d.ts +88 -0
- package/dist/src/types.js +20 -0
- package/jest.config.js +22 -0
- package/lint-staged.config.js +6 -0
- package/package.json +82 -0
- package/src/Color4.test.ts +12 -0
- package/src/Color4.ts +122 -0
- package/src/Display.ts +118 -0
- package/src/Editor.css +58 -0
- package/src/Editor.ts +469 -0
- package/src/EditorImage.test.ts +90 -0
- package/src/EditorImage.ts +297 -0
- package/src/EventDispatcher.test.ts +123 -0
- package/src/EventDispatcher.ts +53 -0
- package/src/Pointer.ts +93 -0
- package/src/SVGLoader.ts +230 -0
- package/src/StrokeBuilder.ts +362 -0
- package/src/UndoRedoHistory.ts +61 -0
- package/src/Viewport.ts +168 -0
- package/src/commands/Command.ts +43 -0
- package/src/commands/Erase.ts +52 -0
- package/src/commands/localization.ts +38 -0
- package/src/components/AbstractComponent.ts +73 -0
- package/src/components/Stroke.test.ts +18 -0
- package/src/components/Stroke.ts +102 -0
- package/src/components/UnknownSVGObject.ts +36 -0
- package/src/components/localization.ts +9 -0
- package/src/editorStyles.js +3 -0
- package/src/geometry/LineSegment2.test.ts +77 -0
- package/src/geometry/LineSegment2.ts +127 -0
- package/src/geometry/Mat33.test.ts +144 -0
- package/src/geometry/Mat33.ts +268 -0
- package/src/geometry/Path.fromString.test.ts +146 -0
- package/src/geometry/Path.test.ts +96 -0
- package/src/geometry/Path.toString.test.ts +31 -0
- package/src/geometry/Path.ts +456 -0
- package/src/geometry/Rect2.test.ts +121 -0
- package/src/geometry/Rect2.ts +215 -0
- package/src/geometry/Vec2.test.ts +32 -0
- package/src/geometry/Vec2.ts +18 -0
- package/src/geometry/Vec3.test.ts +29 -0
- package/src/geometry/Vec3.ts +133 -0
- package/src/localization.ts +27 -0
- package/src/rendering/AbstractRenderer.ts +164 -0
- package/src/rendering/CanvasRenderer.ts +141 -0
- package/src/rendering/DummyRenderer.ts +80 -0
- package/src/rendering/SVGRenderer.ts +159 -0
- package/src/testing/loadExpectExtensions.ts +43 -0
- package/src/toolbar/HTMLToolbar.ts +551 -0
- package/src/toolbar/toolbar.css +110 -0
- package/src/toolbar/types.ts +20 -0
- package/src/tools/BaseTool.ts +58 -0
- package/src/tools/Eraser.ts +67 -0
- package/src/tools/PanZoom.ts +253 -0
- package/src/tools/Pen.ts +121 -0
- package/src/tools/SelectionTool.test.ts +85 -0
- package/src/tools/SelectionTool.ts +545 -0
- package/src/tools/ToolController.ts +126 -0
- package/src/tools/ToolEnabledGroup.ts +14 -0
- package/src/tools/localization.ts +22 -0
- package/src/types.ts +133 -0
- package/tsconfig.json +28 -0
@@ -0,0 +1,96 @@
|
|
1
|
+
import { Bezier } from 'bezier-js';
|
2
|
+
import LineSegment2 from './LineSegment2';
|
3
|
+
import Path, { PathCommandType } from './Path';
|
4
|
+
import { Vec2 } from './Vec2';
|
5
|
+
|
6
|
+
describe('Path', () => {
|
7
|
+
it('should instantiate Beziers from cubic and quatratic commands', () => {
|
8
|
+
const path = new Path(Vec2.zero, [
|
9
|
+
{
|
10
|
+
kind: PathCommandType.CubicBezierTo,
|
11
|
+
controlPoint1: Vec2.of(1, 1),
|
12
|
+
controlPoint2: Vec2.of(-1, -1),
|
13
|
+
endPoint: Vec2.of(3, 3),
|
14
|
+
},
|
15
|
+
{
|
16
|
+
kind: PathCommandType.QuadraticBezierTo,
|
17
|
+
controlPoint: Vec2.of(1, 1),
|
18
|
+
endPoint: Vec2.of(0, 0),
|
19
|
+
},
|
20
|
+
]);
|
21
|
+
|
22
|
+
expect(path.geometry.length).toBe(2);
|
23
|
+
|
24
|
+
const firstItem = path.geometry[0];
|
25
|
+
const secondItem = path.geometry[1];
|
26
|
+
expect(firstItem).toBeInstanceOf(Bezier);
|
27
|
+
expect(secondItem).toBeInstanceOf(Bezier);
|
28
|
+
|
29
|
+
// Force TypeScript to do type narrowing.
|
30
|
+
if (!(firstItem instanceof Bezier) || !(secondItem instanceof Bezier)) {
|
31
|
+
throw new Error('Invalid state! .toBeInstanceOf should have caused test to fail!');
|
32
|
+
}
|
33
|
+
|
34
|
+
// Make sure the control points (and start/end points) match what was set
|
35
|
+
expect(firstItem.points).toMatchObject([
|
36
|
+
{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: -1, y: -1 }, { x: 3, y: 3 }
|
37
|
+
]);
|
38
|
+
expect(secondItem.points).toMatchObject([
|
39
|
+
{ x: 3, y: 3 }, { x: 1, y: 1 }, { x: 0, y: 0 },
|
40
|
+
]);
|
41
|
+
});
|
42
|
+
|
43
|
+
it('should create LineSegments from line commands', () => {
|
44
|
+
const lineStart = Vec2.zero;
|
45
|
+
const lineEnd = Vec2.of(100, 100);
|
46
|
+
|
47
|
+
const path = new Path(lineStart, [
|
48
|
+
{
|
49
|
+
kind: PathCommandType.LineTo,
|
50
|
+
point: lineEnd,
|
51
|
+
},
|
52
|
+
]);
|
53
|
+
|
54
|
+
expect(path.geometry.length).toBe(1);
|
55
|
+
expect(path.geometry[0]).toBeInstanceOf(LineSegment2);
|
56
|
+
expect(path.geometry[0]).toMatchObject(
|
57
|
+
new LineSegment2(lineStart, lineEnd)
|
58
|
+
);
|
59
|
+
});
|
60
|
+
|
61
|
+
it('should give all intersections for a path made up of lines', () => {
|
62
|
+
const lineStart = Vec2.of(100, 100);
|
63
|
+
const path = new Path(lineStart, [
|
64
|
+
{
|
65
|
+
kind: PathCommandType.LineTo,
|
66
|
+
point: Vec2.of(-100, 100),
|
67
|
+
},
|
68
|
+
{
|
69
|
+
kind: PathCommandType.LineTo,
|
70
|
+
point: Vec2.of(0, 0),
|
71
|
+
},
|
72
|
+
{
|
73
|
+
kind: PathCommandType.LineTo,
|
74
|
+
point: Vec2.of(100, -100),
|
75
|
+
},
|
76
|
+
]);
|
77
|
+
|
78
|
+
const intersections = path.intersection(
|
79
|
+
new LineSegment2(Vec2.of(-50, 200), Vec2.of(-50, -200))
|
80
|
+
);
|
81
|
+
|
82
|
+
// Should only have intersections in quadrants II and III.
|
83
|
+
expect(intersections.length).toBe(2);
|
84
|
+
|
85
|
+
// First intersection should be with the first curve
|
86
|
+
const firstIntersection = intersections[0];
|
87
|
+
expect(firstIntersection.point.xy).toMatchObject({
|
88
|
+
x: -50,
|
89
|
+
y: 100,
|
90
|
+
});
|
91
|
+
expect(firstIntersection.curve.get(firstIntersection.parameterValue)).toMatchObject({
|
92
|
+
x: -50,
|
93
|
+
y: 100,
|
94
|
+
});
|
95
|
+
});
|
96
|
+
});
|
@@ -0,0 +1,31 @@
|
|
1
|
+
import Path, { PathCommandType } from './Path';
|
2
|
+
import { Vec2 } from './Vec2';
|
3
|
+
|
4
|
+
|
5
|
+
describe('Path.toString', () => {
|
6
|
+
it('a single-point path should produce a move-to command', () => {
|
7
|
+
const path = new Path(Vec2.of(0, 0), []);
|
8
|
+
expect(path.toString()).toBe('M0,0');
|
9
|
+
});
|
10
|
+
|
11
|
+
it('should convert lineTo commands to L SVG commands', () => {
|
12
|
+
const path = new Path(Vec2.of(0.1, 0.2), [
|
13
|
+
{
|
14
|
+
kind: PathCommandType.LineTo,
|
15
|
+
point: Vec2.of(0.3, 0.4),
|
16
|
+
},
|
17
|
+
]);
|
18
|
+
expect(path.toString()).toBe('M0.1,0.2L0.3,0.4');
|
19
|
+
});
|
20
|
+
|
21
|
+
it('should fix rounding errors', () => {
|
22
|
+
const path = new Path(Vec2.of(0.100001, 0.199999), [
|
23
|
+
{
|
24
|
+
kind: PathCommandType.QuadraticBezierTo,
|
25
|
+
controlPoint: Vec2.of(9999, -10.999999995),
|
26
|
+
endPoint: Vec2.of(0.000300001, 1.400002),
|
27
|
+
},
|
28
|
+
]);
|
29
|
+
expect(path.toString()).toBe('M0.1,0.2Q9999,-11 0.0003,1.4');
|
30
|
+
});
|
31
|
+
});
|
@@ -0,0 +1,456 @@
|
|
1
|
+
import { Bezier } from 'bezier-js';
|
2
|
+
import { RenderingStyle, RenderablePathSpec } from '../rendering/AbstractRenderer';
|
3
|
+
import LineSegment2 from './LineSegment2';
|
4
|
+
import Mat33 from './Mat33';
|
5
|
+
import Rect2 from './Rect2';
|
6
|
+
import { Point2, Vec2 } from './Vec2';
|
7
|
+
|
8
|
+
export enum PathCommandType {
|
9
|
+
LineTo,
|
10
|
+
MoveTo,
|
11
|
+
CubicBezierTo,
|
12
|
+
QuadraticBezierTo,
|
13
|
+
}
|
14
|
+
|
15
|
+
export interface CubicBezierPathCommand {
|
16
|
+
kind: PathCommandType.CubicBezierTo;
|
17
|
+
controlPoint1: Point2;
|
18
|
+
controlPoint2: Point2;
|
19
|
+
endPoint: Point2;
|
20
|
+
}
|
21
|
+
|
22
|
+
export interface QuadraticBezierPathCommand {
|
23
|
+
kind: PathCommandType.QuadraticBezierTo;
|
24
|
+
controlPoint: Point2;
|
25
|
+
endPoint: Point2;
|
26
|
+
}
|
27
|
+
|
28
|
+
export interface LinePathCommand {
|
29
|
+
kind: PathCommandType.LineTo;
|
30
|
+
point: Point2;
|
31
|
+
}
|
32
|
+
|
33
|
+
export interface MoveToPathCommand {
|
34
|
+
kind: PathCommandType.MoveTo;
|
35
|
+
point: Point2;
|
36
|
+
}
|
37
|
+
|
38
|
+
export type PathCommand = CubicBezierPathCommand | LinePathCommand | QuadraticBezierPathCommand | MoveToPathCommand;
|
39
|
+
|
40
|
+
interface IntersectionResult {
|
41
|
+
curve: LineSegment2|Bezier;
|
42
|
+
parameterValue: number;
|
43
|
+
point: Point2;
|
44
|
+
}
|
45
|
+
|
46
|
+
type GeometryArrayType = Array<LineSegment2|Bezier>;
|
47
|
+
export default class Path {
|
48
|
+
private cachedGeometry: GeometryArrayType|null;
|
49
|
+
public readonly bbox: Rect2;
|
50
|
+
|
51
|
+
public constructor(public readonly startPoint: Point2, public readonly parts: PathCommand[]) {
|
52
|
+
this.cachedGeometry = null;
|
53
|
+
|
54
|
+
// Initial bounding box contains one point: the start point.
|
55
|
+
this.bbox = Rect2.bboxOf([startPoint]);
|
56
|
+
|
57
|
+
// Convert into a representation of the geometry (cache for faster intersection
|
58
|
+
// calculation)
|
59
|
+
for (const part of parts) {
|
60
|
+
this.bbox = this.bbox.union(Path.computeBBoxForSegment(startPoint, part));
|
61
|
+
}
|
62
|
+
}
|
63
|
+
|
64
|
+
// Lazy-loads and returns this path's geometry
|
65
|
+
public get geometry(): Array<LineSegment2|Bezier> {
|
66
|
+
if (this.cachedGeometry) {
|
67
|
+
return this.cachedGeometry;
|
68
|
+
}
|
69
|
+
|
70
|
+
let startPoint = this.startPoint;
|
71
|
+
const geometry: GeometryArrayType = [];
|
72
|
+
|
73
|
+
for (const part of this.parts) {
|
74
|
+
switch (part.kind) {
|
75
|
+
case PathCommandType.CubicBezierTo:
|
76
|
+
geometry.push(
|
77
|
+
new Bezier(
|
78
|
+
startPoint.xy, part.controlPoint1.xy, part.controlPoint2.xy, part.endPoint.xy
|
79
|
+
)
|
80
|
+
);
|
81
|
+
startPoint = part.endPoint;
|
82
|
+
break;
|
83
|
+
case PathCommandType.QuadraticBezierTo:
|
84
|
+
geometry.push(
|
85
|
+
new Bezier(
|
86
|
+
startPoint.xy, part.controlPoint.xy, part.endPoint.xy
|
87
|
+
)
|
88
|
+
);
|
89
|
+
startPoint = part.endPoint;
|
90
|
+
break;
|
91
|
+
case PathCommandType.LineTo:
|
92
|
+
geometry.push(
|
93
|
+
new LineSegment2(startPoint, part.point)
|
94
|
+
);
|
95
|
+
startPoint = part.point;
|
96
|
+
break;
|
97
|
+
case PathCommandType.MoveTo:
|
98
|
+
startPoint = part.point;
|
99
|
+
break;
|
100
|
+
}
|
101
|
+
}
|
102
|
+
|
103
|
+
this.cachedGeometry = geometry;
|
104
|
+
return this.cachedGeometry;
|
105
|
+
}
|
106
|
+
|
107
|
+
public static computeBBoxForSegment(startPoint: Point2, part: PathCommand): Rect2 {
|
108
|
+
const points = [startPoint];
|
109
|
+
let exhaustivenessCheck: never;
|
110
|
+
switch (part.kind) {
|
111
|
+
case PathCommandType.MoveTo:
|
112
|
+
case PathCommandType.LineTo:
|
113
|
+
points.push(part.point);
|
114
|
+
break;
|
115
|
+
case PathCommandType.CubicBezierTo:
|
116
|
+
points.push(part.controlPoint1, part.controlPoint2, part.endPoint);
|
117
|
+
break;
|
118
|
+
case PathCommandType.QuadraticBezierTo:
|
119
|
+
points.push(part.controlPoint, part.endPoint);
|
120
|
+
break;
|
121
|
+
default:
|
122
|
+
exhaustivenessCheck = part;
|
123
|
+
return exhaustivenessCheck;
|
124
|
+
}
|
125
|
+
|
126
|
+
return Rect2.bboxOf(points);
|
127
|
+
}
|
128
|
+
|
129
|
+
public intersection(line: LineSegment2): IntersectionResult[] {
|
130
|
+
const result: IntersectionResult[] = [];
|
131
|
+
for (const part of this.geometry) {
|
132
|
+
if (part instanceof LineSegment2) {
|
133
|
+
const intersection = part.intersection(line);
|
134
|
+
|
135
|
+
if (intersection) {
|
136
|
+
result.push({
|
137
|
+
curve: part,
|
138
|
+
parameterValue: intersection.t,
|
139
|
+
point: intersection.point,
|
140
|
+
});
|
141
|
+
}
|
142
|
+
} else {
|
143
|
+
const intersectionPoints = part.intersects(line).map(t => {
|
144
|
+
// We're using the .intersects(line) function, which is documented
|
145
|
+
// to always return numbers. However, to satisfy the type checker (and
|
146
|
+
// possibly improperly-defined types),
|
147
|
+
if (typeof t === 'string') {
|
148
|
+
t = parseFloat(t);
|
149
|
+
}
|
150
|
+
|
151
|
+
const point = Vec2.ofXY(part.get(t));
|
152
|
+
|
153
|
+
// Ensure that the intersection is on the line
|
154
|
+
if (point.minus(line.p1).magnitude() > line.length
|
155
|
+
|| point.minus(line.p2).magnitude() > line.length) {
|
156
|
+
return null;
|
157
|
+
}
|
158
|
+
|
159
|
+
return {
|
160
|
+
point,
|
161
|
+
parameterValue: t,
|
162
|
+
curve: part,
|
163
|
+
};
|
164
|
+
}).filter(entry => entry !== null) as IntersectionResult[];
|
165
|
+
result.push(...intersectionPoints);
|
166
|
+
}
|
167
|
+
}
|
168
|
+
|
169
|
+
return result;
|
170
|
+
}
|
171
|
+
|
172
|
+
public transformedBy(affineTransfm: Mat33): Path {
|
173
|
+
const startPoint = affineTransfm.transformVec2(this.startPoint);
|
174
|
+
const newParts: PathCommand[] = [];
|
175
|
+
|
176
|
+
let exhaustivenessCheck: never;
|
177
|
+
for (const part of this.parts) {
|
178
|
+
switch (part.kind) {
|
179
|
+
case PathCommandType.MoveTo:
|
180
|
+
case PathCommandType.LineTo:
|
181
|
+
newParts.push({
|
182
|
+
kind: part.kind,
|
183
|
+
point: affineTransfm.transformVec2(part.point),
|
184
|
+
});
|
185
|
+
break;
|
186
|
+
case PathCommandType.CubicBezierTo:
|
187
|
+
newParts.push({
|
188
|
+
kind: part.kind,
|
189
|
+
controlPoint1: affineTransfm.transformVec2(part.controlPoint1),
|
190
|
+
controlPoint2: affineTransfm.transformVec2(part.controlPoint2),
|
191
|
+
endPoint: affineTransfm.transformVec2(part.endPoint),
|
192
|
+
});
|
193
|
+
break;
|
194
|
+
case PathCommandType.QuadraticBezierTo:
|
195
|
+
newParts.push({
|
196
|
+
kind: part.kind,
|
197
|
+
controlPoint: affineTransfm.transformVec2(part.controlPoint),
|
198
|
+
endPoint: affineTransfm.transformVec2(part.endPoint),
|
199
|
+
});
|
200
|
+
break;
|
201
|
+
default:
|
202
|
+
exhaustivenessCheck = part;
|
203
|
+
return exhaustivenessCheck;
|
204
|
+
}
|
205
|
+
}
|
206
|
+
|
207
|
+
return new Path(startPoint, newParts);
|
208
|
+
}
|
209
|
+
|
210
|
+
// Creates a new path by joining [other] to the end of this path
|
211
|
+
public union(other: Path|null): Path {
|
212
|
+
if (!other) {
|
213
|
+
return this;
|
214
|
+
}
|
215
|
+
|
216
|
+
return new Path(this.startPoint, [
|
217
|
+
...this.parts,
|
218
|
+
{
|
219
|
+
kind: PathCommandType.MoveTo,
|
220
|
+
point: other.startPoint,
|
221
|
+
},
|
222
|
+
...other.parts,
|
223
|
+
]);
|
224
|
+
}
|
225
|
+
|
226
|
+
public static fromRenderable(renderable: RenderablePathSpec): Path {
|
227
|
+
return new Path(renderable.startPoint, renderable.commands);
|
228
|
+
}
|
229
|
+
|
230
|
+
public toRenderable(fill: RenderingStyle): RenderablePathSpec {
|
231
|
+
return {
|
232
|
+
startPoint: this.startPoint,
|
233
|
+
style: fill,
|
234
|
+
commands: this.parts,
|
235
|
+
};
|
236
|
+
}
|
237
|
+
|
238
|
+
public toString(): string {
|
239
|
+
return Path.toString(this.startPoint, this.parts);
|
240
|
+
}
|
241
|
+
|
242
|
+
public static toString(startPoint: Point2, parts: PathCommand[]): string {
|
243
|
+
const result: string[] = [];
|
244
|
+
|
245
|
+
const toRoundedString = (num: number): string => {
|
246
|
+
// Try to remove rounding errors. If the number ends in at least three/four zeroes
|
247
|
+
// (or nines) just one or two digits, it's probably a rounding error.
|
248
|
+
const fixRoundingUpExp = /^([-]?\d*\.?\d*[1-9.])0{4,}\d$/;
|
249
|
+
const hasRoundingDownExp = /^([-]?)(\d*)\.(\d*9{4,}\d)$/;
|
250
|
+
|
251
|
+
let text = num.toString();
|
252
|
+
if (text.indexOf('.') === -1) {
|
253
|
+
return text;
|
254
|
+
}
|
255
|
+
|
256
|
+
const roundingDownMatch = hasRoundingDownExp.exec(text);
|
257
|
+
if (roundingDownMatch) {
|
258
|
+
const negativeSign = roundingDownMatch[1];
|
259
|
+
const lastDigit = parseInt(text.charAt(text.length - 1), 10);
|
260
|
+
const postDecimal = parseInt(roundingDownMatch[3], 10);
|
261
|
+
const preDecimal = parseInt(roundingDownMatch[2], 10);
|
262
|
+
|
263
|
+
let newPostDecimal = (postDecimal + 10 - lastDigit).toString();
|
264
|
+
let carry = 0;
|
265
|
+
if (newPostDecimal.length > postDecimal.toString().length) {
|
266
|
+
// Left-shift
|
267
|
+
newPostDecimal = newPostDecimal.substring(1);
|
268
|
+
carry = 1;
|
269
|
+
}
|
270
|
+
text = `${negativeSign + (preDecimal + carry).toString()}.${newPostDecimal}`;
|
271
|
+
}
|
272
|
+
|
273
|
+
text = text.replace(fixRoundingUpExp, '$1');
|
274
|
+
// Remove trailing period (if it exists)
|
275
|
+
return text.replace(/[.]$/, '');
|
276
|
+
};
|
277
|
+
|
278
|
+
const addCommand = (command: string, ...points: Point2[]) => {
|
279
|
+
const parts: string[] = [];
|
280
|
+
for (const point of points) {
|
281
|
+
const xComponent = toRoundedString(point.x);
|
282
|
+
const yComponent = toRoundedString(point.y);
|
283
|
+
parts.push(`${xComponent},${yComponent}`);
|
284
|
+
}
|
285
|
+
result.push(`${command}${parts.join(' ')}`);
|
286
|
+
};
|
287
|
+
|
288
|
+
addCommand('M', startPoint);
|
289
|
+
let exhaustivenessCheck: never;
|
290
|
+
for (const part of parts) {
|
291
|
+
switch (part.kind) {
|
292
|
+
case PathCommandType.MoveTo:
|
293
|
+
addCommand('M', part.point);
|
294
|
+
break;
|
295
|
+
case PathCommandType.LineTo:
|
296
|
+
addCommand('L', part.point);
|
297
|
+
break;
|
298
|
+
case PathCommandType.CubicBezierTo:
|
299
|
+
addCommand('C', part.controlPoint1, part.controlPoint2, part.endPoint);
|
300
|
+
break;
|
301
|
+
case PathCommandType.QuadraticBezierTo:
|
302
|
+
addCommand('Q', part.controlPoint, part.endPoint);
|
303
|
+
break;
|
304
|
+
default:
|
305
|
+
exhaustivenessCheck = part;
|
306
|
+
return exhaustivenessCheck;
|
307
|
+
}
|
308
|
+
}
|
309
|
+
|
310
|
+
return result.join('');
|
311
|
+
}
|
312
|
+
|
313
|
+
// Create a Path from a SVG path specification.
|
314
|
+
// TODO: Support a larger subset of SVG paths.
|
315
|
+
// TODO: Support s,t shorthands.
|
316
|
+
public static fromString(pathString: string): Path {
|
317
|
+
// See the MDN reference:
|
318
|
+
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
|
319
|
+
// and
|
320
|
+
// https://www.w3.org/TR/SVG2/paths.html
|
321
|
+
|
322
|
+
// Remove linebreaks
|
323
|
+
pathString = pathString.split('\n').join(' ');
|
324
|
+
|
325
|
+
let lastPos: Point2 = Vec2.zero;
|
326
|
+
let firstPos: Point2|null = null;
|
327
|
+
const commands: PathCommand[] = [];
|
328
|
+
|
329
|
+
|
330
|
+
const moveTo = (point: Point2) => {
|
331
|
+
commands.push({
|
332
|
+
kind: PathCommandType.MoveTo,
|
333
|
+
point,
|
334
|
+
});
|
335
|
+
};
|
336
|
+
const lineTo = (point: Point2) => {
|
337
|
+
commands.push({
|
338
|
+
kind: PathCommandType.LineTo,
|
339
|
+
point,
|
340
|
+
});
|
341
|
+
};
|
342
|
+
const cubicBezierTo = (cp1: Point2, cp2: Point2, end: Point2) => {
|
343
|
+
commands.push({
|
344
|
+
kind: PathCommandType.CubicBezierTo,
|
345
|
+
controlPoint1: cp1,
|
346
|
+
controlPoint2: cp2,
|
347
|
+
endPoint: end,
|
348
|
+
});
|
349
|
+
};
|
350
|
+
const quadraticBeierTo = (controlPoint: Point2, endPoint: Point2) => {
|
351
|
+
commands.push({
|
352
|
+
kind: PathCommandType.QuadraticBezierTo,
|
353
|
+
controlPoint,
|
354
|
+
endPoint,
|
355
|
+
});
|
356
|
+
};
|
357
|
+
|
358
|
+
// Each command: Command character followed by anything that isn't a command character
|
359
|
+
const commandExp = /([MmZzLlHhVvCcSsQqTtAa])\s*([^a-zA-Z]*)/g;
|
360
|
+
let current;
|
361
|
+
while ((current = commandExp.exec(pathString)) !== null) {
|
362
|
+
const argParts = current[2].trim().split(/[^0-9.-]/).filter(
|
363
|
+
part => part.length > 0
|
364
|
+
);
|
365
|
+
const numericArgs = argParts.map(arg => parseFloat(arg));
|
366
|
+
|
367
|
+
const commandChar = current[1];
|
368
|
+
const uppercaseCommand = commandChar !== commandChar.toLowerCase();
|
369
|
+
const args = numericArgs.reduce((
|
370
|
+
accumulator: Point2[], current, index, parts
|
371
|
+
): Point2[] => {
|
372
|
+
if (index % 2 !== 0) {
|
373
|
+
const currentAsFloat = current;
|
374
|
+
const prevAsFloat = parts[index - 1];
|
375
|
+
return accumulator.concat(Vec2.of(prevAsFloat, currentAsFloat));
|
376
|
+
} else {
|
377
|
+
return accumulator;
|
378
|
+
}
|
379
|
+
}, []).map((coordinate: Vec2): Point2 => {
|
380
|
+
// Lowercase commands are relative, uppercase commands use absolute
|
381
|
+
// positioning
|
382
|
+
if (uppercaseCommand) {
|
383
|
+
lastPos = coordinate;
|
384
|
+
return coordinate;
|
385
|
+
} else {
|
386
|
+
lastPos = lastPos.plus(coordinate);
|
387
|
+
return lastPos;
|
388
|
+
}
|
389
|
+
});
|
390
|
+
|
391
|
+
let expectedPointArgCount;
|
392
|
+
|
393
|
+
switch (commandChar.toLowerCase()) {
|
394
|
+
case 'm':
|
395
|
+
expectedPointArgCount = 1;
|
396
|
+
moveTo(args[0]);
|
397
|
+
break;
|
398
|
+
case 'l':
|
399
|
+
expectedPointArgCount = 1;
|
400
|
+
lineTo(args[0]);
|
401
|
+
break;
|
402
|
+
case 'z':
|
403
|
+
expectedPointArgCount = 0;
|
404
|
+
// firstPos can be null if the stroke data is just 'z'.
|
405
|
+
if (firstPos) {
|
406
|
+
lineTo(firstPos);
|
407
|
+
}
|
408
|
+
break;
|
409
|
+
case 'c':
|
410
|
+
expectedPointArgCount = 3;
|
411
|
+
cubicBezierTo(args[0], args[1], args[2]);
|
412
|
+
break;
|
413
|
+
case 'q':
|
414
|
+
expectedPointArgCount = 2;
|
415
|
+
quadraticBeierTo(args[0], args[1]);
|
416
|
+
break;
|
417
|
+
|
418
|
+
// Horizontal line
|
419
|
+
case 'h':
|
420
|
+
expectedPointArgCount = 0;
|
421
|
+
|
422
|
+
if (uppercaseCommand) {
|
423
|
+
lineTo(Vec2.of(numericArgs[0], lastPos.y));
|
424
|
+
} else {
|
425
|
+
lineTo(lastPos.plus(Vec2.of(numericArgs[0], 0)));
|
426
|
+
}
|
427
|
+
break;
|
428
|
+
|
429
|
+
// Vertical line
|
430
|
+
case 'v':
|
431
|
+
expectedPointArgCount = 0;
|
432
|
+
|
433
|
+
if (uppercaseCommand) {
|
434
|
+
lineTo(Vec2.of(lastPos.x, numericArgs[1]));
|
435
|
+
} else {
|
436
|
+
lineTo(lastPos.plus(Vec2.of(0, numericArgs[1])));
|
437
|
+
}
|
438
|
+
break;
|
439
|
+
default:
|
440
|
+
throw new Error(`Unknown path command ${commandChar}`);
|
441
|
+
}
|
442
|
+
|
443
|
+
if (args.length !== expectedPointArgCount) {
|
444
|
+
throw new Error(`
|
445
|
+
Incorrect number of arguments: got ${JSON.stringify(args)} with a length of ${args.length} ≠ ${expectedPointArgCount}.
|
446
|
+
`.trim());
|
447
|
+
}
|
448
|
+
|
449
|
+
if (args.length > 0) {
|
450
|
+
firstPos ??= args[0];
|
451
|
+
}
|
452
|
+
}
|
453
|
+
|
454
|
+
return new Path(firstPos ?? Vec2.zero, commands);
|
455
|
+
}
|
456
|
+
}
|
@@ -0,0 +1,121 @@
|
|
1
|
+
|
2
|
+
import Rect2 from './Rect2';
|
3
|
+
import { Vec2 } from './Vec2';
|
4
|
+
import loadExpectExtensions from '../testing/loadExpectExtensions';
|
5
|
+
import Mat33 from './Mat33';
|
6
|
+
|
7
|
+
loadExpectExtensions();
|
8
|
+
|
9
|
+
describe('Rect2 tests', () => {
|
10
|
+
it('Positive width, height', () => {
|
11
|
+
expect(new Rect2(-1, -2, -3, 4)).objEq(new Rect2(-4, -2, 3, 4));
|
12
|
+
expect(new Rect2(0, 0, 0, 0).size).objEq(Vec2.zero);
|
13
|
+
expect(Rect2.fromCorners(
|
14
|
+
Vec2.of(-3, -3),
|
15
|
+
Vec2.of(-1, -1)
|
16
|
+
)).objEq(new Rect2(
|
17
|
+
-3, -3,
|
18
|
+
2, 2
|
19
|
+
));
|
20
|
+
});
|
21
|
+
|
22
|
+
it('Bounding box', () => {
|
23
|
+
expect(Rect2.bboxOf([
|
24
|
+
Vec2.zero,
|
25
|
+
])).objEq(Rect2.empty);
|
26
|
+
|
27
|
+
expect(Rect2.bboxOf([
|
28
|
+
Vec2.of(-1, -1),
|
29
|
+
Vec2.of(1, 2),
|
30
|
+
Vec2.of(3, 4),
|
31
|
+
Vec2.of(1, -4),
|
32
|
+
])).objEq(new Rect2(
|
33
|
+
-1, -4,
|
34
|
+
4, 8
|
35
|
+
));
|
36
|
+
|
37
|
+
expect(Rect2.bboxOf([
|
38
|
+
Vec2.zero,
|
39
|
+
], 10)).objEq(new Rect2(
|
40
|
+
-10, -10,
|
41
|
+
20, 20
|
42
|
+
));
|
43
|
+
});
|
44
|
+
|
45
|
+
it('"union"ing', () => {
|
46
|
+
expect(new Rect2(0, 0, 1, 1).union(new Rect2(1, 1, 2, 2))).objEq(
|
47
|
+
new Rect2(0, 0, 3, 3)
|
48
|
+
);
|
49
|
+
expect(Rect2.empty.union(Rect2.empty)).objEq(Rect2.empty);
|
50
|
+
});
|
51
|
+
|
52
|
+
it('contains', () => {
|
53
|
+
expect(new Rect2(-1, -1, 2, 2).containsPoint(Vec2.zero)).toBe(true);
|
54
|
+
expect(new Rect2(-1, -1, 0, 0).containsPoint(Vec2.zero)).toBe(false);
|
55
|
+
expect(new Rect2(1, 2, 3, 4).containsRect(Rect2.empty)).toBe(false);
|
56
|
+
expect(new Rect2(1, 2, 3, 4).containsRect(new Rect2(1, 2, 1, 2))).toBe(true);
|
57
|
+
expect(new Rect2(-2, -2, 4, 4).containsRect(new Rect2(-1, 0, 1, 1))).toBe(true);
|
58
|
+
expect(new Rect2(-2, -2, 4, 4).containsRect(new Rect2(-1, 0, 10, 1))).toBe(false);
|
59
|
+
});
|
60
|
+
|
61
|
+
it('a rectangle should contain itself', () => {
|
62
|
+
const rect = new Rect2(1 / 3, 1 / 4, 1 / 5, 1 / 6);
|
63
|
+
expect(rect.containsRect(rect)).toBe(true);
|
64
|
+
});
|
65
|
+
|
66
|
+
it('empty rect should not contain a larger rect', () => {
|
67
|
+
expect(Rect2.empty.containsRect(new Rect2(-1, -1, 3, 3))).toBe(false);
|
68
|
+
});
|
69
|
+
|
70
|
+
it('Intersection testing', () => {
|
71
|
+
expect(new Rect2(-1, -1, 2, 2).intersects(Rect2.empty)).toBe(true);
|
72
|
+
expect(new Rect2(-1, -1, 2, 2).intersects(new Rect2(0, 0, 1, 1))).toBe(true);
|
73
|
+
expect(new Rect2(-1, -1, 2, 2).intersects(new Rect2(0, 0, 10, 10))).toBe(true);
|
74
|
+
expect(new Rect2(-1, -1, 2, 2).intersects(new Rect2(3, 3, 10, 10))).toBe(false);
|
75
|
+
});
|
76
|
+
|
77
|
+
it('Computing intersections', () => {
|
78
|
+
expect(new Rect2(-1, -1, 2, 2).intersection(Rect2.empty)).objEq(Rect2.empty);
|
79
|
+
expect(new Rect2(-1, -1, 2, 2).intersection(new Rect2(0, 0, 3, 3))).objEq(
|
80
|
+
new Rect2(0, 0, 1, 1)
|
81
|
+
);
|
82
|
+
expect(new Rect2(-2, 0, 1, 2).intersection(new Rect2(-3, 0, 2, 2))).objEq(
|
83
|
+
new Rect2(-2, 0, 1, 2)
|
84
|
+
);
|
85
|
+
expect(new Rect2(-1, -1, 2, 2).intersection(new Rect2(3, 3, 10, 10))).toBe(null);
|
86
|
+
});
|
87
|
+
|
88
|
+
it('A transformed bounding box', () => {
|
89
|
+
const rotationMat = Mat33.zRotation(Math.PI / 4);
|
90
|
+
const rect = Rect2.unitSquare.translatedBy(Vec2.of(-0.5, -0.5));
|
91
|
+
const transformedBBox = rect.transformedBoundingBox(rotationMat);
|
92
|
+
expect(transformedBBox.containsPoint(Vec2.of(0.5, 0.5)));
|
93
|
+
expect(transformedBBox.containsRect(rect)).toBe(true);
|
94
|
+
});
|
95
|
+
|
96
|
+
describe('Grown to include a point', () => {
|
97
|
+
it('Growing an empty rectange to include (1, 0)', () => {
|
98
|
+
const originalRect = Rect2.empty;
|
99
|
+
const grownRect = originalRect.grownToPoint(Vec2.unitX);
|
100
|
+
expect(grownRect).objEq(new Rect2(0, 0, 1, 0));
|
101
|
+
});
|
102
|
+
|
103
|
+
it('Growing the unit rectangle to include (-5, 1), with a margin', () => {
|
104
|
+
const originalRect = Rect2.unitSquare;
|
105
|
+
const grownRect = originalRect.grownToPoint(Vec2.of(-5, 1), 4);
|
106
|
+
expect(grownRect).objEq(new Rect2(-9, -3, 10, 8));
|
107
|
+
});
|
108
|
+
|
109
|
+
it('Growing to include a point just above', () => {
|
110
|
+
const original = Rect2.unitSquare;
|
111
|
+
const grown = original.grownToPoint(Vec2.of(-1, -1));
|
112
|
+
expect(grown).objEq(new Rect2(-1, -1, 2, 2));
|
113
|
+
});
|
114
|
+
|
115
|
+
it('Growing to include a point just below', () => {
|
116
|
+
const original = Rect2.unitSquare;
|
117
|
+
const grown = original.grownToPoint(Vec2.of(2, 2));
|
118
|
+
expect(grown).objEq(new Rect2(0, 0, 2, 2));
|
119
|
+
});
|
120
|
+
});
|
121
|
+
});
|