@inditextech/weave-sdk 5.7.1 → 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.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