1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
//! Stroke engine — bridges pen input events to the brush node graph.
//!
//! Owns the `BrushGraphRunner` for the stroke duration and handles:
//! - Storing raw events in `StrokeRecord` (for re-rendering)
//! - Stabilization (retroactive stroke reshaping via pluggable algorithm)
//! - Computing derived sensor values (speed, distance, angle, tilt)
//! - Interpolating between events and placing dabs at spacing intervals
//! - Evaluating the brush graph per dab (CPU + GPU)
//! - Per-dab save points for rewind capability
use super::eval::BrushGraphRunner;
use super::gpu_context::BrushGpuContext;
use super::interpolation::{lerp_paint_info, CatmullRomSegment};
use super::paint_info::{PaintInformation, StrokeRecord};
use super::save_points::SavePointStore;
use super::spacing::SpacingConfig;
use super::stabilizer::{StabilizeResult, StabilizerAlgorithm};
use super::DAB_REFERENCE_SIZE;
/// Snapshot of the stroke engine's render state at a specific dab.
///
/// Used by the checkpoint system to restore the engine to a known state
/// and re-render only from that point forward, instead of from scratch.
#[derive(Clone)]
pub struct RenderCheckpoint {
pub last_point: Option<PaintInformation>,
pub accumulated_distance: f32,
pub leftover_distance: f32,
pub last_dab_size: [f32; 2],
pub last_dab_pos: Option<[f32; 2]>,
pub dab_count: u32,
}
/// Reference fade distance in pixels. The fade sensor goes from 0 to 1
/// over this distance, then clamps at 1. Configurable per-brush later.
const FADE_DISTANCE_PX: f32 = 1000.0;
/// Drives a single brush stroke from begin to end.
///
/// Created by the engine at stroke start, fed pointer events via `move_to`,
/// and consumed at stroke end to yield a `StrokeRecord`.
pub struct StrokeEngine {
runner: BrushGraphRunner,
record: StrokeRecord,
spacing: SpacingConfig,
/// Pluggable stabilizer algorithm (pass-through when no stabilization).
stabilizer: Box<dyn StabilizerAlgorithm>,
/// Per-dab save points for rewind capability.
pub save_points: SavePointStore,
/// Last processed point for interpolation (post-derived-values).
last_point: Option<PaintInformation>,
/// Cumulative distance along the stroke path (in pixels).
accumulated_distance: f32,
/// Distance remaining from the last segment that didn't reach the next
/// spacing threshold — carried forward to the next segment.
leftover_distance: f32,
/// Dab size [w, h] from the last evaluated dab (for spacing).
last_dab_size: [f32; 2],
/// Position of the most recently *emitted* dab — source-of-truth for
/// `PaintInformation.motion` (per-dab delta, populated in `place_dab`).
/// Distinct from `last_point` which tracks the previous stabilized
/// *event*. Reset to `None` at stroke start and on full re-render.
last_dab_pos: Option<[f32; 2]>,
/// Running dab index within the stroke.
dab_count: u32,
/// Stroke seed for deterministic per-dab randomness. Passed to
/// the runner so random nodes can generate independent sequences.
stroke_seed: u32,
}
impl StrokeEngine {
/// Create a new stroke engine.
///
/// `runner` is a pre-compiled brush graph. `color` is the foreground
/// color (linear RGBA). `spacing` controls dab placement.
/// `stabilizer` is the stroke stabilization algorithm.
pub fn new(
runner: BrushGraphRunner,
color: [f32; 4],
spacing: SpacingConfig,
stabilizer: Box<dyn StabilizerAlgorithm>,
) -> Self {
let stroke_seed = web_time::SystemTime::now()
.duration_since(web_time::SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u32)
.unwrap_or(42);
let d = Self::default_diameter();
Self {
runner,
record: StrokeRecord::new(color, "default".into()),
spacing,
stabilizer,
save_points: SavePointStore::new(),
last_point: None,
accumulated_distance: 0.0,
leftover_distance: 0.0,
last_dab_size: [d, d],
last_dab_pos: None,
dab_count: 0,
stroke_seed,
}
}
/// Default dab diameter for initial spacing (before the first dab is evaluated).
fn default_diameter() -> f32 {
DAB_REFERENCE_SIZE as f32 * 0.5
}
/// The effective canvas-space diameter for spacing and bounding rect.
fn effective_diameter(&self) -> f32 {
self.last_dab_size[0].max(self.last_dab_size[1])
}
/// Feed a raw pointer event to the stabilizer.
///
/// Returns the stabilization result (divergence info). The caller
/// is responsible for rewind + re-render when divergence occurs.
pub fn stabilize(&mut self, raw: PaintInformation) -> StabilizeResult {
self.record.push(raw);
self.stabilizer.push(raw)
}
/// The stabilizer's conservative max divergence window (vector indices).
pub fn max_divergence_window(&self) -> usize {
self.stabilizer.max_divergence_window()
}
/// Number of points in the stabilized polyline.
pub fn stabilizer_len(&self) -> usize {
self.stabilizer.len()
}
/// Capture the current render state as a checkpoint.
pub fn capture_render_state(&self) -> RenderCheckpoint {
RenderCheckpoint {
last_point: self.last_point,
accumulated_distance: self.accumulated_distance,
leftover_distance: self.leftover_distance,
last_dab_size: self.last_dab_size,
last_dab_pos: self.last_dab_pos,
dab_count: self.dab_count,
}
}
/// Restore render state from a checkpoint.
pub fn restore_render_state(&mut self, checkpoint: &RenderCheckpoint) {
self.last_point = checkpoint.last_point;
self.accumulated_distance = checkpoint.accumulated_distance;
self.leftover_distance = checkpoint.leftover_distance;
self.last_dab_size = checkpoint.last_dab_size;
self.last_dab_pos = checkpoint.last_dab_pos;
self.dab_count = checkpoint.dab_count;
}
/// Reset rendering state for a full re-render from scratch.
///
/// Call this before `render_from_stabilized()` when the stabilizer
/// reports divergence and the stroke buffer has been rewound.
pub fn reset_render_state(&mut self) {
self.last_point = None;
self.accumulated_distance = 0.0;
self.leftover_distance = 0.0;
let d = Self::default_diameter();
self.last_dab_size = [d, d];
self.last_dab_pos = None;
self.dab_count = 0;
self.save_points.clear();
}
/// Compute the per-dab motion vector for a dab about to be placed at
/// `pos`, and advance the last-dab-position tracker. Thin wrapper over
/// the free function so the motion contract can be unit-tested without
/// constructing a full `StrokeEngine` (which would require a runner +
/// stabilizer + GPU).
fn next_dab_motion(&mut self, pos: [f32; 2]) -> [f32; 2] {
advance_dab_motion(&mut self.last_dab_pos, pos)
}
/// Render dabs along the stabilized polyline starting from `start_vector_index`.
///
/// Used for partial re-render after checkpoint restoration. Walks the
/// stabilized polyline from `start_vector_index` to tip, computing derived
/// values (speed, distance, angle) between consecutive points, and
/// placing dabs at spacing intervals.
pub fn render_from_stabilized_range(
&mut self,
gpu: &mut BrushGpuContext,
start_vector_index: usize,
) {
let end = self.stabilizer.len().saturating_sub(1);
self.render_from_stabilized_range_to(gpu, start_vector_index, end);
}
/// Render dabs along the stabilized polyline from `start_vector_index`
/// to `end_vector_index` (inclusive).
///
/// Used for segmented rendering with checkpoints between segments.
/// The engine's render state is left ready to continue from end+1.
pub fn render_from_stabilized_range_to(
&mut self,
gpu: &mut BrushGpuContext,
start_vector_index: usize,
end_vector_index: usize,
) {
// `stab_len` is cached once: nothing inside the loop mutates the
// stabilizer, so the count can't drift. We then scope each
// `self.stabilizer.stabilized()` borrow tightly — copying the
// handful of `PaintInformation` values we need (it's `Copy`) and
// releasing the slice before calling `self.place_dab`, which
// takes `&mut self`. This replaces the prior full-polyline
// `.to_vec()` clone (one alloc per `render_from_*` call, growing
// linearly with stroke length).
let stab_len = self.stabilizer.len();
if stab_len == 0 {
return;
}
let start = start_vector_index.min(stab_len);
let end = end_vector_index.min(stab_len - 1);
// When resuming from a checkpoint, snap last_point.pos to the current
// stabilized position. Between checkpoint capture and now, intermediate
// frames may have shifted the polyline — the checkpoint's last_point
// reflects the old position. Without this, the first segment bridges
// from the old position to the new next point, creating a tangent
// discontinuity ("broken chain" artifact at corners).
if start > 0 {
let snap_pos = self.stabilizer.stabilized().get(start - 1).map(|p| p.pos);
if let (Some(pos), Some(lp)) = (snap_pos, self.last_point.as_mut()) {
lp.pos = pos;
}
}
// Walk the polyline, computing derived values and placing dabs.
for i in start..=end {
let (raw, prev_neighbor, next_neighbor) = {
let stab = self.stabilizer.stabilized();
let raw = stab[i];
let prev = if i >= 2 { Some(stab[i - 2]) } else { None };
let next = if i + 1 < stab.len() {
Some(stab[i + 1])
} else {
None
};
(raw, prev, next)
};
let mut info = raw;
// First point of the stroke: no segment to place dabs along.
if self.last_point.is_none() {
info.derive_sensors(None, 0.0);
self.place_dab(&info, gpu, i);
self.last_point = Some(info);
self.save_points
.finalize_render_state(i, self.capture_render_state());
continue;
}
let prev = self.last_point.unwrap();
// Build Catmull-Rom segment between prev (p1) and info (p2).
// Outer control points use stabilized neighbours when available;
// degenerate fallback duplicates the endpoint at stroke edges.
let p0_pt = prev_neighbor.unwrap_or(prev);
let p1_pt = prev;
let p2_pt = info;
let p3_pt = next_neighbor.unwrap_or(info);
let seg = CatmullRomSegment::new(&p0_pt, &p1_pt, &p2_pt, &p3_pt);
let arc_len = seg.arc_length();
// Segment-derived sensors use the Catmull-Rom arc length —
// chord distance would under-count on curved strokes.
info.derive_sensors(Some(&prev), arc_len);
self.accumulated_distance = info.distance;
if arc_len < 0.001 {
self.last_point = Some(info);
self.save_points
.finalize_render_state(i, self.capture_render_state());
continue;
}
let mut traveled = self.leftover_distance;
while traveled < arc_len {
// Position comes from the curve; sensors lerp between
// endpoints so they can't overshoot (pressure stays in-range,
// time stays monotonic, etc.).
let cr_dab = seg.eval_at_distance(traveled);
let t_lerp = traveled / arc_len;
let mut dab_info = lerp_paint_info(&prev, &info, t_lerp);
dab_info.pos = cr_dab.pos;
self.place_dab(&dab_info, gpu, i);
let step = self.spacing.distance(self.effective_diameter());
debug_assert!(
step >= super::spacing::ABSOLUTE_MIN_SPACING_PX,
"dab spacing dropped below 1px: {step}"
);
traveled += step;
}
self.leftover_distance = traveled - arc_len;
self.last_point = Some(info);
// Capture end-of-segment state on ALL save points for this vector
// index. This represents "everything through vector index i is
// fully processed" — the checkpoint restore starts from i+1.
self.save_points
.finalize_render_state(i, self.capture_render_state());
}
// Phase-end flush for dab-batching terminals (paint, watercolor_batched):
// dispatch the batched dab queue before this phase's submit_final.
// Fragment-path terminals no-op here.
self.runner.flush_dabs(gpu);
}
/// Process a raw pointer event — stabilize and render in one step.
///
/// Convenience method that combines `stabilize()` + `render_from_stabilized_tail()`.
/// Used by the fallback path when no stroke buffer is active.
/// When divergence occurs, the caller must handle rewind externally.
pub fn move_to(&mut self, raw: PaintInformation, gpu: &mut BrushGpuContext) -> StabilizeResult {
let result = self.stabilize(raw);
if result.divergence_index.is_none() {
self.render_from_stabilized_tail(gpu);
}
result
}
/// Evaluate the brush graph for a single dab at the given position.
fn place_dab(
&mut self,
info: &PaintInformation,
gpu: &mut BrushGpuContext,
vector_index: usize,
) {
let mut dab_info = *info;
dab_info.fade = (dab_info.distance / FADE_DISTANCE_PX).min(1.0);
// Motion is a per-dab quantity — the previous-dab → this-dab delta.
// Interpolators leave it zero (they have no view of dab order); we
// fill it here so smudge sees the correct smear-sample offset.
dab_info.motion = self.next_dab_motion(dab_info.pos);
self.runner.clear_slots();
self.runner.seed_sensors(
&dab_info,
self.record.color,
self.stroke_seed,
self.dab_count,
);
self.runner.execute_cpu();
// Per-dab context state: reset the read-mirror cache so the first
// node that needs a canvas region this dab actually issues the copy.
if let Some(stroke) = gpu.stroke.as_mut() {
stroke.reset_per_dab_read_cache();
}
// Reset the write-bbox accumulator so each terminal's passes can
// publish their footprint fresh. Read back after execute_gpu below.
gpu.dab_batch.write_canvas_bbox = None;
self.runner.execute_gpu(gpu);
gpu.flush_if_needed();
// Update `last_dab_size` from whichever terminal in the graph
// publishes a `dab_size` output. The runner cached the slot at
// build time, so a new terminal that publishes the same port is
// picked up automatically — no hand-written terminal-name list
// to keep in sync.
if let Some(size) = self.runner.last_dab_size() {
self.last_dab_size = size;
}
// Dab bounding box for save points, in canvas coords. Prefer the
// footprint the terminal actually wrote (post-scatter, post-anything
// else the graph did). Fall back to the `info.pos ± radius`
// envelope for graphs without a scratch-writing terminal, so they
// still get sensible checkpoint bounds.
let canvas_bbox = gpu.dab_batch.write_canvas_bbox.unwrap_or_else(|| {
let diameter = self.effective_diameter();
let half = diameter * 0.5;
let x = (info.pos[0] - half).floor() as i32;
let y = (info.pos[1] - half).floor() as i32;
let x2 = (info.pos[0] + half).ceil() as i32;
let y2 = (info.pos[1] + half).ceil() as i32;
crate::coord::CanvasRect::from_xywh(
x,
y,
(x2 - x).max(0) as u32,
(y2 - y).max(0) as u32,
)
});
// Render state is captured at end-of-segment, not per-dab.
// Push a placeholder; the loop in render_from_stabilized_range
// overwrites the last save point's render_state after each segment.
self.save_points.push(
canvas_bbox,
vector_index,
RenderCheckpoint {
last_point: None,
accumulated_distance: 0.0,
leftover_distance: 0.0,
last_dab_size: [0.0, 0.0],
last_dab_pos: None,
dab_count: 0,
},
);
self.dab_count += 1;
gpu.perf.record_dab();
}
/// Render only the tail of the stabilized polyline — the latest point.
///
/// Used when the stabilizer reports no divergence (only new points added).
/// The engine's internal state (last_point, leftover_distance) is still
/// valid from the previous render, so we continue from where we left off.
pub fn render_from_stabilized_tail(&mut self, gpu: &mut BrushGpuContext) {
let stabilized = self.stabilizer.stabilized();
let len = stabilized.len();
if len == 0 {
return;
}
let raw_pt = stabilized[len - 1];
let mut info = raw_pt;
if self.last_point.is_none() {
info.derive_sensors(None, 0.0);
self.place_dab(&info, gpu, len - 1);
self.last_point = Some(info);
self.save_points
.finalize_render_state(len - 1, self.capture_render_state());
// Terminals queue the dab and rely on `flush_dabs` to
// actually run the render pass. Without this, a single-
// event stroke (one `move_to` + `end_stroke`) leaves the
// queued first dab unflushed and the stroke renders as
// nothing.
self.runner.flush_dabs(gpu);
return;
}
let prev = self.last_point.unwrap();
// Tip segment: no future sample yet, so p3 = p2 (degenerate).
// The next input event re-renders this segment with proper
// lookahead via the synthesized tip-correction divergence.
let p0_pt = if len >= 3 { stabilized[len - 3] } else { prev };
let p1_pt = prev;
let p2_pt = info;
let p3_pt = info;
let seg = CatmullRomSegment::new(&p0_pt, &p1_pt, &p2_pt, &p3_pt);
let arc_len = seg.arc_length();
info.derive_sensors(Some(&prev), arc_len);
self.accumulated_distance = info.distance;
if arc_len < 0.001 {
self.last_point = Some(info);
return;
}
let mut traveled = self.leftover_distance;
while traveled < arc_len {
let cr_dab = seg.eval_at_distance(traveled);
let t_lerp = traveled / arc_len;
let mut dab_info = lerp_paint_info(&prev, &info, t_lerp);
dab_info.pos = cr_dab.pos;
self.place_dab(&dab_info, gpu, len - 1);
let step = self.spacing.distance(self.effective_diameter());
debug_assert!(
step >= super::spacing::ABSOLUTE_MIN_SPACING_PX,
"dab spacing dropped below 1px: {step}"
);
traveled += step;
}
self.leftover_distance = traveled - arc_len;
self.last_point = Some(info);
self.save_points
.finalize_render_state(len - 1, self.capture_render_state());
// Phase-end flush for compute-path terminals. See sibling call
// in `render_from_stabilized_range_to`.
self.runner.flush_dabs(gpu);
}
/// Delegate the stroke-start / rewind-boundary lifecycle hook to every
/// GPU terminal in the graph. Called by the engine at the start of a
/// stroke and at every rewind boundary (full or partial) — the paint
/// terminal clears its scratch here; other terminals (warp, smudge, …)
/// may copy the pre-stroke layer, etc.
pub fn begin_stroke(&mut self, gpu: &mut BrushGpuContext) {
self.runner.begin_stroke(gpu);
}
/// Delegate the per-pen-event commit hook to every GPU terminal. Called
/// once per pen event after the event's dabs have rendered into the
/// scratch.
pub fn commit(&mut self, gpu: &mut BrushGpuContext) {
self.runner.commit(gpu);
}
/// Finish the stroke, consuming the engine and returning the record.
pub fn end(self) -> StrokeRecord {
self.record
}
/// Number of dabs placed so far.
pub fn dab_count(&self) -> u32 {
self.dab_count
}
}
/// Per-dab motion: delta from the previous emitted dab. `tracker` is the
/// position of the most recently emitted dab, or `None` at stroke start /
/// after a rewind. Returns `[0, 0]` when there is no previous dab — that's
/// the contract smudge relies on (zero motion → identity smear write).
fn advance_dab_motion(tracker: &mut Option<[f32; 2]>, pos: [f32; 2]) -> [f32; 2] {
let motion = match *tracker {
Some(prev) => [pos[0] - prev[0], pos[1] - prev[1]],
None => [0.0, 0.0],
};
*tracker = Some(pos);
motion
}
#[cfg(test)]
mod tests {
use super::*;
/// Regression: per-dab motion must be the previous-dab → this-dab delta,
/// not the segment delta. The old bug carried `PaintInformation.motion`
/// from `derive_sensors` (event-to-event) through to every interpolated
/// dab in the segment, so a 100px segment with 20 dabs at 5px spacing
/// would seed `motion=[100,0]` for every dab — wrong for smudge. After
/// the fix, each dab sees its own ~5px step.
#[test]
fn motion_is_per_dab_delta_not_segment_delta() {
let mut tracker: Option<[f32; 2]> = None;
// First dab — no prior dab, motion must be zero.
assert_eq!(advance_dab_motion(&mut tracker, [0.0, 0.0]), [0.0, 0.0]);
// 20 dabs at 5px spacing along x — each motion must be ~5px, not 100px.
for i in 1..=20 {
let pos = [i as f32 * 5.0, 0.0];
let m = advance_dab_motion(&mut tracker, pos);
assert!(
(m[0] - 5.0).abs() < 1e-6 && m[1].abs() < 1e-6,
"dab {i}: expected ~[5,0], got {m:?} (regression: per-segment motion leaking through)"
);
}
}
#[test]
fn motion_resets_to_zero_after_rewind() {
let mut tracker: Option<[f32; 2]> = None;
advance_dab_motion(&mut tracker, [10.0, 10.0]);
advance_dab_motion(&mut tracker, [20.0, 10.0]);
// Simulate `reset_render_state` clearing the tracker.
tracker = None;
assert_eq!(advance_dab_motion(&mut tracker, [100.0, 100.0]), [0.0, 0.0]);
}
#[test]
fn motion_diagonal_step() {
let mut tracker: Option<[f32; 2]> = None;
advance_dab_motion(&mut tracker, [10.0, 20.0]);
let m = advance_dab_motion(&mut tracker, [13.0, 24.0]);
assert!((m[0] - 3.0).abs() < 1e-6 && (m[1] - 4.0).abs() < 1e-6);
}
}