js-draw 0.7.2 → 0.9.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.
Files changed (82) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.md +1 -0
  2. package/CHANGELOG.md +12 -0
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Color4.js +3 -0
  5. package/dist/src/SVGLoader.js +5 -7
  6. package/dist/src/components/Stroke.js +10 -3
  7. package/dist/src/components/builders/FreehandLineBuilder.d.ts +10 -23
  8. package/dist/src/components/builders/FreehandLineBuilder.js +70 -396
  9. package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.d.ts +36 -0
  10. package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.js +339 -0
  11. package/dist/src/components/lib.d.ts +2 -0
  12. package/dist/src/components/lib.js +2 -0
  13. package/dist/src/components/util/StrokeSmoother.d.ts +35 -0
  14. package/dist/src/components/util/StrokeSmoother.js +206 -0
  15. package/dist/src/math/Mat33.d.ts +2 -0
  16. package/dist/src/math/Mat33.js +4 -0
  17. package/dist/src/math/Path.d.ts +2 -0
  18. package/dist/src/math/Path.js +44 -0
  19. package/dist/src/rendering/renderers/CanvasRenderer.js +2 -0
  20. package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -0
  21. package/dist/src/rendering/renderers/SVGRenderer.js +20 -0
  22. package/dist/src/toolbar/HTMLToolbar.d.ts +3 -0
  23. package/dist/src/toolbar/HTMLToolbar.js +24 -0
  24. package/dist/src/toolbar/IconProvider.d.ts +1 -0
  25. package/dist/src/toolbar/IconProvider.js +43 -1
  26. package/dist/src/toolbar/localization.d.ts +2 -0
  27. package/dist/src/toolbar/localization.js +2 -0
  28. package/dist/src/toolbar/makeColorInput.d.ts +2 -1
  29. package/dist/src/toolbar/makeColorInput.js +13 -2
  30. package/dist/src/toolbar/widgets/ActionButtonWidget.d.ts +1 -1
  31. package/dist/src/toolbar/widgets/ActionButtonWidget.js +2 -2
  32. package/dist/src/toolbar/widgets/BaseToolWidget.d.ts +1 -2
  33. package/dist/src/toolbar/widgets/BaseToolWidget.js +2 -3
  34. package/dist/src/toolbar/widgets/BaseWidget.d.ts +32 -2
  35. package/dist/src/toolbar/widgets/BaseWidget.js +67 -6
  36. package/dist/src/toolbar/widgets/EraserToolWidget.d.ts +4 -0
  37. package/dist/src/toolbar/widgets/EraserToolWidget.js +3 -0
  38. package/dist/src/toolbar/widgets/HandToolWidget.d.ts +3 -1
  39. package/dist/src/toolbar/widgets/HandToolWidget.js +22 -13
  40. package/dist/src/toolbar/widgets/PenToolWidget.d.ts +7 -1
  41. package/dist/src/toolbar/widgets/PenToolWidget.js +83 -12
  42. package/dist/src/toolbar/widgets/SelectionToolWidget.d.ts +1 -1
  43. package/dist/src/toolbar/widgets/SelectionToolWidget.js +7 -7
  44. package/dist/src/toolbar/widgets/TextToolWidget.d.ts +4 -1
  45. package/dist/src/toolbar/widgets/TextToolWidget.js +17 -3
  46. package/dist/src/tools/PanZoom.d.ts +4 -1
  47. package/dist/src/tools/PanZoom.js +24 -1
  48. package/dist/src/tools/Pen.d.ts +2 -2
  49. package/dist/src/tools/Pen.js +2 -2
  50. package/dist/src/tools/SelectionTool/Selection.d.ts +1 -0
  51. package/dist/src/tools/SelectionTool/Selection.js +8 -1
  52. package/dist/src/tools/ToolController.js +2 -1
  53. package/package.json +1 -1
  54. package/src/Color4.ts +2 -0
  55. package/src/SVGLoader.ts +8 -8
  56. package/src/components/Stroke.ts +16 -3
  57. package/src/components/builders/FreehandLineBuilder.ts +54 -495
  58. package/src/components/builders/PressureSensitiveFreehandLineBuilder.ts +454 -0
  59. package/src/components/lib.ts +3 -1
  60. package/src/components/util/StrokeSmoother.ts +290 -0
  61. package/src/math/Mat33.ts +5 -0
  62. package/src/math/Path.test.ts +49 -0
  63. package/src/math/Path.ts +51 -0
  64. package/src/rendering/renderers/CanvasRenderer.ts +2 -0
  65. package/src/rendering/renderers/SVGRenderer.ts +24 -0
  66. package/src/toolbar/HTMLToolbar.ts +33 -0
  67. package/src/toolbar/IconProvider.ts +49 -1
  68. package/src/toolbar/localization.ts +4 -0
  69. package/src/toolbar/makeColorInput.ts +21 -3
  70. package/src/toolbar/widgets/ActionButtonWidget.ts +6 -3
  71. package/src/toolbar/widgets/BaseToolWidget.ts +4 -3
  72. package/src/toolbar/widgets/BaseWidget.ts +83 -5
  73. package/src/toolbar/widgets/EraserToolWidget.ts +11 -0
  74. package/src/toolbar/widgets/HandToolWidget.ts +48 -17
  75. package/src/toolbar/widgets/PenToolWidget.ts +110 -13
  76. package/src/toolbar/widgets/SelectionToolWidget.ts +8 -5
  77. package/src/toolbar/widgets/TextToolWidget.ts +29 -4
  78. package/src/tools/PanZoom.ts +28 -1
  79. package/src/tools/Pen.test.ts +2 -2
  80. package/src/tools/Pen.ts +1 -1
  81. package/src/tools/SelectionTool/Selection.ts +10 -1
  82. package/src/tools/ToolController.ts +2 -1
@@ -0,0 +1,339 @@
1
+ import { Bezier } from 'bezier-js';
2
+ import { Vec2 } from '../../math/Vec2';
3
+ import Rect2 from '../../math/Rect2';
4
+ import { PathCommandType } from '../../math/Path';
5
+ import Stroke from '../Stroke';
6
+ import Viewport from '../../Viewport';
7
+ import { StrokeSmoother } from '../util/StrokeSmoother';
8
+ export const makePressureSensitiveFreehandLineBuilder = (initialPoint, viewport) => {
9
+ // Don't smooth if input is more than ± 3 pixels from the true curve, do smooth if
10
+ // less than ±1 px from the curve.
11
+ const maxSmoothingDist = viewport.getSizeOfPixelOnCanvas() * 3;
12
+ const minSmoothingDist = viewport.getSizeOfPixelOnCanvas();
13
+ return new PressureSensitiveFreehandLineBuilder(initialPoint, minSmoothingDist, maxSmoothingDist, viewport);
14
+ };
15
+ // Handles stroke smoothing and creates Strokes from user/stylus input.
16
+ export default class PressureSensitiveFreehandLineBuilder {
17
+ constructor(startPoint,
18
+ // Maximum distance from the actual curve (irrespective of stroke width)
19
+ // for which a point is considered 'part of the curve'.
20
+ // Note that the maximum will be smaller if the stroke width is less than
21
+ // [maxFitAllowed].
22
+ minFitAllowed, maxFitAllowed, viewport) {
23
+ this.startPoint = startPoint;
24
+ this.minFitAllowed = minFitAllowed;
25
+ this.viewport = viewport;
26
+ this.isFirstSegment = true;
27
+ this.pathStartConnector = null;
28
+ this.mostRecentConnector = null;
29
+ this.lastUpperBezier = null;
30
+ this.lastLowerBezier = null;
31
+ this.parts = [];
32
+ this.upperSegments = [];
33
+ this.lowerSegments = [];
34
+ this.curveFitter = new StrokeSmoother(startPoint, minFitAllowed, maxFitAllowed, curve => this.addCurve(curve));
35
+ this.curveStartWidth = startPoint.width;
36
+ this.bbox = new Rect2(this.startPoint.pos.x, this.startPoint.pos.y, 0, 0);
37
+ }
38
+ getBBox() {
39
+ return this.bbox;
40
+ }
41
+ getRenderingStyle() {
42
+ var _a;
43
+ return {
44
+ fill: (_a = this.startPoint.color) !== null && _a !== void 0 ? _a : null,
45
+ };
46
+ }
47
+ previewCurrentPath() {
48
+ var _a;
49
+ const upperPath = this.upperSegments.slice();
50
+ const lowerPath = this.lowerSegments.slice();
51
+ let lowerToUpperCap;
52
+ let pathStartConnector;
53
+ const currentCurve = this.curveFitter.preview();
54
+ if (currentCurve) {
55
+ const { upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand } = this.segmentToPath(currentCurve);
56
+ upperPath.push(upperCurveCommand);
57
+ lowerPath.push(lowerCurveCommand);
58
+ lowerToUpperCap = lowerToUpperConnector;
59
+ pathStartConnector = (_a = this.pathStartConnector) !== null && _a !== void 0 ? _a : upperToLowerConnector;
60
+ }
61
+ else {
62
+ if (this.mostRecentConnector === null || this.pathStartConnector === null) {
63
+ return null;
64
+ }
65
+ lowerToUpperCap = this.mostRecentConnector;
66
+ pathStartConnector = this.pathStartConnector;
67
+ }
68
+ let startPoint;
69
+ const lastLowerSegment = lowerPath[lowerPath.length - 1];
70
+ if (lastLowerSegment.kind === PathCommandType.LineTo || lastLowerSegment.kind === PathCommandType.MoveTo) {
71
+ startPoint = lastLowerSegment.point;
72
+ }
73
+ else {
74
+ startPoint = lastLowerSegment.endPoint;
75
+ }
76
+ return {
77
+ // Start at the end of the lower curve:
78
+ // Start point
79
+ // ↓
80
+ // __/ __/ ← Most recent points on this end
81
+ // /___ /
82
+ // ↑
83
+ // Oldest points
84
+ startPoint,
85
+ commands: [
86
+ // Move to the most recent point on the upperPath:
87
+ // ----→•
88
+ // __/ __/
89
+ // /___ /
90
+ lowerToUpperCap,
91
+ // Move to the beginning of the upperPath:
92
+ // __/ __/
93
+ // /___ /
94
+ // • ←-
95
+ ...upperPath.reverse(),
96
+ // Move to the beginning of the lowerPath:
97
+ // __/ __/
98
+ // /___ /
99
+ // •
100
+ pathStartConnector,
101
+ // Move back to the start point:
102
+ // •
103
+ // __/ __/
104
+ // /___ /
105
+ ...lowerPath,
106
+ ],
107
+ style: this.getRenderingStyle(),
108
+ };
109
+ }
110
+ previewFullPath() {
111
+ const preview = this.previewCurrentPath();
112
+ if (preview) {
113
+ return [...this.parts, preview];
114
+ }
115
+ return null;
116
+ }
117
+ previewStroke() {
118
+ const pathPreview = this.previewFullPath();
119
+ if (pathPreview) {
120
+ return new Stroke(pathPreview);
121
+ }
122
+ return null;
123
+ }
124
+ preview(renderer) {
125
+ const paths = this.previewFullPath();
126
+ if (paths) {
127
+ const approxBBox = this.viewport.visibleRect;
128
+ renderer.startObject(approxBBox);
129
+ for (const path of paths) {
130
+ renderer.drawPath(path);
131
+ }
132
+ renderer.endObject();
133
+ }
134
+ }
135
+ build() {
136
+ this.curveFitter.finalizeCurrentCurve();
137
+ if (this.isFirstSegment) {
138
+ // Ensure we have something.
139
+ this.addCurve(null);
140
+ }
141
+ return this.previewStroke();
142
+ }
143
+ roundPoint(point) {
144
+ let minFit = Math.min(this.minFitAllowed, this.curveStartWidth / 2);
145
+ if (minFit < 1e-10) {
146
+ minFit = this.minFitAllowed;
147
+ }
148
+ return Viewport.roundPoint(point, minFit);
149
+ }
150
+ // Returns true if, due to overlap with previous segments, a new RenderablePathSpec should be created.
151
+ shouldStartNewSegment(lowerCurve, upperCurve) {
152
+ if (!this.lastLowerBezier || !this.lastUpperBezier) {
153
+ return false;
154
+ }
155
+ const getIntersection = (curve1, curve2) => {
156
+ const intersection = curve1.intersects(curve2);
157
+ if (!intersection || intersection.length === 0) {
158
+ return null;
159
+ }
160
+ // From http://pomax.github.io/bezierjs/#intersect-curve,
161
+ // .intersects returns an array of 't1/t2' pairs, where curve1.at(t1) gives the point.
162
+ const firstTPair = intersection[0];
163
+ const match = /^([-0-9.eE]+)\/([-0-9.eE]+)$/.exec(firstTPair);
164
+ if (!match) {
165
+ throw new Error(`Incorrect format returned by .intersects: ${intersection} should be array of "number/number"!`);
166
+ }
167
+ const t = parseFloat(match[1]);
168
+ return Vec2.ofXY(curve1.get(t));
169
+ };
170
+ const getExitDirection = (curve) => {
171
+ return Vec2.ofXY(curve.points[2]).minus(Vec2.ofXY(curve.points[1])).normalized();
172
+ };
173
+ const getEnterDirection = (curve) => {
174
+ return Vec2.ofXY(curve.points[1]).minus(Vec2.ofXY(curve.points[0])).normalized();
175
+ };
176
+ // Prevent
177
+ // /
178
+ // / /
179
+ // / / /|
180
+ // / / |
181
+ // / |
182
+ // where the next stroke and the previous stroke are in different directions.
183
+ //
184
+ // Are the exit/enter directions of the previous and current curves in different enough directions?
185
+ if (getEnterDirection(upperCurve).dot(getExitDirection(this.lastUpperBezier)) < 0.3
186
+ || getEnterDirection(lowerCurve).dot(getExitDirection(this.lastLowerBezier)) < 0.3
187
+ // Also handle if the curves exit/enter directions differ
188
+ || getEnterDirection(upperCurve).dot(getExitDirection(upperCurve)) < 0
189
+ || getEnterDirection(lowerCurve).dot(getExitDirection(lowerCurve)) < 0) {
190
+ return true;
191
+ }
192
+ // Check whether the lower curve intersects the other wall:
193
+ // / / ← lower
194
+ // / / /
195
+ // / / /
196
+ // //
197
+ // / /
198
+ const lowerIntersection = getIntersection(lowerCurve, this.lastUpperBezier);
199
+ const upperIntersection = getIntersection(upperCurve, this.lastLowerBezier);
200
+ if (lowerIntersection || upperIntersection) {
201
+ return true;
202
+ }
203
+ return false;
204
+ }
205
+ addCurve(curve) {
206
+ // Case where no points have been added
207
+ if (!curve) {
208
+ // Don't create a circle around the initial point if the stroke has more than one point.
209
+ if (!this.isFirstSegment) {
210
+ return;
211
+ }
212
+ const width = Viewport.roundPoint(this.startPoint.width / 3.5, Math.min(this.minFitAllowed, this.startPoint.width / 4));
213
+ const center = this.roundPoint(this.startPoint.pos);
214
+ // Start on the right, cycle clockwise:
215
+ // |
216
+ // ----- ←
217
+ // |
218
+ const startPoint = this.startPoint.pos.plus(Vec2.of(width, 0));
219
+ // Draw a circle-ish shape around the start point
220
+ this.lowerSegments.push({
221
+ kind: PathCommandType.QuadraticBezierTo,
222
+ controlPoint: center.plus(Vec2.of(width, width)),
223
+ // Bottom of the circle
224
+ // |
225
+ // -----
226
+ // |
227
+ // ↑
228
+ endPoint: center.plus(Vec2.of(0, width)),
229
+ }, {
230
+ kind: PathCommandType.QuadraticBezierTo,
231
+ controlPoint: center.plus(Vec2.of(-width, width)),
232
+ endPoint: center.plus(Vec2.of(-width, 0)),
233
+ }, {
234
+ kind: PathCommandType.QuadraticBezierTo,
235
+ controlPoint: center.plus(Vec2.of(-width, -width)),
236
+ endPoint: center.plus(Vec2.of(0, -width)),
237
+ }, {
238
+ kind: PathCommandType.QuadraticBezierTo,
239
+ controlPoint: center.plus(Vec2.of(width, -width)),
240
+ endPoint: center.plus(Vec2.of(width, 0)),
241
+ });
242
+ this.pathStartConnector = {
243
+ kind: PathCommandType.LineTo,
244
+ point: startPoint,
245
+ };
246
+ this.mostRecentConnector = this.pathStartConnector;
247
+ return;
248
+ }
249
+ const { upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand, lowerCurve, upperCurve, } = this.segmentToPath(curve);
250
+ const shouldStartNew = this.shouldStartNewSegment(lowerCurve, upperCurve);
251
+ if (shouldStartNew) {
252
+ const part = this.previewCurrentPath();
253
+ if (part) {
254
+ this.parts.push(part);
255
+ this.upperSegments = [];
256
+ this.lowerSegments = [];
257
+ }
258
+ }
259
+ if (this.isFirstSegment || shouldStartNew) {
260
+ // We draw the upper path (reversed), then the lower path, so we need the
261
+ // upperToLowerConnector to join the two paths.
262
+ this.pathStartConnector = upperToLowerConnector;
263
+ this.isFirstSegment = false;
264
+ }
265
+ // With the most recent connector, we're joining the end of the lowerPath to the most recent
266
+ // upperPath:
267
+ this.mostRecentConnector = lowerToUpperConnector;
268
+ this.lowerSegments.push(lowerCurveCommand);
269
+ this.upperSegments.push(upperCurveCommand);
270
+ this.lastLowerBezier = lowerCurve;
271
+ this.lastUpperBezier = upperCurve;
272
+ this.curveStartWidth = curve.startWidth;
273
+ }
274
+ // Returns [upper curve, connector, lower curve]
275
+ segmentToPath(curve) {
276
+ const bezier = new Bezier(curve.startPoint.xy, curve.controlPoint.xy, curve.endPoint.xy);
277
+ let startVec = Vec2.ofXY(bezier.normal(0)).normalized();
278
+ let endVec = Vec2.ofXY(bezier.normal(1)).normalized();
279
+ startVec = startVec.times(curve.startWidth / 2);
280
+ endVec = endVec.times(curve.endWidth / 2);
281
+ if (!isFinite(startVec.magnitude())) {
282
+ console.error('Warning: startVec is NaN or ∞', startVec, endVec, curve);
283
+ startVec = endVec;
284
+ }
285
+ const startPt = curve.startPoint;
286
+ const endPt = curve.endPoint;
287
+ const controlPoint = curve.controlPoint;
288
+ // Approximate the normal at the location of the control point
289
+ let projectionT = bezier.project(controlPoint.xy).t;
290
+ if (!projectionT) {
291
+ if (startPt.minus(controlPoint).magnitudeSquared() < endPt.minus(controlPoint).magnitudeSquared()) {
292
+ projectionT = 0.1;
293
+ }
294
+ else {
295
+ projectionT = 0.9;
296
+ }
297
+ }
298
+ const halfVecT = projectionT;
299
+ const halfVec = Vec2.ofXY(bezier.normal(halfVecT))
300
+ .normalized().times(curve.startWidth / 2 * halfVecT
301
+ + curve.endWidth / 2 * (1 - halfVecT));
302
+ // Each starts at startPt ± startVec
303
+ const lowerCurveStartPoint = this.roundPoint(startPt.plus(startVec));
304
+ const lowerCurveControlPoint = this.roundPoint(controlPoint.plus(halfVec));
305
+ const lowerCurveEndPoint = this.roundPoint(endPt.plus(endVec));
306
+ const upperCurveControlPoint = this.roundPoint(controlPoint.minus(halfVec));
307
+ const upperCurveStartPoint = this.roundPoint(endPt.minus(endVec));
308
+ const upperCurveEndPoint = this.roundPoint(startPt.minus(startVec));
309
+ const lowerCurveCommand = {
310
+ kind: PathCommandType.QuadraticBezierTo,
311
+ controlPoint: lowerCurveControlPoint,
312
+ endPoint: lowerCurveEndPoint,
313
+ };
314
+ // From the end of the upperCurve to the start of the lowerCurve:
315
+ const upperToLowerConnector = {
316
+ kind: PathCommandType.LineTo,
317
+ point: lowerCurveStartPoint,
318
+ };
319
+ // From the end of lowerCurve to the start of upperCurve:
320
+ const lowerToUpperConnector = {
321
+ kind: PathCommandType.LineTo,
322
+ point: upperCurveStartPoint,
323
+ };
324
+ const upperCurveCommand = {
325
+ kind: PathCommandType.QuadraticBezierTo,
326
+ controlPoint: upperCurveControlPoint,
327
+ endPoint: upperCurveEndPoint,
328
+ };
329
+ const upperCurve = new Bezier(upperCurveStartPoint, upperCurveControlPoint, upperCurveEndPoint);
330
+ const lowerCurve = new Bezier(lowerCurveStartPoint, lowerCurveControlPoint, lowerCurveEndPoint);
331
+ return {
332
+ upperCurveCommand, upperToLowerConnector, lowerToUpperConnector, lowerCurveCommand,
333
+ upperCurve, lowerCurve,
334
+ };
335
+ }
336
+ addPoint(newPoint) {
337
+ this.curveFitter.addPoint(newPoint);
338
+ }
339
+ }
@@ -1,5 +1,7 @@
1
1
  export * from './builders/types';
2
2
  export { makeFreehandLineBuilder } from './builders/FreehandLineBuilder';
3
+ export { makePressureSensitiveFreehandLineBuilder } from './builders/PressureSensitiveFreehandLineBuilder';
4
+ export { default as StrokeSmoother, Curve as StrokeSmootherCurve } from './util/StrokeSmoother';
3
5
  export * from './AbstractComponent';
4
6
  export { default as AbstractComponent } from './AbstractComponent';
5
7
  import Stroke from './Stroke';
@@ -1,5 +1,7 @@
1
1
  export * from './builders/types';
2
2
  export { makeFreehandLineBuilder } from './builders/FreehandLineBuilder';
3
+ export { makePressureSensitiveFreehandLineBuilder } from './builders/PressureSensitiveFreehandLineBuilder';
4
+ export { default as StrokeSmoother } from './util/StrokeSmoother';
3
5
  export * from './AbstractComponent';
4
6
  export { default as AbstractComponent } from './AbstractComponent';
5
7
  import Stroke from './Stroke';
@@ -0,0 +1,35 @@
1
+ import { Vec2 } from '../../math/Vec2';
2
+ import Rect2 from '../../math/Rect2';
3
+ import { StrokeDataPoint } from '../../types';
4
+ export interface Curve {
5
+ startPoint: Vec2;
6
+ startWidth: number;
7
+ controlPoint: Vec2;
8
+ endWidth: number;
9
+ endPoint: Vec2;
10
+ }
11
+ declare type OnCurveAddedCallback = (curve: Curve | null) => void;
12
+ export declare class StrokeSmoother {
13
+ private startPoint;
14
+ private minFitAllowed;
15
+ private maxFitAllowed;
16
+ private onCurveAdded;
17
+ private isFirstSegment;
18
+ private buffer;
19
+ private lastPoint;
20
+ private lastExitingVec;
21
+ private currentCurve;
22
+ private curveStartWidth;
23
+ private curveEndWidth;
24
+ private momentum;
25
+ private bbox;
26
+ constructor(startPoint: StrokeDataPoint, minFitAllowed: number, maxFitAllowed: number, onCurveAdded: OnCurveAddedCallback);
27
+ getBBox(): Rect2;
28
+ preview(): Curve | null;
29
+ private approxCurrentCurveLength;
30
+ finalizeCurrentCurve(): void;
31
+ private currentSegmentToPath;
32
+ private computeExitingVec;
33
+ addPoint(newPoint: StrokeDataPoint): void;
34
+ }
35
+ export default StrokeSmoother;
@@ -0,0 +1,206 @@
1
+ import { Bezier } from 'bezier-js';
2
+ import { Vec2 } from '../../math/Vec2';
3
+ import Rect2 from '../../math/Rect2';
4
+ import LineSegment2 from '../../math/LineSegment2';
5
+ // Handles stroke smoothing
6
+ export class StrokeSmoother {
7
+ constructor(startPoint,
8
+ // Maximum distance from the actual curve (irrespective of stroke width)
9
+ // for which a point is considered 'part of the curve'.
10
+ // Note that the maximum will be smaller if the stroke width is less than
11
+ // [maxFitAllowed].
12
+ minFitAllowed, maxFitAllowed, onCurveAdded) {
13
+ this.startPoint = startPoint;
14
+ this.minFitAllowed = minFitAllowed;
15
+ this.maxFitAllowed = maxFitAllowed;
16
+ this.onCurveAdded = onCurveAdded;
17
+ this.isFirstSegment = true;
18
+ this.lastExitingVec = null;
19
+ this.currentCurve = null;
20
+ this.lastPoint = this.startPoint;
21
+ this.buffer = [this.startPoint.pos];
22
+ this.momentum = Vec2.zero;
23
+ this.currentCurve = null;
24
+ this.curveStartWidth = startPoint.width;
25
+ this.bbox = new Rect2(this.startPoint.pos.x, this.startPoint.pos.y, 0, 0);
26
+ }
27
+ getBBox() {
28
+ return this.bbox;
29
+ }
30
+ preview() {
31
+ if (!this.currentCurve) {
32
+ return null;
33
+ }
34
+ return this.currentSegmentToPath();
35
+ }
36
+ // Returns the distance between the start, control, and end points of the curve.
37
+ approxCurrentCurveLength() {
38
+ if (!this.currentCurve) {
39
+ return 0;
40
+ }
41
+ const startPt = Vec2.ofXY(this.currentCurve.points[0]);
42
+ const controlPt = Vec2.ofXY(this.currentCurve.points[1]);
43
+ const endPt = Vec2.ofXY(this.currentCurve.points[2]);
44
+ const toControlDist = startPt.minus(controlPt).length();
45
+ const toEndDist = endPt.minus(controlPt).length();
46
+ return toControlDist + toEndDist;
47
+ }
48
+ finalizeCurrentCurve() {
49
+ // Case where no points have been added
50
+ if (!this.currentCurve) {
51
+ return;
52
+ }
53
+ this.onCurveAdded(this.currentSegmentToPath());
54
+ const lastPoint = this.buffer[this.buffer.length - 1];
55
+ this.lastExitingVec = Vec2.ofXY(this.currentCurve.points[2]).minus(Vec2.ofXY(this.currentCurve.points[1]));
56
+ console.assert(this.lastExitingVec.magnitude() !== 0, 'lastExitingVec has zero length!');
57
+ // Use the last two points to start a new curve (the last point isn't used
58
+ // in the current curve and we want connected curves to share end points)
59
+ this.buffer = [
60
+ this.buffer[this.buffer.length - 2], lastPoint,
61
+ ];
62
+ this.currentCurve = null;
63
+ }
64
+ // Returns [upper curve, connector, lower curve]
65
+ currentSegmentToPath() {
66
+ if (this.currentCurve == null) {
67
+ throw new Error('Invalid State: currentCurve is null!');
68
+ }
69
+ const startVec = Vec2.ofXY(this.currentCurve.normal(0)).normalized();
70
+ if (!isFinite(startVec.magnitude())) {
71
+ throw new Error(`startVec(${startVec}) is NaN or ∞`);
72
+ }
73
+ const startPt = Vec2.ofXY(this.currentCurve.get(0));
74
+ const endPt = Vec2.ofXY(this.currentCurve.get(1));
75
+ const controlPoint = Vec2.ofXY(this.currentCurve.points[1]);
76
+ return {
77
+ startPoint: startPt,
78
+ controlPoint,
79
+ endPoint: endPt,
80
+ startWidth: this.curveStartWidth,
81
+ endWidth: this.curveEndWidth,
82
+ };
83
+ }
84
+ // Compute the direction of the velocity at the end of this.buffer
85
+ computeExitingVec() {
86
+ return this.momentum.normalized().times(this.lastPoint.width / 2);
87
+ }
88
+ addPoint(newPoint) {
89
+ var _a, _b;
90
+ if (this.lastPoint) {
91
+ // Ignore points that are identical
92
+ const fuzzEq = 1e-10;
93
+ const deltaTime = newPoint.time - this.lastPoint.time;
94
+ if (newPoint.pos.eq(this.lastPoint.pos, fuzzEq) || deltaTime === 0) {
95
+ return;
96
+ }
97
+ else if (isNaN(newPoint.pos.magnitude())) {
98
+ console.warn('Discarding NaN point.', newPoint);
99
+ return;
100
+ }
101
+ const threshold = Math.min(this.lastPoint.width, newPoint.width) / 3;
102
+ const shouldSnapToInitial = this.startPoint.pos.minus(newPoint.pos).magnitude() < threshold
103
+ && this.isFirstSegment;
104
+ // Snap to the starting point if the stroke is contained within a small ball centered
105
+ // at the starting point.
106
+ // This allows us to create a circle/dot at the start of the stroke.
107
+ if (shouldSnapToInitial) {
108
+ return;
109
+ }
110
+ const velocity = newPoint.pos.minus(this.lastPoint.pos).times(1 / (deltaTime) * 1000);
111
+ this.momentum = this.momentum.lerp(velocity, 0.9);
112
+ }
113
+ const lastPoint = (_a = this.lastPoint) !== null && _a !== void 0 ? _a : newPoint;
114
+ this.lastPoint = newPoint;
115
+ this.buffer.push(newPoint.pos);
116
+ const pointRadius = newPoint.width / 2;
117
+ const prevEndWidth = this.curveEndWidth;
118
+ this.curveEndWidth = pointRadius;
119
+ if (this.isFirstSegment) {
120
+ // The start of a curve often lacks accurate pressure information. Update it.
121
+ this.curveStartWidth = (this.curveStartWidth + pointRadius) / 2;
122
+ }
123
+ // recompute bbox
124
+ this.bbox = this.bbox.grownToPoint(newPoint.pos, pointRadius);
125
+ if (this.currentCurve === null) {
126
+ const p1 = lastPoint.pos;
127
+ const p2 = lastPoint.pos.plus((_b = this.lastExitingVec) !== null && _b !== void 0 ? _b : Vec2.unitX);
128
+ const p3 = newPoint.pos;
129
+ // Quadratic Bézier curve
130
+ this.currentCurve = new Bezier(p1.xy, p2.xy, p3.xy);
131
+ this.curveStartWidth = lastPoint.width / 2;
132
+ console.assert(!isNaN(p1.magnitude()) && !isNaN(p2.magnitude()) && !isNaN(p3.magnitude()), 'Expected !NaN');
133
+ }
134
+ // If there isn't an entering vector (e.g. because this.isFirstCurve), approximate it.
135
+ let enteringVec = this.lastExitingVec;
136
+ if (!enteringVec) {
137
+ let sampleIdx = Math.ceil(this.buffer.length / 2);
138
+ if (sampleIdx === 0 || sampleIdx >= this.buffer.length) {
139
+ sampleIdx = this.buffer.length - 1;
140
+ }
141
+ enteringVec = this.buffer[sampleIdx].minus(this.buffer[0]);
142
+ }
143
+ let exitingVec = this.computeExitingVec();
144
+ // Find the intersection between the entering vector and the exiting vector
145
+ const maxRelativeLength = 2.2;
146
+ const segmentStart = this.buffer[0];
147
+ const segmentEnd = newPoint.pos;
148
+ const startEndDist = segmentEnd.minus(segmentStart).magnitude();
149
+ const maxControlPointDist = maxRelativeLength * startEndDist;
150
+ // Exit in cases where we would divide by zero
151
+ if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || !isFinite(exitingVec.magnitude())) {
152
+ return;
153
+ }
154
+ console.assert(isFinite(enteringVec.magnitude()), 'Pre-normalized enteringVec has NaN or ∞ magnitude!');
155
+ enteringVec = enteringVec.normalized();
156
+ exitingVec = exitingVec.normalized();
157
+ console.assert(isFinite(enteringVec.magnitude()), 'Normalized enteringVec has NaN or ∞ magnitude!');
158
+ const lineFromStart = new LineSegment2(segmentStart, segmentStart.plus(enteringVec.times(maxControlPointDist)));
159
+ const lineFromEnd = new LineSegment2(segmentEnd.minus(exitingVec.times(maxControlPointDist)), segmentEnd);
160
+ const intersection = lineFromEnd.intersection(lineFromStart);
161
+ // Position the control point at this intersection
162
+ let controlPoint = null;
163
+ if (intersection) {
164
+ controlPoint = intersection.point;
165
+ }
166
+ // No intersection or the intersection is one of the end points?
167
+ if (!controlPoint || segmentStart.eq(controlPoint) || segmentEnd.eq(controlPoint)) {
168
+ // Position the control point closer to the first -- the connecting
169
+ // segment will be roughly a line.
170
+ controlPoint = segmentStart.plus(enteringVec.times(startEndDist / 3));
171
+ }
172
+ console.assert(!segmentStart.eq(controlPoint, 1e-11), 'Start and control points are equal!');
173
+ console.assert(!controlPoint.eq(segmentEnd, 1e-11), 'Control and end points are equal!');
174
+ const prevCurve = this.currentCurve;
175
+ this.currentCurve = new Bezier(segmentStart.xy, controlPoint.xy, segmentEnd.xy);
176
+ if (isNaN(Vec2.ofXY(this.currentCurve.normal(0)).magnitude())) {
177
+ console.error('NaN normal at 0. Curve:', this.currentCurve);
178
+ this.currentCurve = prevCurve;
179
+ }
180
+ // Should we start making a new curve? Check whether all buffer points are within
181
+ // ±strokeWidth of the curve.
182
+ const curveMatchesPoints = (curve) => {
183
+ for (const point of this.buffer) {
184
+ const proj = Vec2.ofXY(curve.project(point.xy));
185
+ const dist = proj.minus(point).magnitude();
186
+ const minFit = Math.max(Math.min(this.curveStartWidth, this.curveEndWidth) / 3, this.minFitAllowed);
187
+ if (dist > minFit || dist > this.maxFitAllowed) {
188
+ return false;
189
+ }
190
+ }
191
+ return true;
192
+ };
193
+ if (this.buffer.length > 3 && this.approxCurrentCurveLength() > this.curveStartWidth) {
194
+ if (!curveMatchesPoints(this.currentCurve)) {
195
+ // Use a curve that better fits the points
196
+ this.currentCurve = prevCurve;
197
+ this.curveEndWidth = prevEndWidth;
198
+ // Reset the last point -- the current point was not added to the curve.
199
+ this.lastPoint = lastPoint;
200
+ this.finalizeCurrentCurve();
201
+ return;
202
+ }
203
+ }
204
+ }
205
+ }
206
+ export default StrokeSmoother;
@@ -94,6 +94,8 @@ export default class Mat33 {
94
94
  * ```
95
95
  */
96
96
  mapEntries(mapping: (component: number) => number): Mat33;
97
+ /** Estimate the scale factor of this matrix (based on the first row). */
98
+ getScaleFactor(): number;
97
99
  /** Constructs a 3x3 translation matrix (for translating `Vec2`s) */
98
100
  static translation(amount: Vec2): Mat33;
99
101
  static zRotation(radians: number, center?: Point2): Mat33;
@@ -199,6 +199,10 @@ export default class Mat33 {
199
199
  mapEntries(mapping) {
200
200
  return new Mat33(mapping(this.a1), mapping(this.a2), mapping(this.a3), mapping(this.b1), mapping(this.b2), mapping(this.b3), mapping(this.c1), mapping(this.c2), mapping(this.c3));
201
201
  }
202
+ /** Estimate the scale factor of this matrix (based on the first row). */
203
+ getScaleFactor() {
204
+ return Math.hypot(this.a1, this.a2);
205
+ }
202
206
  /** Constructs a 3x3 translation matrix (for translating `Vec2`s) */
203
207
  static translation(amount) {
204
208
  // When transforming Vec2s by a 3x3 matrix, we give the input
@@ -50,6 +50,8 @@ export default class Path {
50
50
  mapPoints(mapping: (point: Point2) => Point2): Path;
51
51
  transformedBy(affineTransfm: Mat33): Path;
52
52
  union(other: Path | null): Path;
53
+ private getEndPoint;
54
+ roughlyIntersects(rect: Rect2, strokeWidth?: number): boolean;
53
55
  closedRoughlyIntersects(rect: Rect2): boolean;
54
56
  static fromRect(rect: Rect2, lineWidth?: number | null): Path;
55
57
  static fromRenderable(renderable: RenderablePathSpec): Path;