darkly 0.5.0

A GPU-native paint engine on wgpu: brushes, layers, blend modes, masks, selections, and undo.
Documentation
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
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
//! GPU context bundle passed to brush node evaluators during `execute_gpu`
//! and `render_cursor_preview_pipeline`.
//!
//! Provides everything a GPU node needs: command encoder, device, queue,
//! dab texture pool, pipelines, canvas target, and selection bind group.
//! Stroke and preview modes are differentiated by *which* method the runner
//! invokes (`evaluate_gpu` vs `render_cursor_preview`), not by a flag on this
//! struct — terminals stop branching on a mode enum.

use std::collections::HashMap;
use std::sync::Arc;

use super::eval::BrushCursorPreviewInfo;
use super::pipeline::BrushPipelines;
use super::scratch::Scratch;
use super::wgsl::{CompiledBrush, IntrinsicUniforms};
use super::wire::ScalarValue;
use crate::gpu::overlay::ToolOverlay;
use crate::gpu::paint_target::GpuPaintTarget;

/// Hard cap on the preview-mask side length. Above this the overlay's
/// linear sampler upsamples (visible stairstepping on soft edges),
/// but the cost is bounded — most brushes never reach this. Sized so
/// a 512² RGBA8 texture is ~1 MB VRAM, acceptable as a hover-only
/// resource.
pub const MAX_PREVIEW_MASK_SIDE: u32 = 512;

/// Minimum preview-mask side length. Even tiny brushes get this floor
/// so the linear-upsampling overlay sample has enough texels to read.
pub const MIN_PREVIEW_MASK_SIDE: u32 = 128;

/// Per-context brush perf counters. Drained at `submit_final` and folded
/// into the engine-side accumulator via [`AddAssign`]. Engine-only events
/// (mid-stroke full re-renders) live directly on `DarklyEngine`, not on
/// this struct — the per-context value would always be zero.
///
/// `submit_us` is wall-clock around `queue.submit()`. The per-flush
/// counters describe workload (dab volume, union-bbox area), not host
/// time spent processing it. See `engine/perf.rs` for the design note.
#[derive(Default, Clone, Debug)]
pub struct BrushPerfCounters {
    /// Number of `place_dab` invocations during this counter's lifetime.
    /// Exposed by `engine.test_stroke_total_dabs()` for integration tests.
    pub dabs_placed: u32,
    /// Wall-clock microseconds inside `queue.submit()` (final + ring-flush).
    pub submit_us: u64,
    /// Number of `queue.submit()` calls.
    pub submits: u32,
    /// Number of dab-terminal flushes (one `flush_dabs` call each).
    pub dab_flushes: u32,
    /// Total dabs that flowed through a dab-batching terminal.
    pub flushed_dabs: u32,
    /// Sum of `union_w * union_h` across every dab flush.
    pub dab_union_bbox_area: u64,
    /// Per-flush dab counts. One entry per `flush_dabs` call. Drained
    /// per-event by the bench harness; production paths never read this.
    pub dabs_per_flush: Vec<u32>,
    /// Per-flush `union_w * union_h` in canvas pixels. Parallel to
    /// `dabs_per_flush`.
    pub dab_union_bbox_area_per_flush: Vec<u32>,
}

impl BrushPerfCounters {
    /// Increment the per-stroke dab counter. Called once per `place_dab`.
    pub fn record_dab(&mut self) {
        self.dabs_placed = self.dabs_placed.saturating_add(1);
    }

    /// Increment dab + dispatch counts at flush time once the queued dabs
    /// are about to be dispatched.
    pub fn record_dab_flush(&mut self, dab_count: u32) {
        self.flushed_dabs = self.flushed_dabs.saturating_add(dab_count);
        self.dab_flushes = self.dab_flushes.saturating_add(1);
    }

    /// Record the workload shape of one dab flush: `dab_count` queued
    /// dabs covering a `union_w × union_h` bbox in canvas pixels.
    /// Appends to both per-flush vectors and accumulates the area
    /// total. Called at the top of `flush_dabs` once the union bbox
    /// has been computed.
    pub fn record_dab_flush_workload(&mut self, dab_count: u32, union_w: u32, union_h: u32) {
        let area = (union_w as u64).saturating_mul(union_h as u64);
        self.dab_union_bbox_area = self.dab_union_bbox_area.saturating_add(area);
        self.dabs_per_flush.push(dab_count);
        self.dab_union_bbox_area_per_flush
            .push(area.min(u32::MAX as u64) as u32);
    }
}

impl std::ops::AddAssign for BrushPerfCounters {
    /// Merge per-context counters into the engine-side stroke accumulator
    /// (or any two accumulators in general). Scalars saturating-add;
    /// per-flush vectors append in order.
    fn add_assign(&mut self, mut rhs: Self) {
        self.dabs_placed = self.dabs_placed.saturating_add(rhs.dabs_placed);
        self.submit_us = self.submit_us.saturating_add(rhs.submit_us);
        self.submits = self.submits.saturating_add(rhs.submits);
        self.dab_flushes = self.dab_flushes.saturating_add(rhs.dab_flushes);
        self.flushed_dabs = self.flushed_dabs.saturating_add(rhs.flushed_dabs);
        self.dab_union_bbox_area = self
            .dab_union_bbox_area
            .saturating_add(rhs.dab_union_bbox_area);
        self.dabs_per_flush.append(&mut rhs.dabs_per_flush);
        self.dab_union_bbox_area_per_flush
            .append(&mut rhs.dab_union_bbox_area_per_flush);
    }
}

/// Hard cap on dab records that can be queued in a single phase across
/// any dab-batching terminal. Sized so the per-phase dab buffer is
/// trivial VRAM cost (16384 records × ~32-byte typical record ≈ 512
/// KB) and well above what any realistic stroke phase will reach
/// (~30 dabs even at high stabilisation). `DabBatch::queue_dab`
/// debug-asserts on this — overflow panics loudly in test/dev so the
/// constant gets bumped rather than silently truncating in release.
pub const MAX_DABS_PER_PHASE: u32 = 16384;

/// Resources needed to render to a real paint target — present during a
/// real stroke and during the palette-thumbnail render, absent during a
/// cursor-hover preview where the terminal writes to the overlay mask
/// instead.
///
/// Bundling these four fields together makes their joint Some-ness a
/// type-level invariant: a code path that holds a `&StrokeResources`
/// can rely on the scratch *and* the pre-stroke snapshot being live.
/// Read-mirror / write-bbox helpers hang off this struct so consumers
/// reach for "the stroke" rather than picking individual fields out of a
/// flat bag.
pub struct StrokeResources<'a> {
    /// The stroke scratch (write side + R/W-hazard read mirror).
    /// Held mutably so the read mirror can lazy-grow to fit the current
    /// dab's footprint.
    pub scratch: &'a mut Scratch,
    /// The paint target the terminal is committing to: a layer (RGBA8)
    /// or mask (R8). Format awareness lives in `GpuPaintTarget`'s brush
    /// extension (`commit_brush_dab`, `save_pre_stroke_snapshot`,
    /// `commit_scratch_blit`) — terminals call uniform methods on the
    /// paint target and never branch on R8 vs RGBA8.
    pub paint_target: GpuPaintTarget<'a>,
    /// Pre-stroke layer snapshot. Supplied by `StrokeBuffer::save_pre_stroke`
    /// at the start of a stroke.
    pub pre_stroke_texture: &'a wgpu::Texture,
    /// Bind group (canvas-copy BGL) over `pre_stroke_texture`, pre-built
    /// by `StrokeBuffer` so `color_output::commit` can bind it as the
    /// composite background without recreating bind groups every event.
    pub pre_stroke_bind_group: &'a wgpu::BindGroup,
}

/// Cursor-hover preview state. Populated by `brush_graph::regenerate_brush_cursor_preview`
/// to drive the overlay's preview-mask texture; absent during a real stroke.
pub struct CursorPreviewState<'a> {
    /// Preview mask target. Populated by the engine during preview regen;
    /// terminal `render_cursor_preview` hooks blit their preview texture into it.
    ///
    /// Used as the fallback when `mask_overlay` is `None` — tests
    /// pre-allocate a fixed-size mask and stuff a view in here. The
    /// engine driver leaves this `None` and grows the mask on demand
    /// via [`BrushGpuContext::ensure_cursor_preview_mask`] through
    /// `mask_overlay`.
    pub mask_view: Option<&'a wgpu::TextureView>,
    pub mask_size: (u32, u32),
    /// Mutable handle to the overlay that owns the preview-mask texture.
    /// Held by the engine driver so a terminal's `render_cursor_preview` can
    /// grow the mask via [`BrushGpuContext::ensure_cursor_preview_mask`] when
    /// the brush's bbox would otherwise stairstep through the overlay's
    /// linear sampler. `None` in tests that build the context manually
    /// with a fixed-size pre-allocated mask.
    pub mask_overlay: Option<&'a mut ToolOverlay>,
    /// Set by a terminal's `render_cursor_preview` hook to publish overlay
    /// placement info (extent + rotation) to the engine. The engine reads
    /// this after `render_cursor_preview_pipeline` returns. `None` outside the
    /// preview path; first-write-wins if multiple terminals try to publish
    /// (unusual — typically one terminal owns the preview).
    pub info: Option<BrushCursorPreviewInfo>,
}

/// Per-batch dab ledger. Populated by whichever dab-batching terminal is
/// active in the graph during a single pen event and drained by that
/// terminal's `flush_dabs` hook. Always present on `BrushGpuContext`
/// (empty when no terminal queues anything — cursor-hover, no-op
/// strokes); methods hang off it so the per-batch lifecycle is visible
/// at the call site.
///
/// A brush graph has at most one dab-batching terminal at a time, so
/// the byte layouts of `bytes` and `meta_bytes` are unambiguous — each
/// terminal reinterprets via `bytemuck::cast_slice` against its own
/// record type.
#[derive(Default)]
pub struct DabBatch {
    /// Dabs queued during a single pen event. Bytes written by
    /// `bytemuck::bytes_of` on each terminal's own record struct — the
    /// WGSL binding reinterprets them as that terminal's `Dab` type.
    /// Empty for brushes that don't use a dab-batching terminal.
    pub bytes: Vec<u8>,
    /// Number of dab records currently in `bytes`. Each terminal's
    /// record size is constant per terminal, so `bytes.len() == count *
    /// sizeof(Record)`; the count is tracked explicitly so flush code
    /// doesn't need to know the record size.
    pub count: u32,
    /// Layer-local bounding box covered by the queued dabs, as
    /// `[x0, y0, x1, y1]`. The terminal's `flush_dabs` reads it as a
    /// workload metric (recorded into `BrushPerfCounters` for the bench
    /// harness). `None` when the queue is empty.
    pub bbox: Option<[u32; 4]>,
    /// Terminal-private per-dab CPU meta, packed by `evaluate_gpu` in
    /// lockstep with [`Self::bytes`] and drained by the terminal's
    /// `flush_dabs` hook. Only used by per-dab-feedback terminals
    /// (`smudge`, `liquify`) that need CPU-side state at flush time to
    /// drive mirror-snapshot copies without re-deriving footprints from
    /// GPU memory. The framework doesn't interpret these bytes — the
    /// owning terminal reinterprets them via `bytemuck::cast_slice`
    /// against its own meta record struct.
    pub meta_bytes: Vec<u8>,
    /// Union of canvas-pixel rects the current dab's passes write to.
    /// The node that issues the write is the only thing that knows the
    /// real footprint — `stroke_engine` can't derive it from `info.pos`
    /// because the graph may offset the dab (scatter, wobble, future
    /// position-modulating nodes). Each pass unions its rect into this
    /// via [`Self::push_write_bbox`]; `stroke_engine` reads it after
    /// `execute_gpu` for the save-point bbox and resets it before the
    /// next dab.
    pub write_canvas_bbox: Option<crate::coord::CanvasRect>,
    /// Compiled WGSL for this brush, populated by the engine before
    /// stroke evaluation. Read by the terminal's `evaluate_gpu` and
    /// `flush_dabs` to know the dab record / uniform layouts and the
    /// pipeline topology hash.
    pub compiled_brush: Option<Arc<CompiledBrush>>,
    /// Name → value map of every output slot in the brush graph, built
    /// by the runner's `dispatch_gpu` immediately after `execute_cpu`
    /// and held for the duration of the dispatch pass. Keys follow the
    /// `n{node_id}_{port_name}` convention used by
    /// [`crate::brush::wgsl::CompileWgslCtx::dab_field_name`]. The
    /// terminal reads from this to pack per-dab records and uniforms.
    pub slot_outputs: Option<HashMap<String, ScalarValue>>,
}

impl DabBatch {
    /// Pack one dab record for the active compiled brush into [`Self::bytes`]
    /// and bump [`Self::count`]. Every terminal (paint, watercolor, smudge,
    /// liquify) calls this from its `evaluate_gpu` after computing the
    /// per-dab geometry; the WGSL terminal reinterprets the bytes via its
    /// dab layout at flush time.
    ///
    /// [`Self::slot_outputs`] must have been populated by the runner's
    /// `dispatch_gpu` before this call — that's the source of the per-node
    /// field values the compiled record packer reads.
    pub fn queue_dab(
        &mut self,
        compiled: &CompiledBrush,
        position: [f32; 2],
        bbox_radius: f32,
        radius: f32,
    ) {
        let record_start = self.bytes.len();
        super::wgsl::pack_intrinsic_dab_header(&mut self.bytes, position, bbox_radius, radius);
        let outputs = self
            .slot_outputs
            .as_ref()
            .expect("queue_dab requires slot_outputs on DabBatch");
        super::wgsl::pack_dab_record(compiled, outputs, &mut self.bytes);
        // Pad to the full record size so the next dab starts aligned.
        let written = self.bytes.len() - record_start;
        if written < compiled.dab_record_size {
            self.bytes
                .resize(record_start + compiled.dab_record_size, 0);
        }
        self.count = self.count.saturating_add(1);
        debug_assert!(
            self.count <= MAX_DABS_PER_PHASE,
            "dab queue overflowed MAX_DABS_PER_PHASE ({MAX_DABS_PER_PHASE}); \
             bump the constant or flush more often",
        );
    }

    /// Drain the compute-dab queue. Returns the raw bytes (caller
    /// reinterprets via `bytemuck::cast_slice`) and the dab count. Also
    /// clears [`Self::bbox`]. The terminal-private [`Self::meta_bytes`]
    /// is *not* drained here — the caller drains it directly via
    /// [`Self::take_meta`] when it needs to walk per-dab meta inside its
    /// `flush_dabs` loop. Called from a terminal's `flush_dabs` hook
    /// once the dispatch is encoded.
    pub fn take(&mut self) -> (Vec<u8>, u32) {
        let bytes = std::mem::take(&mut self.bytes);
        let count = std::mem::take(&mut self.count);
        self.bbox = None;
        (bytes, count)
    }

    /// Drain the per-dab CPU meta queue. Symmetric with [`Self::take`];
    /// callers `bytemuck::cast_slice` the returned bytes against their
    /// meta record type.
    pub fn take_meta(&mut self) -> Vec<u8> {
        std::mem::take(&mut self.meta_bytes)
    }

    /// Discard the compute-dab queue without dispatching. Used at stroke
    /// begin / rewind to drop any state from a prior context, and by
    /// `flush_dabs` when its early-out path runs (empty queue, no
    /// scratch, etc.) so a follow-up dispatch doesn't see stale state.
    pub fn clear(&mut self) {
        self.bytes.clear();
        self.count = 0;
        self.bbox = None;
        self.meta_bytes.clear();
    }

    /// Union a write-pass footprint into [`Self::write_canvas_bbox`].
    /// Called by any GPU node whose pass writes to the stroke scratch,
    /// so `stroke_engine` can record a save-point bbox that matches what
    /// was actually drawn.
    pub fn push_write_bbox(&mut self, bbox: crate::coord::CanvasRect) {
        if bbox.is_empty() {
            return;
        }
        self.write_canvas_bbox = Some(match self.write_canvas_bbox {
            Some(prev) => prev.union(bbox),
            None => bbox,
        });
    }
}

impl<'a> StrokeResources<'a> {
    /// Reset the per-dab read-mirror cache so the first node that needs
    /// the read mirror this dab actually issues a fresh copy.
    pub fn reset_per_dab_read_cache(&mut self) {
        self.scratch.reset_read_origin_cache();
    }
}

/// Everything a GPU brush node needs to record render passes.
///
/// Created once per rendering batch (per-segment in divergence, per-frame
/// in the no-divergence tail) and passed to the stroke engine.  Each dab
/// records its render passes into the encoder.  Dynamic uniform buffer
/// offsets allow all dabs to share one encoder without per-dab submission.
/// Call `submit_final()` when the batch is complete.
///
/// Fields fall into three groups:
///
/// - **Always-present GPU primitives** (encoder, device, queue, pipelines,
///   selection bind group, canvas dims, blend mode, perf): used by every
///   path; live flat on the struct because there's no mode-dependence to
///   encode.
/// - **Mode-dependent state** (`stroke`, `preview`): grouped into
///   `Option`-wrapped sub-structs. `stroke` is `Some` during a real
///   stroke or palette-thumbnail render; `preview` is `Some` during
///   cursor-hover preview regen. The two are mutually exclusive in
///   practice, but the type does not enforce it — only their joint
///   construction at the engine boundary does.
/// - **Per-batch ledger** (`dab_batch`): the dab-queueing machinery the
///   compiled brush populates. Always present; default-empty when no
///   terminal queues anything.
pub struct BrushGpuContext<'a> {
    pub encoder: wgpu::CommandEncoder,
    pub device: &'a wgpu::Device,
    pub queue: &'a wgpu::Queue,
    pub pipelines: &'a BrushPipelines,
    /// Selection mask bind group (or default 1x1 white when no selection).
    pub selection_bind_group: &'a wgpu::BindGroup,
    pub canvas_width: u32,
    pub canvas_height: u32,
    /// Plane-space offset of the canvas window (`Document::canvas_origin`).
    /// Threaded into `IntrinsicUniforms.canvas_origin` so the stroke shader
    /// maps a plane position to the window-anchored selection mask.
    pub canvas_origin: [i32; 2],
    /// Composite blend mode override: 0 = source-over (paint), 1 = destination-out (erase).
    /// Set per-stroke by the engine based on the active tool.
    pub blend_mode: u32,
    /// Active view rotation in radians (the `rotation` arg passed to
    /// `ViewTransform::from_pan_zoom_rotate`). Threaded into
    /// `IntrinsicUniforms.view_rotation` by `intrinsic_header` / `intrinsic_preview_header`
    /// so terminals never need to know about view rotation themselves.
    pub view_rotation: f32,
    /// Host-side counters for this context's lifetime. Written by the
    /// stroke engine + compute terminals via `record_*` helpers; drained
    /// by `submit_final` so the engine can `+= ` the result into its own
    /// stroke-level accumulator.
    pub perf: BrushPerfCounters,

    /// Stroke-mode resources (real stroke + palette-thumbnail render).
    /// `None` in cursor-hover preview where no commit happens.
    pub stroke: Option<StrokeResources<'a>>,
    /// Cursor-hover preview state. `None` during a real stroke.
    pub preview: Option<CursorPreviewState<'a>>,
    /// Per-batch dab ledger — see [`DabBatch`].
    pub dab_batch: DabBatch,
}

impl<'a> BrushGpuContext<'a> {
    /// Build the per-stroke intrinsic header for a terminal flushing dabs
    /// into a layer. Single source of truth for which fields go where so
    /// terminals don't each maintain their own `IntrinsicUniforms { … }`
    /// literal; adding a future global intrinsic field only touches this
    /// method (and `intrinsic_preview_header` below). Cursor-preview-only
    /// fields are zeroed here — see `intrinsic_preview_header` for the
    /// other mode.
    pub fn intrinsic_header(
        &self,
        layer_offset: [i32; 2],
        layer_size: [u32; 2],
    ) -> IntrinsicUniforms {
        IntrinsicUniforms {
            layer_offset,
            layer_size,
            canvas_size: [self.canvas_width, self.canvas_height],
            canvas_origin: self.canvas_origin,
            cursor_preview_centre: [0.0, 0.0],
            cursor_preview_size: [0, 0],
            view_rotation: self.view_rotation,
            _pad: [0, 0, 0],
        }
    }

    /// Build the intrinsic header for the cursor-preview render path.
    /// The preview target is its own square texture (not the canvas), so
    /// `layer_offset / layer_size / canvas_size` collapse to the target
    /// dimensions; `cursor_preview_centre / size` carry the preview
    /// viewport the vertex stage maps against.
    pub fn intrinsic_preview_header(
        &self,
        target_w: u32,
        target_h: u32,
        centre: [f32; 2],
    ) -> IntrinsicUniforms {
        IntrinsicUniforms {
            layer_offset: [0, 0],
            layer_size: [target_w, target_h],
            canvas_size: [target_w, target_h],
            // Preview renders into its own square texture and ignores the
            // selection (`sel = 1.0`), so the window origin is irrelevant.
            canvas_origin: [0, 0],
            cursor_preview_centre: centre,
            cursor_preview_size: [target_w, target_h],
            view_rotation: self.view_rotation,
            _pad: [0, 0, 0],
        }
    }

    /// Submit the batched encoder and consume the context. Returns the
    /// per-context perf counters so the caller can fold them into the
    /// stroke-level accumulator; the final submit's wall clock is
    /// included in `submit_us`.
    pub fn submit_final(mut self) -> BrushPerfCounters {
        let t = web_time::Instant::now();
        self.queue.submit([self.encoder.finish()]);
        let us = t.elapsed().as_micros() as u64;
        self.perf.submit_us = self.perf.submit_us.saturating_add(us);
        self.perf.submits = self.perf.submits.saturating_add(1);
        self.perf
    }

    /// If any uniform ring is nearly full, submit the current encoder,
    /// reset all rings, and create a fresh encoder.  Called between dabs
    /// to prevent ring overflow — adds at most 1 extra submit per ~250
    /// dabs, which is negligible compared to the old per-dab submit.
    pub fn flush_if_needed(&mut self) {
        if self.pipelines.rings_nearly_full() {
            let t = web_time::Instant::now();
            let finished = std::mem::replace(
                &mut self.encoder,
                self.device
                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
                        label: Some("brush-ring-flush"),
                    }),
            );
            self.queue.submit([finished.finish()]);
            self.pipelines.reset_uniform_rings();
            self.perf.submit_us = self
                .perf
                .submit_us
                .saturating_add(t.elapsed().as_micros() as u64);
            self.perf.submits = self.perf.submits.saturating_add(1);
        }
    }

    /// Ensure the preview mask is sized to fit a brush footprint of
    /// `bbox_radius` canvas pixels half-extent. Reallocates if needed,
    /// rounding the requested side up to the next power of two so
    /// neighbouring slider-scrub values hit the cache. Returns
    /// `(view, width, height)`.
    ///
    /// When `preview.mask_overlay` is bound (engine driver path), the
    /// overlay's preview-mask texture grows on demand up to
    /// [`MAX_PREVIEW_MASK_SIDE`]. When it's `None` (test path), the
    /// caller's pre-allocated `preview.mask_view` / `preview.mask_size`
    /// is returned unchanged. Returns `None` if there is no preview state.
    pub fn ensure_cursor_preview_mask(
        &mut self,
        bbox_radius: f32,
    ) -> Option<(wgpu::TextureView, u32, u32)> {
        let preview = self.preview.as_mut()?;
        let requested = ((bbox_radius * 2.0).ceil() as u32).max(1);
        let side = requested
            .next_power_of_two()
            .clamp(MIN_PREVIEW_MASK_SIDE, MAX_PREVIEW_MASK_SIDE);
        if let Some(overlay) = preview.mask_overlay.as_mut() {
            let view = overlay
                .ensure_cursor_preview_mask(self.device, side, side)
                .clone();
            return Some((view, side, side));
        }
        // Test fallback — return the pre-allocated mask as-is.
        let view = preview.mask_view?;
        Some((view.clone(), preview.mask_size.0, preview.mask_size.1))
    }

    /// Compute the layer-clipped per-dab footprint, push the canvas-space
    /// write bbox (so save_points / checkpoints cover the real damage
    /// region), and snapshot the scratch under the dab into
    /// `scratch read mirror`. Returns `None` if the dab footprint doesn't
    /// overlap the layer (early-out for the caller — typically `return
    /// vec![]`) or if there is no stroke context.
    ///
    /// The write region is the dab footprint (`position ± write_half`);
    /// the read region is the scratch-mirror snapshot footprint
    /// (`position ± read_half`). Read must be at least as large as write,
    /// but a brush that samples the scratch at an offset (smudge: per-dab
    /// `−motion`; clone: a stroke-scoped anchor) sizes the read region
    /// wider so the offset sample always lies inside the snapshot. For a
    /// symmetric dab pass equal write/read halves (e.g. `radius`,
    /// `radius`, `radius`, `radius`).
    ///
    /// The returned `DabFootprint`'s `origin/size` describe the write
    /// region (so the brush's render-pass viewport covers exactly the
    /// dab footprint); `copy_canvas_origin/copy_local_origin/copy_size`
    /// describe the (larger) read region, matching the read-mirror copy
    /// just issued.
    pub fn prepare_dab_canvas_copy(
        &mut self,
        position: [f32; 2],
        write_half_w: f32,
        write_half_h: f32,
        read_half_w: f32,
        read_half_h: f32,
    ) -> Option<DabFootprint> {
        debug_assert!(
            read_half_w >= write_half_w && read_half_h >= write_half_h,
            "read region must enclose write region",
        );
        let stroke = self.stroke.as_mut()?;
        let pt_canvas = stroke.paint_target.canvas_extent();

        let layer_x0 = pt_canvas.x0() as f32;
        let layer_y0 = pt_canvas.y0() as f32;
        let layer_x1 = layer_x0 + pt_canvas.width as f32;
        let layer_y1 = layer_y0 + pt_canvas.height as f32;

        // Write region (the dab footprint that the brush draws into).
        let unclipped_write_x0 = position[0] - write_half_w;
        let unclipped_write_y0 = position[1] - write_half_h;
        let write_x0 = unclipped_write_x0.max(layer_x0);
        let write_y0 = unclipped_write_y0.max(layer_y0);
        let write_x1 = (position[0] + write_half_w).min(layer_x1);
        let write_y1 = (position[1] + write_half_h).min(layer_y1);
        let quad_w = write_x1 - write_x0;
        let quad_h = write_y1 - write_y0;
        if quad_w <= 0.0 || quad_h <= 0.0 {
            return None;
        }

        // Read region (the scratch snapshot the brush samples from). Floor the
        // near edge and ceil the far edge before clamping to the extent, so
        // every fragment in the quad has a valid scratch read mirror texel and
        // `i32` origins stay representable (paste-extent / leftward-grown
        // layers).
        let copy = pt_canvas.clamp_f32(
            position[0] - read_half_w,
            position[1] - read_half_h,
            position[0] + read_half_w,
            position[1] + read_half_h,
        )?;
        let copy_canvas_x = copy.x0();
        let copy_canvas_y = copy.y0();
        let copy_w = copy.width;
        let copy_h = copy.height;

        // Save-point bbox tracks the write region — that's the only damage to
        // scratch. Canvas coords are stable across mid-stroke layer growth
        // (Storage Frame Rule). `quad_w/h > 0` above guarantees the snapped
        // footprint is non-empty, so the clamp can't be `None` here.
        let write_bbox = pt_canvas
            .clamp_f32(
                unclipped_write_x0,
                unclipped_write_y0,
                position[0] + write_half_w,
                position[1] + write_half_h,
            )
            .expect("write region has positive area (quad_w/h > 0), so its snapped bbox overlaps the extent");
        self.dab_batch.push_write_bbox(write_bbox);

        // The read mirror is filled from the stroke scratch, which is
        // layer-sized and indexed in layer-local pixels — translate
        // before issuing the copy.
        // Re-borrow stroke for the scratch copy (push_write_bbox above
        // took `&mut self.dab_batch`, which is disjoint from `self.stroke`).
        let stroke = self.stroke.as_mut().expect("stroke just observed Some");
        let copy_local_x = (copy_canvas_x - pt_canvas.x0()) as u32;
        let copy_local_y = (copy_canvas_y - pt_canvas.y0()) as u32;
        stroke.scratch.sync_read_mirror(
            self.device,
            &mut self.encoder,
            copy_local_x,
            copy_local_y,
            copy_w,
            copy_h,
        );

        Some(DabFootprint {
            layer_offset: [pt_canvas.x0(), pt_canvas.y0()],
            layer_size: [pt_canvas.width, pt_canvas.height],
            unclipped_origin: [unclipped_write_x0, unclipped_write_y0],
            origin: [write_x0, write_y0],
            size: [quad_w, quad_h],
            copy_canvas_origin: [copy_canvas_x, copy_canvas_y],
            copy_local_origin: [copy_local_x, copy_local_y],
            copy_size: [copy_w, copy_h],
        })
    }

    /// Snapshot the stroke scratch under `(origin_x, origin_y, w, h)` into
    /// the read mirror at `(0, 0)`, lazy-growing the read mirror first if
    /// the requested footprint exceeds its current size.  Idempotent per
    /// dab: the first caller issues `copy_texture_to_texture`; subsequent
    /// callers with matching origin are no-ops.  Mismatched origins (or a
    /// grow) force a fresh copy.
    ///
    /// Both `smudge_stamp` (canvas sampling) and `color_output` (Porter-Duff
    /// bg) need this, and both compute the same footprint from the same
    /// position — the cache prevents a redundant copy per dab.
    ///
    /// No-op in cursor-preview mode (no stroke resources).
    pub fn sync_scratch_read_mirror(
        &mut self,
        origin_x: u32,
        origin_y: u32,
        width: u32,
        height: u32,
    ) {
        if let Some(stroke) = self.stroke.as_mut() {
            stroke.scratch.sync_read_mirror(
                self.device,
                &mut self.encoder,
                origin_x,
                origin_y,
                width,
                height,
            );
        }
    }
}

/// Per-dab footprint produced by [`BrushGpuContext::prepare_dab_canvas_copy`].
///
/// Bundles every value brush terminals need to populate per-dab uniforms:
/// the layer-clipped quad in canvas coords, the layer-local origin of
/// the `scratch read mirror` snapshot the shader will read, and the layer's own
/// offset/size (for vertex NDC mapping against the layer-sized scratch
/// render target).
///
/// Coordinates are reported as `[x, y]` arrays so callers can name them
/// however reads best at the call site. `unclipped_origin` is the dab's
/// *pre-clip* top-left in canvas pixels — kept here because terminal
/// nodes that compute UVs for a stamp texture (color_output, watercolor)
/// derive `uv_min/uv_max` relative to the original (pre-clip) footprint.
#[derive(Copy, Clone, Debug)]
pub struct DabFootprint {
    /// `paint_target.offset_x/y` — layer's canvas-space offset.
    pub layer_offset: [i32; 2],
    /// `paint_target.width/height` — layer pixel dimensions.
    pub layer_size: [u32; 2],
    /// Dab footprint top-left in canvas pixels, *before* clipping to
    /// the layer extent.
    pub unclipped_origin: [f32; 2],
    /// Layer-clipped quad top-left in canvas pixels.
    pub origin: [f32; 2],
    /// Layer-clipped quad size in canvas pixels.
    pub size: [f32; 2],
    /// Integer canvas-space copy rect origin (`i32` — may be negative
    /// on paste-extent layers).
    pub copy_canvas_origin: [i32; 2],
    /// Layer-local origin of the `scratch read mirror` snapshot region (matches
    /// the `ensure_canvas_copy` source origin already issued). Use as
    /// the `copy_origin` uniform for shaders that read `scratch read mirror`.
    pub copy_local_origin: [u32; 2],
    /// `scratch read mirror` snapshot dimensions in pixels.
    pub copy_size: [u32; 2],
}