@inditextech/weave-sdk 5.7.0 → 5.8.0-SNAPSHOT.613.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/dist/lab.d.ts +289 -0
- package/dist/lab.d.ts.map +1 -0
- package/dist/lab.js +1438 -0
- package/dist/lab.js.map +1 -0
- package/dist/lab.stats.html +5305 -0
- package/dist/sdk.js +3250 -174
- package/dist/sdk.node.js +3250 -174
- package/dist/sdk.node.stats.html +1 -1
- package/dist/sdk.stats.html +1 -1
- package/dist/types.d.ts +610 -31
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3250 -174
- package/dist/types.js.map +1 -1
- package/dist/types.stats.html +1 -1
- package/package.json +8 -3
package/dist/lab.js
ADDED
|
@@ -0,0 +1,1438 @@
|
|
|
1
|
+
import Konva from "konva";
|
|
2
|
+
import { WeavePlugin } from "@inditextech/weave-sdk";
|
|
3
|
+
import simplify from "simplify-js";
|
|
4
|
+
//#region src/actions/brush-tool/constants.ts
|
|
5
|
+
/** Name under which the lab plugin registers itself. */
|
|
6
|
+
const BRUSH_LAB_PLUGIN_NAME = "brushLab";
|
|
7
|
+
/**
|
|
8
|
+
* The legacy input profile: reproduces, point for point, what the brush tool
|
|
9
|
+
* recorded before profiles existed. Every profile starts from it.
|
|
10
|
+
*/
|
|
11
|
+
const BRUSH_LEGACY_INPUT_PROFILE = {
|
|
12
|
+
capture: {
|
|
13
|
+
source: "legacy",
|
|
14
|
+
clock: "now",
|
|
15
|
+
minMovePx: 0,
|
|
16
|
+
predicted: "tail",
|
|
17
|
+
event: "pointermove"
|
|
18
|
+
},
|
|
19
|
+
pressure: {
|
|
20
|
+
zero: "legacy",
|
|
21
|
+
filter: "legacy",
|
|
22
|
+
timeConstantMs: 40,
|
|
23
|
+
oneEuro: {
|
|
24
|
+
minCutoff: 1,
|
|
25
|
+
beta: .01
|
|
26
|
+
},
|
|
27
|
+
floor: .15,
|
|
28
|
+
start: "backfill",
|
|
29
|
+
startSamples: 3,
|
|
30
|
+
end: "backfill"
|
|
31
|
+
},
|
|
32
|
+
stabilizer: {
|
|
33
|
+
type: "none",
|
|
34
|
+
minCutoff: 3,
|
|
35
|
+
beta: .01,
|
|
36
|
+
radiusPx: 4,
|
|
37
|
+
amount: .3,
|
|
38
|
+
zoomAware: false,
|
|
39
|
+
catchUpOnEnd: true
|
|
40
|
+
},
|
|
41
|
+
finalize: {
|
|
42
|
+
mode: "dp",
|
|
43
|
+
toleranceUnit: "canvas",
|
|
44
|
+
pressureWeight: 20,
|
|
45
|
+
resampleSpacingPx: 2
|
|
46
|
+
},
|
|
47
|
+
touchGuard: {
|
|
48
|
+
waitMs: 0,
|
|
49
|
+
movePx: 0,
|
|
50
|
+
cancelOnSecondTouch: false,
|
|
51
|
+
ignoreAfterPen: false
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/actions/brush-tool/tunables/merge.ts
|
|
56
|
+
const isPlain = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
57
|
+
/** Deep clone of plain data. */
|
|
58
|
+
function cloneData(value) {
|
|
59
|
+
if (Array.isArray(value)) return value.map(cloneData);
|
|
60
|
+
if (isPlain(value)) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, cloneData(v)]));
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
/** Returns a new object: `base` with every defined value of `patch` merged in. */
|
|
64
|
+
function mergeData(base, patch) {
|
|
65
|
+
const result = cloneData(base);
|
|
66
|
+
if (!isPlain(patch)) return result;
|
|
67
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
68
|
+
if (v === void 0) continue;
|
|
69
|
+
result[k] = isPlain(v) && isPlain(result[k]) ? mergeData(result[k], v) : cloneData(v);
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
/** Freezes plain data recursively. */
|
|
74
|
+
function deepFreeze(value) {
|
|
75
|
+
if (isPlain(value) || Array.isArray(value)) {
|
|
76
|
+
for (const v of Object.values(value)) deepFreeze(v);
|
|
77
|
+
Object.freeze(value);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/actions/brush-tool/tunables/presets.ts
|
|
83
|
+
const FIXES = {
|
|
84
|
+
capture: {
|
|
85
|
+
source: "coalesced",
|
|
86
|
+
clock: "event"
|
|
87
|
+
},
|
|
88
|
+
pressure: { zero: "keep" }
|
|
89
|
+
};
|
|
90
|
+
const withFixes = (patch) => mergeData(FIXES, patch);
|
|
91
|
+
const PEN_SMOOTH = withFixes({
|
|
92
|
+
pressure: {
|
|
93
|
+
filter: "oneEuro",
|
|
94
|
+
oneEuro: {
|
|
95
|
+
minCutoff: 1.5,
|
|
96
|
+
beta: .02
|
|
97
|
+
},
|
|
98
|
+
start: "average"
|
|
99
|
+
},
|
|
100
|
+
stabilizer: {
|
|
101
|
+
type: "oneEuro",
|
|
102
|
+
minCutoff: 6,
|
|
103
|
+
beta: .05,
|
|
104
|
+
zoomAware: true
|
|
105
|
+
},
|
|
106
|
+
finalize: {
|
|
107
|
+
mode: "bezierFit",
|
|
108
|
+
toleranceUnit: "screen",
|
|
109
|
+
tolerance: 2,
|
|
110
|
+
resampleSpacingPx: 8
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
const PEN_ILLUSTRATOR = withFixes({
|
|
114
|
+
pressure: {
|
|
115
|
+
filter: "timeEma",
|
|
116
|
+
timeConstantMs: 30,
|
|
117
|
+
start: "average"
|
|
118
|
+
},
|
|
119
|
+
stabilizer: {
|
|
120
|
+
type: "streamline",
|
|
121
|
+
amount: .25,
|
|
122
|
+
zoomAware: true
|
|
123
|
+
},
|
|
124
|
+
finalize: {
|
|
125
|
+
mode: "bezierFit",
|
|
126
|
+
toleranceUnit: "screen",
|
|
127
|
+
tolerance: 2,
|
|
128
|
+
resampleSpacingPx: 8
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const TOUCH_GUARD = withFixes({ touchGuard: {
|
|
132
|
+
waitMs: 100,
|
|
133
|
+
movePx: 6,
|
|
134
|
+
cancelOnSecondTouch: true
|
|
135
|
+
} });
|
|
136
|
+
const legacy = () => ({
|
|
137
|
+
id: "legacy",
|
|
138
|
+
label: "Legacy",
|
|
139
|
+
description: "Behaviour of earlier releases, point for point.",
|
|
140
|
+
patch: {}
|
|
141
|
+
});
|
|
142
|
+
const fixed = () => ({
|
|
143
|
+
id: "fixed",
|
|
144
|
+
label: "Fixed",
|
|
145
|
+
description: "Keeps every pen sample, uses event time, keeps zero pressure.",
|
|
146
|
+
patch: FIXES
|
|
147
|
+
});
|
|
148
|
+
const penPresets = () => [
|
|
149
|
+
legacy(),
|
|
150
|
+
fixed(),
|
|
151
|
+
{
|
|
152
|
+
id: "smooth",
|
|
153
|
+
label: "Smooth",
|
|
154
|
+
description: "Fixed, plus One Euro smoothing of pressure and position and a Bézier-fitted finish.",
|
|
155
|
+
patch: PEN_SMOOTH
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "illustrator",
|
|
159
|
+
label: "Illustrator-like",
|
|
160
|
+
description: "Fixed, plus streamline stabilizing and a Bézier-fitted finish in screen pixels.",
|
|
161
|
+
patch: PEN_ILLUSTRATOR
|
|
162
|
+
}
|
|
163
|
+
];
|
|
164
|
+
const RAW_PROFILE_PRESETS = {
|
|
165
|
+
mouse: [
|
|
166
|
+
legacy(),
|
|
167
|
+
fixed(),
|
|
168
|
+
{
|
|
169
|
+
id: "stabilized",
|
|
170
|
+
label: "Stabilized",
|
|
171
|
+
description: "Fixed, plus One Euro position smoothing that follows the zoom and a Bézier-fitted finish.",
|
|
172
|
+
patch: withFixes({
|
|
173
|
+
stabilizer: {
|
|
174
|
+
type: "oneEuro",
|
|
175
|
+
minCutoff: 2,
|
|
176
|
+
beta: .01,
|
|
177
|
+
zoomAware: true
|
|
178
|
+
},
|
|
179
|
+
finalize: {
|
|
180
|
+
mode: "bezierFit",
|
|
181
|
+
toleranceUnit: "screen",
|
|
182
|
+
tolerance: 2,
|
|
183
|
+
resampleSpacingPx: 8
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "pulledString",
|
|
189
|
+
label: "Pulled string",
|
|
190
|
+
description: "Fixed, plus a pulled-string stabilizer.",
|
|
191
|
+
patch: withFixes({ stabilizer: {
|
|
192
|
+
type: "pulledString",
|
|
193
|
+
radiusPx: 6,
|
|
194
|
+
zoomAware: true
|
|
195
|
+
} })
|
|
196
|
+
}
|
|
197
|
+
],
|
|
198
|
+
touch: [
|
|
199
|
+
legacy(),
|
|
200
|
+
fixed(),
|
|
201
|
+
{
|
|
202
|
+
id: "guarded",
|
|
203
|
+
label: "Guarded",
|
|
204
|
+
description: "Fixed, plus a short wait that a second finger can cancel.",
|
|
205
|
+
patch: TOUCH_GUARD
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "smooth",
|
|
209
|
+
label: "Smooth",
|
|
210
|
+
description: "Guarded, plus One Euro position smoothing and a Bézier-fitted finish.",
|
|
211
|
+
patch: mergeData(TOUCH_GUARD, {
|
|
212
|
+
stabilizer: {
|
|
213
|
+
type: "oneEuro",
|
|
214
|
+
minCutoff: 3,
|
|
215
|
+
beta: .02,
|
|
216
|
+
zoomAware: true
|
|
217
|
+
},
|
|
218
|
+
finalize: {
|
|
219
|
+
mode: "bezierFit",
|
|
220
|
+
toleranceUnit: "screen",
|
|
221
|
+
tolerance: 2,
|
|
222
|
+
resampleSpacingPx: 8
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
],
|
|
227
|
+
"pen.applePencil": penPresets(),
|
|
228
|
+
"pen.desktop": penPresets()
|
|
229
|
+
};
|
|
230
|
+
const WIDTH_CURVE = { width: {
|
|
231
|
+
min: .2,
|
|
232
|
+
gamma: .8
|
|
233
|
+
} };
|
|
234
|
+
const ROUND = {
|
|
235
|
+
...WIDTH_CURVE,
|
|
236
|
+
outline: "round",
|
|
237
|
+
caps: "merged",
|
|
238
|
+
curve: "centripetal",
|
|
239
|
+
pressureInterp: "linear",
|
|
240
|
+
zoomAware: true
|
|
241
|
+
};
|
|
242
|
+
const RAW_LOOK_PRESETS = [
|
|
243
|
+
{
|
|
244
|
+
id: "legacy",
|
|
245
|
+
label: "Legacy",
|
|
246
|
+
description: "No look is stored: strokes are drawn as in earlier releases.",
|
|
247
|
+
patch: null
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: "widthCurve",
|
|
251
|
+
label: "Width curve",
|
|
252
|
+
description: "Legacy outline with a minimum width and a pressure curve.",
|
|
253
|
+
patch: WIDTH_CURVE
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
id: "round",
|
|
257
|
+
label: "Round",
|
|
258
|
+
description: "Round outline: no spikes at turns, uniform translucent colour.",
|
|
259
|
+
patch: ROUND
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: "freehand",
|
|
263
|
+
label: "Freehand",
|
|
264
|
+
description: "Freehand outline.",
|
|
265
|
+
patch: {
|
|
266
|
+
...WIDTH_CURVE,
|
|
267
|
+
outline: "freehand",
|
|
268
|
+
caps: "merged",
|
|
269
|
+
zoomAware: true
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: "illustrator",
|
|
274
|
+
label: "Illustrator-like",
|
|
275
|
+
description: "Round outline with a Size ± Variation style width range.",
|
|
276
|
+
patch: {
|
|
277
|
+
...ROUND,
|
|
278
|
+
width: {
|
|
279
|
+
min: .4,
|
|
280
|
+
max: 1,
|
|
281
|
+
gamma: 1
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
];
|
|
286
|
+
const RAW_SCENARIOS = [
|
|
287
|
+
{
|
|
288
|
+
id: "A",
|
|
289
|
+
label: "A: legacy",
|
|
290
|
+
description: "Today's behaviour, identical point for point.",
|
|
291
|
+
profiles: {
|
|
292
|
+
mouse: "legacy",
|
|
293
|
+
touch: "legacy",
|
|
294
|
+
"pen.applePencil": "legacy",
|
|
295
|
+
"pen.desktop": "legacy"
|
|
296
|
+
},
|
|
297
|
+
look: "legacy"
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: "B",
|
|
301
|
+
label: "B: fixes",
|
|
302
|
+
description: "Every pen sample, event time and zero pressure kept.",
|
|
303
|
+
profiles: {
|
|
304
|
+
mouse: "fixed",
|
|
305
|
+
touch: "fixed",
|
|
306
|
+
"pen.applePencil": "fixed",
|
|
307
|
+
"pen.desktop": "fixed"
|
|
308
|
+
},
|
|
309
|
+
look: "legacy"
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
id: "C",
|
|
313
|
+
label: "C: smooth",
|
|
314
|
+
description: "B plus smoothing and a pressure curve.",
|
|
315
|
+
profiles: {
|
|
316
|
+
mouse: "stabilized",
|
|
317
|
+
touch: "smooth",
|
|
318
|
+
"pen.applePencil": "smooth",
|
|
319
|
+
"pen.desktop": "smooth"
|
|
320
|
+
},
|
|
321
|
+
look: "widthCurve"
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: "D",
|
|
325
|
+
label: "D: round",
|
|
326
|
+
description: "C plus the round outline.",
|
|
327
|
+
profiles: {
|
|
328
|
+
mouse: "stabilized",
|
|
329
|
+
touch: "smooth",
|
|
330
|
+
"pen.applePencil": "smooth",
|
|
331
|
+
"pen.desktop": "smooth"
|
|
332
|
+
},
|
|
333
|
+
look: "round"
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: "E",
|
|
337
|
+
label: "E: freehand",
|
|
338
|
+
description: "B plus the freehand outline.",
|
|
339
|
+
profiles: {
|
|
340
|
+
mouse: "fixed",
|
|
341
|
+
touch: "fixed",
|
|
342
|
+
"pen.applePencil": "fixed",
|
|
343
|
+
"pen.desktop": "fixed"
|
|
344
|
+
},
|
|
345
|
+
look: "freehand"
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: "F",
|
|
349
|
+
label: "F: Illustrator-like",
|
|
350
|
+
description: "Bézier-fitted finish, streamline stabilizing and a Size ± Variation width.",
|
|
351
|
+
profiles: {
|
|
352
|
+
mouse: "stabilized",
|
|
353
|
+
touch: "smooth",
|
|
354
|
+
"pen.applePencil": "illustrator",
|
|
355
|
+
"pen.desktop": "illustrator"
|
|
356
|
+
},
|
|
357
|
+
look: "illustrator"
|
|
358
|
+
}
|
|
359
|
+
];
|
|
360
|
+
/** Factory profile presets per profile. Read-only. */
|
|
361
|
+
const BRUSH_PROFILE_PRESETS = deepFreeze(RAW_PROFILE_PRESETS);
|
|
362
|
+
deepFreeze(RAW_LOOK_PRESETS);
|
|
363
|
+
/** Factory scenarios. Read-only. */
|
|
364
|
+
const BRUSH_SCENARIOS = deepFreeze(RAW_SCENARIOS);
|
|
365
|
+
/** Full profile configuration of a preset: the legacy profile with its patch. */
|
|
366
|
+
function resolveBrushProfilePatch(patch) {
|
|
367
|
+
return mergeData(BRUSH_LEGACY_INPUT_PROFILE, patch);
|
|
368
|
+
}
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/plugins/brush-lab/constants.ts
|
|
371
|
+
const BRUSH_LAB_LAYER_ID = "brushLabLayer";
|
|
372
|
+
const BRUSH_LAB_METRICS_EVENT = "onBrushLabMetrics";
|
|
373
|
+
/** Strokes kept in memory for ghost comparison. */
|
|
374
|
+
const BRUSH_LAB_DEFAULT_MAX_GHOSTS = 5;
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/plugins/brush-lab/device-report.ts
|
|
377
|
+
const WINDOW_MS = 2e3;
|
|
378
|
+
/**
|
|
379
|
+
* Collects what pointer events say about the device. It keeps counters and a
|
|
380
|
+
* short window of event times, nothing else.
|
|
381
|
+
*/
|
|
382
|
+
var DeviceReporter = class {
|
|
383
|
+
pointerType = null;
|
|
384
|
+
times = [];
|
|
385
|
+
samples = 0;
|
|
386
|
+
events = 0;
|
|
387
|
+
minPressure = Infinity;
|
|
388
|
+
maxPressure = -Infinity;
|
|
389
|
+
tilt = false;
|
|
390
|
+
predicted = false;
|
|
391
|
+
penAsMouse = false;
|
|
392
|
+
resolvedProfile = null;
|
|
393
|
+
zoom = 1;
|
|
394
|
+
fps = 0;
|
|
395
|
+
record(e) {
|
|
396
|
+
this.pointerType = e.pointerType;
|
|
397
|
+
this.events++;
|
|
398
|
+
const coalesced = e.getCoalescedEvents?.().length ?? 0;
|
|
399
|
+
this.samples += Math.max(coalesced, 1);
|
|
400
|
+
if (typeof e.getPredictedEvents === "function") this.predicted = true;
|
|
401
|
+
this.times.push(e.timeStamp);
|
|
402
|
+
const cutoff = e.timeStamp - WINDOW_MS;
|
|
403
|
+
while (this.times.length > 1 && this.times[0] < cutoff) this.times.shift();
|
|
404
|
+
if (e.pointerType === "pen" || e.pointerType === "touch") {
|
|
405
|
+
this.minPressure = Math.min(this.minPressure, e.pressure);
|
|
406
|
+
this.maxPressure = Math.max(this.maxPressure, e.pressure);
|
|
407
|
+
}
|
|
408
|
+
const tilted = Boolean(e.tiltX) || Boolean(e.tiltY);
|
|
409
|
+
if (e.pointerType === "pen" && tilted) this.tilt = true;
|
|
410
|
+
if (e.pointerType === "mouse" && (tilted || e.pressure > 0 && e.pressure !== .5)) this.penAsMouse = true;
|
|
411
|
+
}
|
|
412
|
+
setResolvedProfile(key) {
|
|
413
|
+
this.resolvedProfile = key;
|
|
414
|
+
}
|
|
415
|
+
setZoom(zoom) {
|
|
416
|
+
this.zoom = zoom;
|
|
417
|
+
}
|
|
418
|
+
setFps(fps) {
|
|
419
|
+
this.fps = fps;
|
|
420
|
+
}
|
|
421
|
+
report() {
|
|
422
|
+
const first = this.times[0];
|
|
423
|
+
const span = this.times[this.times.length - 1] - first;
|
|
424
|
+
const warnings = [];
|
|
425
|
+
if (this.penAsMouse) warnings.push("penReportsAsMouse");
|
|
426
|
+
return {
|
|
427
|
+
pointerType: this.pointerType,
|
|
428
|
+
resolvedProfile: this.resolvedProfile,
|
|
429
|
+
sampleRateHz: this.times.length > 1 && span > 0 ? (this.times.length - 1) / span * 1e3 : 0,
|
|
430
|
+
samplesPerEvent: this.events ? this.samples / this.events : 0,
|
|
431
|
+
pressure: Number.isFinite(this.minPressure) ? {
|
|
432
|
+
min: this.minPressure,
|
|
433
|
+
max: this.maxPressure
|
|
434
|
+
} : null,
|
|
435
|
+
tilt: this.tilt,
|
|
436
|
+
predictedAvailable: this.predicted,
|
|
437
|
+
zoom: this.zoom,
|
|
438
|
+
fps: this.fps,
|
|
439
|
+
warnings
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/actions/brush-tool/stages/stabilizer.ts
|
|
445
|
+
/** Time assumed between two samples whose times do not advance, in ms. */
|
|
446
|
+
const NOMINAL_SAMPLE_INTERVAL_MS = 5;
|
|
447
|
+
const seconds = (now, last) => (now - last > 0 ? now - last : NOMINAL_SAMPLE_INTERVAL_MS) / 1e3;
|
|
448
|
+
/**
|
|
449
|
+
* Scale between canvas units and the units distances are configured in: screen
|
|
450
|
+
* pixels when the stabilizer is zoom-aware, canvas units otherwise.
|
|
451
|
+
*/
|
|
452
|
+
const unitScale = (cfg, ctx) => cfg.zoomAware && ctx.zoom > 0 && Number.isFinite(ctx.zoom) ? ctx.zoom : 1;
|
|
453
|
+
var NoStabilizer = class {
|
|
454
|
+
reset() {}
|
|
455
|
+
seed() {}
|
|
456
|
+
apply(point) {
|
|
457
|
+
return point;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
/** One Euro low-pass filter on one axis. */
|
|
461
|
+
var OneEuroAxis = class {
|
|
462
|
+
last = 0;
|
|
463
|
+
derivative = 0;
|
|
464
|
+
lastTime = 0;
|
|
465
|
+
seeded = false;
|
|
466
|
+
reset() {
|
|
467
|
+
this.seeded = false;
|
|
468
|
+
this.derivative = 0;
|
|
469
|
+
}
|
|
470
|
+
seed(value, time) {
|
|
471
|
+
this.last = value;
|
|
472
|
+
this.lastTime = time;
|
|
473
|
+
this.derivative = 0;
|
|
474
|
+
this.seeded = true;
|
|
475
|
+
}
|
|
476
|
+
filter(value, time, minCutoff, beta) {
|
|
477
|
+
if (!this.seeded) {
|
|
478
|
+
this.seed(value, time);
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
481
|
+
const dt = seconds(time, this.lastTime);
|
|
482
|
+
const factor = (cutoff) => {
|
|
483
|
+
const r = 2 * Math.PI * cutoff * dt;
|
|
484
|
+
return r / (r + 1);
|
|
485
|
+
};
|
|
486
|
+
const a = factor(1);
|
|
487
|
+
this.derivative = a * ((value - this.last) / dt) + (1 - a) * this.derivative;
|
|
488
|
+
const alpha = factor(minCutoff + beta * Math.abs(this.derivative));
|
|
489
|
+
const filtered = alpha * value + (1 - alpha) * this.last;
|
|
490
|
+
this.last = Number.isFinite(filtered) ? filtered : value;
|
|
491
|
+
this.lastTime = time;
|
|
492
|
+
return this.last;
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
/**
|
|
496
|
+
* One Euro filter (Casiez, Roussel, Vogel 2012) on the pen position: slow
|
|
497
|
+
* movement is smoothed, fast movement is followed closely.
|
|
498
|
+
*/
|
|
499
|
+
var OneEuroStabilizer = class {
|
|
500
|
+
cfg;
|
|
501
|
+
x = new OneEuroAxis();
|
|
502
|
+
y = new OneEuroAxis();
|
|
503
|
+
constructor(cfg) {
|
|
504
|
+
this.cfg = cfg;
|
|
505
|
+
}
|
|
506
|
+
reset() {
|
|
507
|
+
this.x.reset();
|
|
508
|
+
this.y.reset();
|
|
509
|
+
}
|
|
510
|
+
seed(point, ctx) {
|
|
511
|
+
const s = unitScale(this.cfg, ctx);
|
|
512
|
+
this.x.seed(point.x * s, ctx.now);
|
|
513
|
+
this.y.seed(point.y * s, ctx.now);
|
|
514
|
+
}
|
|
515
|
+
apply(point, ctx) {
|
|
516
|
+
const s = unitScale(this.cfg, ctx);
|
|
517
|
+
return {
|
|
518
|
+
x: this.x.filter(point.x * s, ctx.now, this.cfg.minCutoff, this.cfg.beta) / s,
|
|
519
|
+
y: this.y.filter(point.y * s, ctx.now, this.cfg.minCutoff, this.cfg.beta) / s
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
/**
|
|
524
|
+
* Pulled string: the recorded point is held back on a leash of fixed length
|
|
525
|
+
* and only moves when the pen pulls it taut, so movement inside the leash
|
|
526
|
+
* leaves no mark and tight curves are rounded.
|
|
527
|
+
*/
|
|
528
|
+
var PulledStringStabilizer = class {
|
|
529
|
+
cfg;
|
|
530
|
+
tip = null;
|
|
531
|
+
constructor(cfg) {
|
|
532
|
+
this.cfg = cfg;
|
|
533
|
+
}
|
|
534
|
+
reset() {
|
|
535
|
+
this.tip = null;
|
|
536
|
+
}
|
|
537
|
+
seed(point) {
|
|
538
|
+
this.tip = { ...point };
|
|
539
|
+
}
|
|
540
|
+
apply(point, ctx) {
|
|
541
|
+
if (!this.tip) {
|
|
542
|
+
this.tip = { ...point };
|
|
543
|
+
return point;
|
|
544
|
+
}
|
|
545
|
+
const leash = this.cfg.radiusPx / unitScale(this.cfg, ctx);
|
|
546
|
+
const dx = point.x - this.tip.x;
|
|
547
|
+
const dy = point.y - this.tip.y;
|
|
548
|
+
const distance = Math.hypot(dx, dy);
|
|
549
|
+
if (distance > leash && distance > 0) {
|
|
550
|
+
const pull = (distance - leash) / distance;
|
|
551
|
+
this.tip = {
|
|
552
|
+
x: this.tip.x + dx * pull,
|
|
553
|
+
y: this.tip.y + dy * pull
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
return { ...this.tip };
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
/** Longest time constant of the streamline stabilizer, at amount 1, in ms. */
|
|
560
|
+
const STREAMLINE_MAX_TAU_MS = 80;
|
|
561
|
+
/**
|
|
562
|
+
* Streamline: the recorded point eases towards the pen with a time constant
|
|
563
|
+
* proportional to `amount`, so the result does not depend on how often samples
|
|
564
|
+
* arrive. Amount 0 follows the pen exactly.
|
|
565
|
+
*/
|
|
566
|
+
var StreamlineStabilizer = class {
|
|
567
|
+
cfg;
|
|
568
|
+
tip = null;
|
|
569
|
+
lastTime = 0;
|
|
570
|
+
constructor(cfg) {
|
|
571
|
+
this.cfg = cfg;
|
|
572
|
+
}
|
|
573
|
+
reset() {
|
|
574
|
+
this.tip = null;
|
|
575
|
+
}
|
|
576
|
+
seed(point, ctx) {
|
|
577
|
+
this.tip = { ...point };
|
|
578
|
+
this.lastTime = ctx.now;
|
|
579
|
+
}
|
|
580
|
+
apply(point, ctx) {
|
|
581
|
+
const tau = this.cfg.amount * STREAMLINE_MAX_TAU_MS;
|
|
582
|
+
if (!this.tip || tau <= 0) {
|
|
583
|
+
this.tip = { ...point };
|
|
584
|
+
this.lastTime = ctx.now;
|
|
585
|
+
return point;
|
|
586
|
+
}
|
|
587
|
+
const dt = seconds(ctx.now, this.lastTime) * 1e3;
|
|
588
|
+
const alpha = 1 - Math.exp(-dt / tau);
|
|
589
|
+
this.tip = {
|
|
590
|
+
x: this.tip.x + (point.x - this.tip.x) * alpha,
|
|
591
|
+
y: this.tip.y + (point.y - this.tip.y) * alpha
|
|
592
|
+
};
|
|
593
|
+
this.lastTime = ctx.now;
|
|
594
|
+
return { ...this.tip };
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
/** Creates the stabilizer selected by a profile. */
|
|
598
|
+
function createStabilizer(cfg) {
|
|
599
|
+
switch (cfg.type) {
|
|
600
|
+
case "oneEuro": return new OneEuroStabilizer(cfg);
|
|
601
|
+
case "pulledString": return new PulledStringStabilizer(cfg);
|
|
602
|
+
case "streamline": return new StreamlineStabilizer(cfg);
|
|
603
|
+
default: return new NoStabilizer();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/actions/brush-tool/stages/bezier-fit.ts
|
|
608
|
+
const sub = (a, b) => ({
|
|
609
|
+
x: a.x - b.x,
|
|
610
|
+
y: a.y - b.y
|
|
611
|
+
});
|
|
612
|
+
const add = (a, b) => ({
|
|
613
|
+
x: a.x + b.x,
|
|
614
|
+
y: a.y + b.y
|
|
615
|
+
});
|
|
616
|
+
const scale = (a, s) => ({
|
|
617
|
+
x: a.x * s,
|
|
618
|
+
y: a.y * s
|
|
619
|
+
});
|
|
620
|
+
const dot = (a, b) => a.x * b.x + a.y * b.y;
|
|
621
|
+
const length = (a) => Math.hypot(a.x, a.y);
|
|
622
|
+
const normalize = (a) => {
|
|
623
|
+
const l = length(a);
|
|
624
|
+
return l > 0 ? {
|
|
625
|
+
x: a.x / l,
|
|
626
|
+
y: a.y / l
|
|
627
|
+
} : {
|
|
628
|
+
x: 0,
|
|
629
|
+
y: 0
|
|
630
|
+
};
|
|
631
|
+
};
|
|
632
|
+
const B0 = (u) => (1 - u) ** 3;
|
|
633
|
+
const B1 = (u) => 3 * u * (1 - u) ** 2;
|
|
634
|
+
const B2 = (u) => 3 * u * u * (1 - u);
|
|
635
|
+
const B3 = (u) => u ** 3;
|
|
636
|
+
/** Point on a cubic Bézier curve. */
|
|
637
|
+
function bezierPoint(b, t) {
|
|
638
|
+
return {
|
|
639
|
+
x: B0(t) * b[0].x + B1(t) * b[1].x + B2(t) * b[2].x + B3(t) * b[3].x,
|
|
640
|
+
y: B0(t) * b[0].y + B1(t) * b[1].y + B2(t) * b[2].y + B3(t) * b[3].y
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function derivative(b, t) {
|
|
644
|
+
const q = [
|
|
645
|
+
sub(b[1], b[0]),
|
|
646
|
+
sub(b[2], b[1]),
|
|
647
|
+
sub(b[3], b[2])
|
|
648
|
+
].map((v) => scale(v, 3));
|
|
649
|
+
const s = 1 - t;
|
|
650
|
+
return {
|
|
651
|
+
x: s * s * q[0].x + 2 * s * t * q[1].x + t * t * q[2].x,
|
|
652
|
+
y: s * s * q[0].y + 2 * s * t * q[1].y + t * t * q[2].y
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function secondDerivative(b, t) {
|
|
656
|
+
const q = [add(sub(b[2], scale(b[1], 2)), b[0]), add(sub(b[3], scale(b[2], 2)), b[1])].map((v) => scale(v, 6));
|
|
657
|
+
return {
|
|
658
|
+
x: (1 - t) * q[0].x + t * q[1].x,
|
|
659
|
+
y: (1 - t) * q[0].y + t * q[1].y
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Direction of the curve at `index`, estimated over a window at least
|
|
664
|
+
* `window` long on each side (but inside `first..last`), so that noise in the
|
|
665
|
+
* nearest samples does not tilt it. With `before` false only the following
|
|
666
|
+
* points are used (a start tangent); with `after` false only the preceding
|
|
667
|
+
* ones (an end tangent). The vector points forward along the stroke.
|
|
668
|
+
*/
|
|
669
|
+
function windowTangent(pts, index, first, last, window, before, after) {
|
|
670
|
+
let back = index;
|
|
671
|
+
if (before) while (back > first && length(sub(pts[index], pts[back])) < window) back--;
|
|
672
|
+
let ahead = index;
|
|
673
|
+
if (after) while (ahead < last && length(sub(pts[ahead], pts[index])) < window) ahead++;
|
|
674
|
+
return normalize(sub(pts[ahead], pts[back]));
|
|
675
|
+
}
|
|
676
|
+
function chordLengthParameterize(pts, first, last) {
|
|
677
|
+
const u = [0];
|
|
678
|
+
for (let i = first + 1; i <= last; i++) u.push(u[u.length - 1] + length(sub(pts[i], pts[i - 1])));
|
|
679
|
+
const total = u[u.length - 1] || 1;
|
|
680
|
+
return u.map((v) => v / total);
|
|
681
|
+
}
|
|
682
|
+
function generateBezier(pts, first, last, u, tHat1, tHat2) {
|
|
683
|
+
const p0 = pts[first];
|
|
684
|
+
const p3 = pts[last];
|
|
685
|
+
let c00 = 0, c01 = 0, c11 = 0, x0 = 0, x1 = 0;
|
|
686
|
+
for (let i = 0; i < u.length; i++) {
|
|
687
|
+
const a0 = scale(tHat1, B1(u[i]));
|
|
688
|
+
const a1 = scale(tHat2, B2(u[i]));
|
|
689
|
+
c00 += dot(a0, a0);
|
|
690
|
+
c01 += dot(a0, a1);
|
|
691
|
+
c11 += dot(a1, a1);
|
|
692
|
+
const tmp = sub(pts[first + i], add(scale(p0, B0(u[i]) + B1(u[i])), scale(p3, B2(u[i]) + B3(u[i]))));
|
|
693
|
+
x0 += dot(a0, tmp);
|
|
694
|
+
x1 += dot(a1, tmp);
|
|
695
|
+
}
|
|
696
|
+
const det = c00 * c11 - c01 * c01;
|
|
697
|
+
const alphaL = det === 0 ? 0 : (x0 * c11 - x1 * c01) / det;
|
|
698
|
+
const alphaR = det === 0 ? 0 : (c00 * x1 - c01 * x0) / det;
|
|
699
|
+
const segLength = length(sub(p3, p0));
|
|
700
|
+
const epsilon = 1e-6 * segLength;
|
|
701
|
+
if (alphaL < epsilon || alphaR < epsilon) {
|
|
702
|
+
const d = segLength / 3;
|
|
703
|
+
return [
|
|
704
|
+
p0,
|
|
705
|
+
add(p0, scale(tHat1, d)),
|
|
706
|
+
add(p3, scale(tHat2, d)),
|
|
707
|
+
p3
|
|
708
|
+
];
|
|
709
|
+
}
|
|
710
|
+
return [
|
|
711
|
+
p0,
|
|
712
|
+
add(p0, scale(tHat1, alphaL)),
|
|
713
|
+
add(p3, scale(tHat2, alphaR)),
|
|
714
|
+
p3
|
|
715
|
+
];
|
|
716
|
+
}
|
|
717
|
+
function newtonRaphson(b, p, u) {
|
|
718
|
+
const d = sub(bezierPoint(b, u), p);
|
|
719
|
+
const d1 = derivative(b, u);
|
|
720
|
+
const d2 = secondDerivative(b, u);
|
|
721
|
+
const numerator = dot(d, d1);
|
|
722
|
+
const denominator = dot(d1, d1) + dot(d, d2);
|
|
723
|
+
return denominator === 0 ? u : u - numerator / denominator;
|
|
724
|
+
}
|
|
725
|
+
function maxError(pts, first, last, b, u) {
|
|
726
|
+
let split = Math.floor((last - first + 1) / 2) + first;
|
|
727
|
+
let max = 0;
|
|
728
|
+
for (let i = first + 1; i < last; i++) {
|
|
729
|
+
const d = sub(bezierPoint(b, u[i - first]), pts[i]);
|
|
730
|
+
const e = dot(d, d);
|
|
731
|
+
if (e >= max) {
|
|
732
|
+
max = e;
|
|
733
|
+
split = i;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
error: max,
|
|
738
|
+
split
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
function fitCubic(pts, first, last, tHat1, tHat2, errorSq, window, out) {
|
|
742
|
+
if (last - first === 1) {
|
|
743
|
+
const d = length(sub(pts[last], pts[first])) / 3;
|
|
744
|
+
out.push({
|
|
745
|
+
bez: [
|
|
746
|
+
pts[first],
|
|
747
|
+
add(pts[first], scale(tHat1, d)),
|
|
748
|
+
add(pts[last], scale(tHat2, d)),
|
|
749
|
+
pts[last]
|
|
750
|
+
],
|
|
751
|
+
first,
|
|
752
|
+
last,
|
|
753
|
+
u: [0, 1]
|
|
754
|
+
});
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
let u = chordLengthParameterize(pts, first, last);
|
|
758
|
+
let bez = generateBezier(pts, first, last, u, tHat1, tHat2);
|
|
759
|
+
let { error, split } = maxError(pts, first, last, bez, u);
|
|
760
|
+
if (error < errorSq) {
|
|
761
|
+
out.push({
|
|
762
|
+
bez,
|
|
763
|
+
first,
|
|
764
|
+
last,
|
|
765
|
+
u
|
|
766
|
+
});
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (error < errorSq * 4) for (let i = 0; i < 4; i++) {
|
|
770
|
+
const prime = u.map((v, k) => {
|
|
771
|
+
const p = newtonRaphson(bez, pts[first + k], v);
|
|
772
|
+
return Number.isFinite(p) ? Math.min(Math.max(p, 0), 1) : v;
|
|
773
|
+
});
|
|
774
|
+
bez = generateBezier(pts, first, last, prime, tHat1, tHat2);
|
|
775
|
+
({error, split} = maxError(pts, first, last, bez, prime));
|
|
776
|
+
u = prime;
|
|
777
|
+
if (error < errorSq) {
|
|
778
|
+
out.push({
|
|
779
|
+
bez,
|
|
780
|
+
first,
|
|
781
|
+
last,
|
|
782
|
+
u
|
|
783
|
+
});
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const center = windowTangent(pts, split, first, last, window, true, true);
|
|
788
|
+
const back = scale(center, -1);
|
|
789
|
+
fitCubic(pts, first, split, tHat1, back, errorSq, window, out);
|
|
790
|
+
fitCubic(pts, split, last, center, tHat2, errorSq, window, out);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Fits cubic Bézier segments to a polyline so that no input point is farther
|
|
794
|
+
* than `tolerance` from the curve. Needs at least two points, none repeated
|
|
795
|
+
* consecutively.
|
|
796
|
+
*/
|
|
797
|
+
function fitCurve(points, tolerance) {
|
|
798
|
+
if (points.length < 2) return [];
|
|
799
|
+
const out = [];
|
|
800
|
+
const last = points.length - 1;
|
|
801
|
+
const window = tolerance * 3;
|
|
802
|
+
fitCubic(points, 0, last, windowTangent(points, 0, 0, last, window, false, true), scale(windowTangent(points, last, 0, last, window, true, false), -1), tolerance * tolerance, window, out);
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
805
|
+
//#endregion
|
|
806
|
+
//#region src/actions/brush-tool/stages/finalize.ts
|
|
807
|
+
/** Size of one screen pixel in canvas units at a zoom level. */
|
|
808
|
+
const pixelSize = (zoom) => zoom > 0 && Number.isFinite(zoom) ? 1 / zoom : 1;
|
|
809
|
+
/**
|
|
810
|
+
* Tolerance applied when a stroke is finished, in canvas units. The legacy
|
|
811
|
+
* value is the tool-level tolerance in canvas units; with the `screen` unit the
|
|
812
|
+
* configured pixels are converted with the zoom in force when the stroke ends,
|
|
813
|
+
* so the same setting gives the same on-screen fidelity at any zoom.
|
|
814
|
+
*/
|
|
815
|
+
function resolveFinalizeTolerance(cfg, toolTolerance, zoom = 1) {
|
|
816
|
+
const tolerance = cfg.tolerance ?? toolTolerance;
|
|
817
|
+
return cfg.toleranceUnit === "screen" ? tolerance * pixelSize(zoom) : tolerance;
|
|
818
|
+
}
|
|
819
|
+
/** Distance from `p` to the segment, and where along it the closest point is. */
|
|
820
|
+
function toSegment(p, { a, b }) {
|
|
821
|
+
const dx = b.x - a.x;
|
|
822
|
+
const dy = b.y - a.y;
|
|
823
|
+
const lengthSq = dx * dx + dy * dy;
|
|
824
|
+
const t = lengthSq ? Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq)) : 0;
|
|
825
|
+
return {
|
|
826
|
+
distance: Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t)),
|
|
827
|
+
t
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Douglas-Peucker simplification that also keeps pressure detail: a point is
|
|
832
|
+
* kept when it is off the straight path by more than `tolerance`, or when its
|
|
833
|
+
* pressure differs from the pressure interpolated along that path by more than
|
|
834
|
+
* `tolerance / pressureWeight`.
|
|
835
|
+
*/
|
|
836
|
+
function simplifyWithPressure(points, tolerance, pressureWeight) {
|
|
837
|
+
if (points.length <= 2) return points;
|
|
838
|
+
const keep = new Uint8Array(points.length);
|
|
839
|
+
keep[0] = keep[points.length - 1] = 1;
|
|
840
|
+
const stack = [[0, points.length - 1]];
|
|
841
|
+
while (stack.length > 0) {
|
|
842
|
+
const [first, last] = stack.pop();
|
|
843
|
+
let worst = 0;
|
|
844
|
+
let index = -1;
|
|
845
|
+
for (let i = first + 1; i < last; i++) {
|
|
846
|
+
const { distance, t } = toSegment(points[i], {
|
|
847
|
+
a: points[first],
|
|
848
|
+
b: points[last]
|
|
849
|
+
});
|
|
850
|
+
const expected = points[first].pressure + (points[last].pressure - points[first].pressure) * t;
|
|
851
|
+
const error = Math.max(distance, Math.abs(points[i].pressure - expected) * pressureWeight);
|
|
852
|
+
if (error > worst) {
|
|
853
|
+
worst = error;
|
|
854
|
+
index = i;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (worst > tolerance && index !== -1) {
|
|
858
|
+
keep[index] = 1;
|
|
859
|
+
stack.push([first, index], [index, last]);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return points.filter((_, i) => keep[i] === 1);
|
|
863
|
+
}
|
|
864
|
+
/** Pressure at curve parameter `t` of a segment, interpolated between input points. */
|
|
865
|
+
function pressureAt(seg, points, t) {
|
|
866
|
+
let prev = seg.u[0];
|
|
867
|
+
for (let k = 1; k < seg.u.length; k++) {
|
|
868
|
+
const next = Math.max(seg.u[k], prev);
|
|
869
|
+
if (t <= next || k === seg.u.length - 1) {
|
|
870
|
+
const span = next - prev;
|
|
871
|
+
const f = span > 0 ? Math.min(1, Math.max(0, (t - prev) / span)) : 0;
|
|
872
|
+
const p0 = points[seg.first + k - 1].pressure;
|
|
873
|
+
return p0 + (points[seg.first + k].pressure - p0) * f;
|
|
874
|
+
}
|
|
875
|
+
prev = next;
|
|
876
|
+
}
|
|
877
|
+
return points[seg.last].pressure;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Fits a smooth curve through the stroke and samples it back to points, so the
|
|
881
|
+
* saved format stays `{x, y, pressure}`. `tolerance` is the largest distance
|
|
882
|
+
* between the curve and an input point; `spacing` is the distance between
|
|
883
|
+
* sampled points. Both are in canvas units. The first and last points are kept
|
|
884
|
+
* exactly.
|
|
885
|
+
*/
|
|
886
|
+
function fitAndResample(points, tolerance, spacing) {
|
|
887
|
+
if (points.length < 3 || !(tolerance > 0)) return points;
|
|
888
|
+
const distinct = points.filter((p, i) => i === 0 || p.x !== points[i - 1].x || p.y !== points[i - 1].y);
|
|
889
|
+
if (distinct.length < 3) return points;
|
|
890
|
+
const segments = fitCurve(distinct, tolerance);
|
|
891
|
+
if (segments.length === 0) return points;
|
|
892
|
+
const out = [];
|
|
893
|
+
for (const seg of segments) {
|
|
894
|
+
let arc = 0;
|
|
895
|
+
let prev = seg.bez[0];
|
|
896
|
+
for (let k = 1; k <= 16; k++) {
|
|
897
|
+
const q = bezierPoint(seg.bez, k / 16);
|
|
898
|
+
arc += Math.hypot(q.x - prev.x, q.y - prev.y);
|
|
899
|
+
prev = q;
|
|
900
|
+
}
|
|
901
|
+
const steps = Math.max(1, Math.ceil(arc / Math.max(spacing, 1e-6)));
|
|
902
|
+
for (let k = 0; k < steps; k++) {
|
|
903
|
+
const t = k / steps;
|
|
904
|
+
const q = k === 0 ? seg.bez[0] : bezierPoint(seg.bez, t);
|
|
905
|
+
out.push({
|
|
906
|
+
x: q.x,
|
|
907
|
+
y: q.y,
|
|
908
|
+
pressure: pressureAt(seg, distinct, t)
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const end = distinct[distinct.length - 1];
|
|
913
|
+
out.push({
|
|
914
|
+
x: end.x,
|
|
915
|
+
y: end.y,
|
|
916
|
+
pressure: end.pressure
|
|
917
|
+
});
|
|
918
|
+
return out;
|
|
919
|
+
}
|
|
920
|
+
/** Reduces a finished stroke's points as selected by the profile. */
|
|
921
|
+
function finalizePoints(points, cfg, toolTolerance, zoom = 1) {
|
|
922
|
+
const tolerance = resolveFinalizeTolerance(cfg, toolTolerance, zoom);
|
|
923
|
+
switch (cfg.mode) {
|
|
924
|
+
case "none": return points;
|
|
925
|
+
case "dpPressure": return simplifyWithPressure(points, tolerance, cfg.toleranceUnit === "screen" ? cfg.pressureWeight * pixelSize(zoom) : cfg.pressureWeight);
|
|
926
|
+
case "bezierFit": return fitAndResample(points, tolerance, cfg.resampleSpacingPx * pixelSize(zoom));
|
|
927
|
+
default: return simplify(points, tolerance, true);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
//#endregion
|
|
931
|
+
//#region src/plugins/brush-lab/ghosts.ts
|
|
932
|
+
/**
|
|
933
|
+
* Draws a recorded gesture again through another brush: the same pointer
|
|
934
|
+
* events, captured, stabilized and finished as that brush would have done.
|
|
935
|
+
* Positions only: width, pressure smoothing and the outline are not replayed.
|
|
936
|
+
*/
|
|
937
|
+
function replayRecording(recording, profile, simplifyTolerance) {
|
|
938
|
+
const coalesced = profile.capture.source === "coalesced";
|
|
939
|
+
const minMove = profile.capture.minMovePx;
|
|
940
|
+
const captured = [];
|
|
941
|
+
for (const event of recording.gesture.events) {
|
|
942
|
+
const picked = coalesced ? event.samples : event.samples.slice(-1);
|
|
943
|
+
for (const point of picked) captured.push({
|
|
944
|
+
time: point.time ?? event.time,
|
|
945
|
+
point
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
if (captured.length === 0) return [];
|
|
949
|
+
const kept = captured.filter((c, i) => {
|
|
950
|
+
if (i === 0 || minMove <= 0) return true;
|
|
951
|
+
const prev = captured[i - 1].point;
|
|
952
|
+
return Math.hypot(c.point.x - prev.x, c.point.y - prev.y) * recording.zoom >= minMove;
|
|
953
|
+
});
|
|
954
|
+
const stabilizer = createStabilizer(profile.stabilizer);
|
|
955
|
+
stabilizer.reset();
|
|
956
|
+
const ctx = (time) => ({
|
|
957
|
+
now: time,
|
|
958
|
+
zoom: recording.zoom
|
|
959
|
+
});
|
|
960
|
+
stabilizer.seed(kept[0].point, ctx(kept[0].time));
|
|
961
|
+
const points = kept.map((c) => {
|
|
962
|
+
const p = stabilizer.apply(c.point, ctx(c.time));
|
|
963
|
+
return {
|
|
964
|
+
x: p.x,
|
|
965
|
+
y: p.y,
|
|
966
|
+
pressure: c.point.pressure
|
|
967
|
+
};
|
|
968
|
+
});
|
|
969
|
+
const cfg = profile.stabilizer;
|
|
970
|
+
const lastRaw = kept[kept.length - 1].point;
|
|
971
|
+
const lastOut = points[points.length - 1];
|
|
972
|
+
if (cfg.type !== "none" && cfg.catchUpOnEnd && (lastRaw.x !== lastOut.x || lastRaw.y !== lastOut.y)) points.push({
|
|
973
|
+
x: lastRaw.x,
|
|
974
|
+
y: lastRaw.y,
|
|
975
|
+
pressure: lastOut.pressure
|
|
976
|
+
});
|
|
977
|
+
return finalizePoints(points, profile.finalize, simplifyTolerance, recording.zoom);
|
|
978
|
+
}
|
|
979
|
+
/** The last `max` recordings; the oldest are dropped. Memory only. */
|
|
980
|
+
var RecordingStore = class {
|
|
981
|
+
max;
|
|
982
|
+
items = [];
|
|
983
|
+
constructor(max) {
|
|
984
|
+
this.max = max;
|
|
985
|
+
}
|
|
986
|
+
setMax(max) {
|
|
987
|
+
this.max = Math.max(1, Math.floor(max));
|
|
988
|
+
this.trim();
|
|
989
|
+
}
|
|
990
|
+
add(recording) {
|
|
991
|
+
this.items.push(recording);
|
|
992
|
+
this.trim();
|
|
993
|
+
}
|
|
994
|
+
all() {
|
|
995
|
+
return [...this.items];
|
|
996
|
+
}
|
|
997
|
+
last() {
|
|
998
|
+
return this.items[this.items.length - 1];
|
|
999
|
+
}
|
|
1000
|
+
clear() {
|
|
1001
|
+
this.items = [];
|
|
1002
|
+
}
|
|
1003
|
+
get size() {
|
|
1004
|
+
return this.items.length;
|
|
1005
|
+
}
|
|
1006
|
+
trim() {
|
|
1007
|
+
while (this.items.length > this.max) this.items.shift();
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/plugins/brush-lab/recorder.ts
|
|
1012
|
+
/**
|
|
1013
|
+
* Records the pointer events of one gesture, whatever brush is active: every
|
|
1014
|
+
* coalesced sample of every event, with its time. The first pointer that goes
|
|
1015
|
+
* down owns the gesture; others are ignored.
|
|
1016
|
+
*/
|
|
1017
|
+
var GestureRecorder = class {
|
|
1018
|
+
pointerId = null;
|
|
1019
|
+
events = [];
|
|
1020
|
+
begin(e) {
|
|
1021
|
+
if (this.pointerId !== null) return;
|
|
1022
|
+
this.pointerId = e.pointerId;
|
|
1023
|
+
this.events = [{
|
|
1024
|
+
time: e.time,
|
|
1025
|
+
samples: e.samples
|
|
1026
|
+
}];
|
|
1027
|
+
}
|
|
1028
|
+
add(e) {
|
|
1029
|
+
if (e.pointerId !== this.pointerId) return;
|
|
1030
|
+
this.events.push({
|
|
1031
|
+
time: e.time,
|
|
1032
|
+
samples: e.samples
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
end(pointerId) {
|
|
1036
|
+
if (pointerId === this.pointerId) this.pointerId = null;
|
|
1037
|
+
}
|
|
1038
|
+
/** A copy of the gesture so far. */
|
|
1039
|
+
snapshot() {
|
|
1040
|
+
return { events: this.events.map((ev) => ({
|
|
1041
|
+
time: ev.time,
|
|
1042
|
+
samples: ev.samples.map((s) => ({ ...s }))
|
|
1043
|
+
})) };
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
//#endregion
|
|
1047
|
+
//#region src/plugins/brush-lab/metrics.ts
|
|
1048
|
+
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
1049
|
+
function distanceToSegment(p, a, b) {
|
|
1050
|
+
const dx = b.x - a.x;
|
|
1051
|
+
const dy = b.y - a.y;
|
|
1052
|
+
const l = dx * dx + dy * dy;
|
|
1053
|
+
const t = l ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l)) : 0;
|
|
1054
|
+
return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
|
|
1055
|
+
}
|
|
1056
|
+
const mean = (values) => values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
|
1057
|
+
/** Measures a finished stroke from what the brush tool reported. */
|
|
1058
|
+
function computeStrokeMetrics(input) {
|
|
1059
|
+
const real = input.samples.filter((s) => !s.predicted);
|
|
1060
|
+
let droppedDuplicates = 0;
|
|
1061
|
+
for (let i = 1; i < real.length; i++) if (dist(real[i].raw, real[i - 1].raw) === 0) droppedDuplicates++;
|
|
1062
|
+
const spanMs = real.length > 1 ? real[real.length - 1].time - real[0].time : 0;
|
|
1063
|
+
const effectiveRateHz = spanMs > 0 ? (real.length - 1) / spanMs * 1e3 : 0;
|
|
1064
|
+
const jitters = [];
|
|
1065
|
+
for (let i = 1; i < real.length - 1; i++) jitters.push(distanceToSegment(real[i].raw, real[i - 1].raw, real[i + 1].raw));
|
|
1066
|
+
const lag = mean(real.map((s) => dist(s.raw, s.stabilized)));
|
|
1067
|
+
let movedOnFinish = 0;
|
|
1068
|
+
if (input.finalPoints.length > 0) for (const p of input.beforeFinalize) {
|
|
1069
|
+
let best = Infinity;
|
|
1070
|
+
if (input.finalPoints.length === 1) best = dist(p, input.finalPoints[0]);
|
|
1071
|
+
else for (let i = 1; i < input.finalPoints.length; i++) best = Math.min(best, distanceToSegment(p, input.finalPoints[i - 1], input.finalPoints[i]));
|
|
1072
|
+
movedOnFinish = Math.max(movedOnFinish, best);
|
|
1073
|
+
}
|
|
1074
|
+
const steps = [];
|
|
1075
|
+
for (let i = 1; i < input.finalPoints.length; i++) steps.push(Math.abs(input.finalPoints[i].pressure - input.finalPoints[i - 1].pressure) * input.strokeWidth);
|
|
1076
|
+
return {
|
|
1077
|
+
samples: real.length,
|
|
1078
|
+
points: input.finalPoints.length,
|
|
1079
|
+
droppedDuplicates,
|
|
1080
|
+
effectiveRateHz,
|
|
1081
|
+
jitter: mean(jitters),
|
|
1082
|
+
lag,
|
|
1083
|
+
movedOnFinish,
|
|
1084
|
+
widthRoughness: mean(steps),
|
|
1085
|
+
drawMs: input.drawMs
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
//#endregion
|
|
1089
|
+
//#region src/plugins/brush-lab/brush-lab.ts
|
|
1090
|
+
/**
|
|
1091
|
+
* Lab for tuning the brush on real devices. It listens to the brush tool and
|
|
1092
|
+
* draws overlays on a layer of its own. It never changes the document, and it
|
|
1093
|
+
* keeps everything it records in memory only: nothing is stored, sent or
|
|
1094
|
+
* written anywhere.
|
|
1095
|
+
*/
|
|
1096
|
+
var WeaveBrushLabPlugin = class extends WeavePlugin {
|
|
1097
|
+
overlays = {
|
|
1098
|
+
raw: false,
|
|
1099
|
+
stabilized: false,
|
|
1100
|
+
final: false,
|
|
1101
|
+
pressure: false
|
|
1102
|
+
};
|
|
1103
|
+
reporter = new DeviceReporter();
|
|
1104
|
+
recordings = new RecordingStore(5);
|
|
1105
|
+
current = null;
|
|
1106
|
+
last = null;
|
|
1107
|
+
lastMetrics = null;
|
|
1108
|
+
compareScenario = null;
|
|
1109
|
+
recorder = new GestureRecorder();
|
|
1110
|
+
listeners = [];
|
|
1111
|
+
frameHandle = null;
|
|
1112
|
+
constructor(params) {
|
|
1113
|
+
super();
|
|
1114
|
+
if (params?.maxGhosts) this.recordings.setMax(params.maxGhosts);
|
|
1115
|
+
this.initialize();
|
|
1116
|
+
}
|
|
1117
|
+
initialize() {
|
|
1118
|
+
this.enabled = true;
|
|
1119
|
+
}
|
|
1120
|
+
getName() {
|
|
1121
|
+
return BRUSH_LAB_PLUGIN_NAME;
|
|
1122
|
+
}
|
|
1123
|
+
getLayerName() {
|
|
1124
|
+
return BRUSH_LAB_LAYER_ID;
|
|
1125
|
+
}
|
|
1126
|
+
getLayer() {
|
|
1127
|
+
return this.instance?.getStage?.()?.findOne?.(`#${this.getLayerName()}`);
|
|
1128
|
+
}
|
|
1129
|
+
onInit() {
|
|
1130
|
+
this.instance.getStage().add(new Konva.Layer({
|
|
1131
|
+
id: this.getLayerName(),
|
|
1132
|
+
draggable: false,
|
|
1133
|
+
listening: false
|
|
1134
|
+
}));
|
|
1135
|
+
if (this.enabled) this.attach();
|
|
1136
|
+
}
|
|
1137
|
+
onRender() {}
|
|
1138
|
+
enable() {
|
|
1139
|
+
this.enabled = true;
|
|
1140
|
+
this.attach();
|
|
1141
|
+
this.getLayer()?.show();
|
|
1142
|
+
}
|
|
1143
|
+
disable() {
|
|
1144
|
+
this.enabled = false;
|
|
1145
|
+
this.detach();
|
|
1146
|
+
this.getLayer()?.hide();
|
|
1147
|
+
}
|
|
1148
|
+
/** Pointer event position in the stage's drawing coordinates. */
|
|
1149
|
+
toStagePoint(e) {
|
|
1150
|
+
const stage = this.instance.getStage();
|
|
1151
|
+
const rect = stage.container().getBoundingClientRect();
|
|
1152
|
+
return stage.getAbsoluteTransform().copy().invert().point({
|
|
1153
|
+
x: e.clientX - rect.left,
|
|
1154
|
+
y: e.clientY - rect.top
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
recorderEvent(e) {
|
|
1158
|
+
const sources = typeof e.getCoalescedEvents === "function" && e.getCoalescedEvents().length > 0 ? e.getCoalescedEvents() : [e];
|
|
1159
|
+
return {
|
|
1160
|
+
pointerId: e.pointerId,
|
|
1161
|
+
time: e.timeStamp,
|
|
1162
|
+
samples: sources.map((c) => ({
|
|
1163
|
+
...this.toStagePoint(c),
|
|
1164
|
+
pressure: c.pressure,
|
|
1165
|
+
time: typeof c.timeStamp === "number" && c.timeStamp > 0 ? c.timeStamp : e.timeStamp
|
|
1166
|
+
}))
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
attach() {
|
|
1170
|
+
const container = this.instance?.getStage?.()?.container?.();
|
|
1171
|
+
if (container && this.listeners.length === 0) {
|
|
1172
|
+
const inStage = (e) => typeof e.composedPath === "function" ? e.composedPath().includes(container) : true;
|
|
1173
|
+
const add = (type, fn) => {
|
|
1174
|
+
window.addEventListener(type, fn, {
|
|
1175
|
+
passive: true,
|
|
1176
|
+
capture: true
|
|
1177
|
+
});
|
|
1178
|
+
this.listeners.push([
|
|
1179
|
+
type,
|
|
1180
|
+
fn,
|
|
1181
|
+
true
|
|
1182
|
+
]);
|
|
1183
|
+
};
|
|
1184
|
+
add("pointerdown", (e) => {
|
|
1185
|
+
if (inStage(e)) this.recorder.begin(this.recorderEvent(e));
|
|
1186
|
+
});
|
|
1187
|
+
add("pointermove", (e) => {
|
|
1188
|
+
if (inStage(e)) {
|
|
1189
|
+
this.reporter.record(e);
|
|
1190
|
+
this.reporter.setZoom(this.instance.getStage().scaleX());
|
|
1191
|
+
}
|
|
1192
|
+
this.recorder.add(this.recorderEvent(e));
|
|
1193
|
+
});
|
|
1194
|
+
add("pointerup", (e) => this.recorder.end(e.pointerId));
|
|
1195
|
+
add("pointercancel", (e) => this.recorder.end(e.pointerId));
|
|
1196
|
+
}
|
|
1197
|
+
if (this.frameHandle === null && typeof requestAnimationFrame === "function") this.countFrames();
|
|
1198
|
+
}
|
|
1199
|
+
detach() {
|
|
1200
|
+
for (const [type, fn, capture] of this.listeners) window.removeEventListener(type, fn, { capture });
|
|
1201
|
+
this.listeners = [];
|
|
1202
|
+
if (this.frameHandle !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.frameHandle);
|
|
1203
|
+
this.frameHandle = null;
|
|
1204
|
+
}
|
|
1205
|
+
countFrames() {
|
|
1206
|
+
let frames = 0;
|
|
1207
|
+
let since = performance.now();
|
|
1208
|
+
const tick = (now) => {
|
|
1209
|
+
frames++;
|
|
1210
|
+
if (now - since >= 1e3) {
|
|
1211
|
+
this.reporter.setFps(frames * 1e3 / (now - since));
|
|
1212
|
+
frames = 0;
|
|
1213
|
+
since = now;
|
|
1214
|
+
}
|
|
1215
|
+
this.frameHandle = requestAnimationFrame(tick);
|
|
1216
|
+
};
|
|
1217
|
+
this.frameHandle = requestAnimationFrame(tick);
|
|
1218
|
+
}
|
|
1219
|
+
getDeviceReport() {
|
|
1220
|
+
return this.reporter.report();
|
|
1221
|
+
}
|
|
1222
|
+
getLastMetrics() {
|
|
1223
|
+
return this.lastMetrics;
|
|
1224
|
+
}
|
|
1225
|
+
getOverlays() {
|
|
1226
|
+
return { ...this.overlays };
|
|
1227
|
+
}
|
|
1228
|
+
getRecordingCount() {
|
|
1229
|
+
return this.recordings.size;
|
|
1230
|
+
}
|
|
1231
|
+
setOverlay(name, on) {
|
|
1232
|
+
this.overlays[name] = on;
|
|
1233
|
+
this.redraw();
|
|
1234
|
+
}
|
|
1235
|
+
setMaxGhosts(max) {
|
|
1236
|
+
this.recordings.setMax(max);
|
|
1237
|
+
this.redraw();
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Draws the last stroke again through each named brush (every one when none
|
|
1241
|
+
* are given), so the same handwriting can be compared across them.
|
|
1242
|
+
*/
|
|
1243
|
+
compareLast(scenarioId) {
|
|
1244
|
+
if (!BRUSH_SCENARIOS.some((x) => x.id === scenarioId)) return {
|
|
1245
|
+
lines: 0,
|
|
1246
|
+
reason: `Unknown brush "${scenarioId}"`
|
|
1247
|
+
};
|
|
1248
|
+
this.compareScenario = scenarioId;
|
|
1249
|
+
const recording = this.recordings.last();
|
|
1250
|
+
if (!recording) return {
|
|
1251
|
+
lines: 0,
|
|
1252
|
+
reason: "No stroke recorded yet: draw one first"
|
|
1253
|
+
};
|
|
1254
|
+
const events = recording.gesture.events.length;
|
|
1255
|
+
if (events === 0) return {
|
|
1256
|
+
lines: 0,
|
|
1257
|
+
reason: "The stroke has no pointer events (nothing was recorded)"
|
|
1258
|
+
};
|
|
1259
|
+
this.redraw();
|
|
1260
|
+
const lines = this.getLayer()?.find(".lab-ghost").length ?? 0;
|
|
1261
|
+
return lines > 0 ? {
|
|
1262
|
+
lines,
|
|
1263
|
+
events
|
|
1264
|
+
} : {
|
|
1265
|
+
lines: 0,
|
|
1266
|
+
events,
|
|
1267
|
+
reason: "No brush produced a line"
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
/** Forgets every kept stroke and removes the ghosts. */
|
|
1271
|
+
clearGhosts() {
|
|
1272
|
+
this.recordings.clear();
|
|
1273
|
+
this.compareScenario = null;
|
|
1274
|
+
this.redraw();
|
|
1275
|
+
}
|
|
1276
|
+
onStrokeStart(info) {
|
|
1277
|
+
this.reporter.setResolvedProfile(info.profile);
|
|
1278
|
+
this.current = {
|
|
1279
|
+
profileKey: info.profile,
|
|
1280
|
+
preset: info.preset,
|
|
1281
|
+
...info.scenario ? { scenario: info.scenario } : {},
|
|
1282
|
+
profile: info.profileConfig,
|
|
1283
|
+
strokeWidth: info.strokeWidth,
|
|
1284
|
+
zoom: info.zoom,
|
|
1285
|
+
samples: [],
|
|
1286
|
+
batchMs: 0
|
|
1287
|
+
};
|
|
1288
|
+
this.getLayer()?.destroyChildren();
|
|
1289
|
+
this.last = null;
|
|
1290
|
+
this.compareScenario = null;
|
|
1291
|
+
}
|
|
1292
|
+
onSample(sample) {
|
|
1293
|
+
if (!this.current) return;
|
|
1294
|
+
this.current.samples.push(sample);
|
|
1295
|
+
if (sample.predicted) return;
|
|
1296
|
+
const layer = this.getLayer();
|
|
1297
|
+
if (!layer) return;
|
|
1298
|
+
const r = this.markerRadius();
|
|
1299
|
+
if (this.overlays.raw) layer.add(new Konva.Circle({
|
|
1300
|
+
x: sample.raw.x,
|
|
1301
|
+
y: sample.raw.y,
|
|
1302
|
+
radius: r,
|
|
1303
|
+
fill: "#ef4444",
|
|
1304
|
+
name: "lab-raw"
|
|
1305
|
+
}));
|
|
1306
|
+
if (this.overlays.stabilized) layer.add(new Konva.Circle({
|
|
1307
|
+
x: sample.stabilized.x,
|
|
1308
|
+
y: sample.stabilized.y,
|
|
1309
|
+
radius: r,
|
|
1310
|
+
fill: "#2563eb",
|
|
1311
|
+
name: "lab-stabilized"
|
|
1312
|
+
}));
|
|
1313
|
+
layer.batchDraw();
|
|
1314
|
+
}
|
|
1315
|
+
onBatch(info) {
|
|
1316
|
+
if (this.current) this.current.batchMs += info.ms;
|
|
1317
|
+
}
|
|
1318
|
+
onStrokeEnd(info) {
|
|
1319
|
+
const current = this.current;
|
|
1320
|
+
this.current = null;
|
|
1321
|
+
if (!current) return;
|
|
1322
|
+
const measured = computeStrokeMetrics({
|
|
1323
|
+
samples: current.samples,
|
|
1324
|
+
beforeFinalize: info.beforeFinalize,
|
|
1325
|
+
finalPoints: info.finalPoints,
|
|
1326
|
+
strokeWidth: info.strokeWidth,
|
|
1327
|
+
drawMs: current.batchMs + info.finalizeMs
|
|
1328
|
+
});
|
|
1329
|
+
const metrics = {
|
|
1330
|
+
profile: current.profileKey,
|
|
1331
|
+
preset: current.preset,
|
|
1332
|
+
...current.scenario ? { scenario: current.scenario } : {},
|
|
1333
|
+
...measured
|
|
1334
|
+
};
|
|
1335
|
+
this.lastMetrics = metrics;
|
|
1336
|
+
this.recordings.add({
|
|
1337
|
+
profileKey: current.profileKey,
|
|
1338
|
+
gesture: this.recorder.snapshot(),
|
|
1339
|
+
strokeWidth: current.strokeWidth,
|
|
1340
|
+
zoom: current.zoom
|
|
1341
|
+
});
|
|
1342
|
+
this.last = {
|
|
1343
|
+
samples: current.samples,
|
|
1344
|
+
finalPoints: info.finalPoints
|
|
1345
|
+
};
|
|
1346
|
+
this.instance.emitEvent(BRUSH_LAB_METRICS_EVENT, metrics);
|
|
1347
|
+
this.redraw();
|
|
1348
|
+
}
|
|
1349
|
+
markerRadius() {
|
|
1350
|
+
const zoom = this.instance.getStage().scaleX();
|
|
1351
|
+
return 2 / (zoom > 0 ? zoom : 1);
|
|
1352
|
+
}
|
|
1353
|
+
redraw() {
|
|
1354
|
+
const layer = this.getLayer();
|
|
1355
|
+
if (!layer) return;
|
|
1356
|
+
layer.destroyChildren();
|
|
1357
|
+
const r = this.markerRadius();
|
|
1358
|
+
const last = this.last;
|
|
1359
|
+
if (last) {
|
|
1360
|
+
const real = last.samples.filter((s) => !s.predicted);
|
|
1361
|
+
if (this.overlays.raw) for (const s of real) layer.add(new Konva.Circle({
|
|
1362
|
+
x: s.raw.x,
|
|
1363
|
+
y: s.raw.y,
|
|
1364
|
+
radius: r,
|
|
1365
|
+
fill: "#ef4444",
|
|
1366
|
+
name: "lab-raw"
|
|
1367
|
+
}));
|
|
1368
|
+
if (this.overlays.stabilized) for (const s of real) layer.add(new Konva.Circle({
|
|
1369
|
+
x: s.stabilized.x,
|
|
1370
|
+
y: s.stabilized.y,
|
|
1371
|
+
radius: r,
|
|
1372
|
+
fill: "#2563eb",
|
|
1373
|
+
name: "lab-stabilized"
|
|
1374
|
+
}));
|
|
1375
|
+
if (this.overlays.final) for (const p of last.finalPoints) layer.add(new Konva.Circle({
|
|
1376
|
+
x: p.x,
|
|
1377
|
+
y: p.y,
|
|
1378
|
+
radius: r * 1.5,
|
|
1379
|
+
stroke: "#16a34a",
|
|
1380
|
+
strokeWidth: r / 2,
|
|
1381
|
+
name: "lab-final"
|
|
1382
|
+
}));
|
|
1383
|
+
if (this.overlays.pressure && last.finalPoints.length > 1) {
|
|
1384
|
+
const bottom = Math.max(...last.finalPoints.map((p) => p.y)) + 30 * r;
|
|
1385
|
+
const flat = [];
|
|
1386
|
+
for (const p of last.finalPoints) flat.push(p.x, bottom - p.pressure * 20 * r);
|
|
1387
|
+
layer.add(new Konva.Line({
|
|
1388
|
+
points: flat,
|
|
1389
|
+
stroke: "#a855f7",
|
|
1390
|
+
strokeWidth: r,
|
|
1391
|
+
name: "lab-pressure"
|
|
1392
|
+
}));
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
const recording = this.recordings.last();
|
|
1396
|
+
if (recording && this.compareScenario) {
|
|
1397
|
+
const scenario = BRUSH_SCENARIOS.find((x) => x.id === this.compareScenario);
|
|
1398
|
+
const preset = scenario ? BRUSH_PROFILE_PRESETS[recording.profileKey].find((p) => p.id === scenario.profiles[recording.profileKey]) : void 0;
|
|
1399
|
+
const toFlat = (pts) => pts.flatMap((p) => [p.x, p.y]);
|
|
1400
|
+
if (this.last && this.last.finalPoints.length > 1) layer.add(new Konva.Line({
|
|
1401
|
+
points: toFlat(this.last.finalPoints),
|
|
1402
|
+
stroke: "#111111",
|
|
1403
|
+
strokeWidth: r * 2.5,
|
|
1404
|
+
lineCap: "round",
|
|
1405
|
+
lineJoin: "round",
|
|
1406
|
+
opacity: .9,
|
|
1407
|
+
name: "lab-actual"
|
|
1408
|
+
}));
|
|
1409
|
+
if (scenario && preset) {
|
|
1410
|
+
const points = replayRecording(recording, resolveBrushProfilePatch(preset.patch), 1);
|
|
1411
|
+
if (points.length >= 2) {
|
|
1412
|
+
layer.add(new Konva.Line({
|
|
1413
|
+
points: toFlat(points),
|
|
1414
|
+
stroke: "#e11d48",
|
|
1415
|
+
strokeWidth: r,
|
|
1416
|
+
lineCap: "round",
|
|
1417
|
+
lineJoin: "round",
|
|
1418
|
+
name: "lab-ghost"
|
|
1419
|
+
}));
|
|
1420
|
+
layer.add(new Konva.Text({
|
|
1421
|
+
x: points[0].x,
|
|
1422
|
+
y: points[0].y - 14 * r,
|
|
1423
|
+
text: `${scenario.label.replace(/^[A-Z]: /, "")} (red) vs drawn (black)`,
|
|
1424
|
+
fontSize: 10 * r,
|
|
1425
|
+
fill: "#e11d48",
|
|
1426
|
+
name: "lab-ghost-label"
|
|
1427
|
+
}));
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
layer.moveToTop();
|
|
1432
|
+
layer.batchDraw();
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
//#endregion
|
|
1436
|
+
export { BRUSH_LAB_DEFAULT_MAX_GHOSTS, BRUSH_LAB_LAYER_ID, BRUSH_LAB_METRICS_EVENT, BRUSH_LAB_PLUGIN_NAME, WeaveBrushLabPlugin };
|
|
1437
|
+
|
|
1438
|
+
//# sourceMappingURL=lab.js.map
|