js-draw 0.7.2 → 0.8.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/.github/ISSUE_TEMPLATE/translation.md +1 -0
- package/CHANGELOG.md +4 -0
- package/dist/bundle.js +1 -1
- package/dist/src/components/Stroke.js +10 -3
- package/dist/src/components/builders/FreehandLineBuilder.d.ts +10 -23
- package/dist/src/components/builders/FreehandLineBuilder.js +70 -396
- package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.d.ts +36 -0
- package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.js +339 -0
- package/dist/src/components/lib.d.ts +2 -0
- package/dist/src/components/lib.js +2 -0
- package/dist/src/components/util/StrokeSmoother.d.ts +35 -0
- package/dist/src/components/util/StrokeSmoother.js +206 -0
- package/dist/src/math/Mat33.d.ts +2 -0
- package/dist/src/math/Mat33.js +4 -0
- package/dist/src/math/Path.d.ts +2 -0
- package/dist/src/math/Path.js +39 -0
- package/dist/src/rendering/renderers/CanvasRenderer.js +2 -0
- package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -0
- package/dist/src/rendering/renderers/SVGRenderer.js +20 -0
- package/dist/src/toolbar/localization.d.ts +1 -0
- package/dist/src/toolbar/localization.js +1 -0
- package/dist/src/toolbar/widgets/PenToolWidget.js +6 -1
- package/dist/src/tools/Pen.d.ts +2 -2
- package/dist/src/tools/Pen.js +2 -2
- package/dist/src/tools/SelectionTool/Selection.d.ts +1 -0
- package/dist/src/tools/SelectionTool/Selection.js +8 -1
- package/dist/src/tools/ToolController.js +2 -1
- package/package.json +1 -1
- package/src/components/Stroke.ts +16 -3
- package/src/components/builders/FreehandLineBuilder.ts +54 -495
- package/src/components/builders/PressureSensitiveFreehandLineBuilder.ts +454 -0
- package/src/components/lib.ts +3 -1
- package/src/components/util/StrokeSmoother.ts +290 -0
- package/src/math/Mat33.ts +5 -0
- package/src/math/Path.test.ts +25 -0
- package/src/math/Path.ts +45 -0
- package/src/rendering/renderers/CanvasRenderer.ts +2 -0
- package/src/rendering/renderers/SVGRenderer.ts +24 -0
- package/src/toolbar/localization.ts +2 -0
- package/src/toolbar/widgets/PenToolWidget.ts +6 -1
- package/src/tools/Pen.test.ts +2 -2
- package/src/tools/Pen.ts +1 -1
- package/src/tools/SelectionTool/Selection.ts +10 -1
- 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;
|
package/dist/src/math/Mat33.d.ts
CHANGED
@@ -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;
|
package/dist/src/math/Mat33.js
CHANGED
@@ -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
|
package/dist/src/math/Path.d.ts
CHANGED
@@ -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;
|
package/dist/src/math/Path.js
CHANGED
@@ -195,6 +195,45 @@ export default class Path {
|
|
195
195
|
...other.parts,
|
196
196
|
]);
|
197
197
|
}
|
198
|
+
getEndPoint() {
|
199
|
+
if (this.parts.length === 0) {
|
200
|
+
return this.startPoint;
|
201
|
+
}
|
202
|
+
const lastPart = this.parts[this.parts.length - 1];
|
203
|
+
if (lastPart.kind === PathCommandType.QuadraticBezierTo || lastPart.kind === PathCommandType.CubicBezierTo) {
|
204
|
+
return lastPart.endPoint;
|
205
|
+
}
|
206
|
+
else {
|
207
|
+
return lastPart.point;
|
208
|
+
}
|
209
|
+
}
|
210
|
+
roughlyIntersects(rect, strokeWidth = 0) {
|
211
|
+
if (this.parts.length === 0) {
|
212
|
+
return rect.containsPoint(this.startPoint);
|
213
|
+
}
|
214
|
+
const isClosed = this.startPoint.eq(this.getEndPoint());
|
215
|
+
if (isClosed && strokeWidth == 0) {
|
216
|
+
return this.closedRoughlyIntersects(rect);
|
217
|
+
}
|
218
|
+
if (rect.containsRect(this.bbox)) {
|
219
|
+
return true;
|
220
|
+
}
|
221
|
+
// Does the rectangle intersect the bounding boxes of any of this' parts?
|
222
|
+
let startPoint = this.startPoint;
|
223
|
+
for (const part of this.parts) {
|
224
|
+
const bbox = Path.computeBBoxForSegment(startPoint, part).grownBy(strokeWidth);
|
225
|
+
if (part.kind === PathCommandType.LineTo || part.kind === PathCommandType.MoveTo) {
|
226
|
+
startPoint = part.point;
|
227
|
+
}
|
228
|
+
else {
|
229
|
+
startPoint = part.endPoint;
|
230
|
+
}
|
231
|
+
if (rect.intersects(bbox)) {
|
232
|
+
return true;
|
233
|
+
}
|
234
|
+
}
|
235
|
+
return false;
|
236
|
+
}
|
198
237
|
// Treats this as a closed path and returns true if part of `rect` is roughly within
|
199
238
|
// this path's interior.
|
200
239
|
//
|