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
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
//! Framework for compiling brush graphs to a single WGSL fragment shader.
//!
//! At brush-load time, the compiler walks the existing `ExecutionPlan`
//! and asks each node to emit its WGSL contribution. The pieces are
//! concatenated into one shader that evaluates the whole graph per
//! fragment, per dab — no per-dab GPU dispatch, no inter-node textures.
//!
//! ## Two execution models, chosen per brush at the terminal
//!
//! A brush graph compiles its entire upstream chain into one fragment
//! shader per terminal — `shape`, `stamp`, `paint_color`, etc. fuse
//! inline, evaluated per-fragment-per-dab. No upstream per-dab GPU
//! dispatch happens.
//!
//! There is **no runtime fallback** and **no partial compilation**: a
//! brush must have every upstream node implement
//! [`crate::brush::eval::BrushNodeEvaluator::compile_wgsl`] successfully,
//! or brush load fails.
//!
//! ## The compiler walk
//!
//! 1. Topology-sort via the existing [`compile`](crate::nodegraph::compile)
//!    — same `ExecStep` order the runtime dispatch uses.
//! 2. For each step, build a [`CompileWgslCtx`] with input bindings
//!    resolved against upstream output expressions (or port defaults).
//! 3. Call `evaluator.compile_wgsl(&cctx)`; abort on `Err`.
//! 4. Concatenate `decls` into module scope, `body` into `fs_main`,
//!    collect `dab_fields` + `uniform_fields`.
//! 5. Emit the final shader: prelude + uniform/dab structs + decls +
//!    fs_main wrapper that calls the terminal's emitted body.
//!
//! ## Per-dab record schema
//!
//! Each node declares the per-dab fields it needs. The compiler packs
//! them in declaration order, fronted by an intrinsic header
//! (`pos`, `radius`) every terminal reads. CPU-side, each field's
//! `pack` closure writes its bytes from the evaluator's named outputs.
//! WGSL-side, the generated `DabRecord` struct mirrors the layout.
//!
//! ## Alignment
//!
//! `vec4`/`vec2` are emitted in alignment order (largest first) within
//! each contributor's block to avoid std430 padding surprises. The CPU
//! packer asserts the total byte count matches the expected stride.
//!
//! ## File map
//!
//! - [`type_system`] — `WgslType`, `DabField`, `UniformField` + std430
//!   layout helpers.
//! - [`context`] — `CompileWgslCtx`, `NodeWgsl`, `InputBinding`,
//!   `ShaderMode`.
//! - [`extent`] — `ExtentContribution` / `ExtentCtx` + the per-graph
//!   composition walk.
//! - [`intrinsics`] — `IntrinsicUniforms` `repr(C)` mirror of the
//!   WGSL prelude's struct, plus its packer.
//! - [`dab_record`] — fixed-prefix intrinsic dab header + its packer.

pub mod context;
pub mod dab_record;
pub mod extent;
pub mod intrinsics;
pub mod type_system;

pub use context::{CompileWgslCtx, InputBinding, NodeWgsl, ShaderMode};
pub use dab_record::{
    intrinsic_dab_header, pack_intrinsic_dab_header, INTRINSIC_DAB_HEADER_FIELDS,
};
pub use extent::{ExtentContribution, ExtentCtx};
pub use intrinsics::{pack_intrinsic_uniforms, IntrinsicUniforms, INTRINSIC_UNIFORMS_SIZE};
pub use type_system::{DabField, DabPacker, UniformField, UniformPacker, ValuePacker, WgslType};

use std::collections::{HashMap, HashSet};

use crate::brush::eval::BrushNodeEvaluator;
use crate::brush::wire::{BrushWireType, ScalarValue};
use crate::nodegraph::{ExecutionPlan, NodeId, PortDir, PortRef};

use self::dab_record::EPS_RADIUS_TARGET_PX;
use self::extent::compose_brush_extent;
use self::type_system::{compute_struct_size, compute_struct_size_for_uniforms};

/// Below this canvas-px bbox, the dab has effectively no extent and
/// `render_compiled_cursor_preview` early-returns rather than try to compute
/// a canvas-to-target scale.
const EPS_BBOX_CANVAS_PX: f32 = 1e-3;

// ── Compiled output ─────────────────────────────────────────────────────

/// A fully compiled brush graph: WGSL source + the schemas needed to
/// pack per-dab records and stroke-constant uniforms.
#[derive(Clone)]
pub struct CompiledBrush {
    /// Full WGSL source for the brush's stroke fragment shader.
    pub stroke_wgsl: String,
    /// Full WGSL source for the brush's preview (hover-cursor) fragment
    /// shader. Same dab / uniform layouts as `stroke_wgsl`; differs
    /// only in the outer skeleton (single-quad vertex stage, `sel =
    /// 1.0`, no `@group(2)` / `@group(3)` bindings). See
    /// [`ShaderMode`].
    pub cursor_preview_wgsl: String,
    /// Per-dab record layout, in declaration order. The compiler
    /// includes the intrinsic header fields ([`INTRINSIC_DAB_HEADER_FIELDS`])
    /// at the front; everything after is contributed by nodes.
    pub dab_layout: Vec<DabField>,
    /// Total per-dab record size in bytes (post-alignment padding).
    pub dab_record_size: usize,
    /// Stroke-constant uniform layout. Always includes the intrinsic
    /// terminal uniforms; node contributions follow.
    pub uniform_layout: Vec<UniformField>,
    /// Total uniform buffer size in bytes (post-padding).
    pub uniform_size: usize,
    /// Stable hash of the graph topology + relevant params, for
    /// pipeline caching.
    pub topology_hash: u64,
    /// Multiplier on per-dab `effective_radius` produced by composing
    /// every node's [`ExtentContribution`] over the graph. The
    /// terminal computes `bbox_target_px = effective_radius * factor +
    /// extra_px` and packs that into the dab record's intrinsic
    /// header. `1.0` for graphs with no shape-modulating upstream
    /// (the disc fallback). See [`ExtentContribution`] for the
    /// composition rules.
    pub brush_extent_factor: f32,
    /// Additive canvas-pixel padding produced by `AddCanvasPixels`
    /// contributions (displacement / warp nodes). `0.0` for the
    /// current node set.
    pub brush_extent_extra_px: f32,
    /// Names of textures sampled by `image`-style nodes in this
    /// graph, in `@group(3) @binding(1+N)` order. Empty for graphs
    /// without graph-texture nodes. Resolved against
    /// [`crate::gpu::texture_registry::TextureRegistry`] at
    /// pipeline-build time; deduplicated by the compiler so two
    /// nodes sampling the same paper texture share one binding.
    pub graph_texture_names: Vec<String>,
}

impl std::fmt::Debug for CompiledBrush {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CompiledBrush")
            .field("stroke_wgsl_bytes", &self.stroke_wgsl.len())
            .field("cursor_preview_wgsl_bytes", &self.cursor_preview_wgsl.len())
            .field("dab_record_size", &self.dab_record_size)
            .field("uniform_size", &self.uniform_size)
            .field("topology_hash", &self.topology_hash)
            .finish_non_exhaustive()
    }
}

/// Errors raised when a brush graph cannot compile to WGSL.
#[derive(Debug, Clone)]
pub enum CompileError {
    /// A node's `compile_wgsl` returned `Err`. Carries the node's
    /// `type_id` and the error message for diagnostics.
    NodeNotCompilable { type_id: String, reason: String },
    /// The graph has no terminal output node (nothing produces an
    /// `rgba` value to feed the fragment shader's return).
    NoTerminal,
}

impl std::fmt::Display for CompileError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NodeNotCompilable { type_id, reason } => {
                write!(f, "node `{type_id}` is not WGSL-compilable: {reason}")
            }
            Self::NoTerminal => {
                write!(f, "graph has no terminal node (nothing to render)")
            }
        }
    }
}

impl std::error::Error for CompileError {}

// ── The compiler ────────────────────────────────────────────────────────

/// Compile a graph + execution plan into a [`CompiledBrush`].
///
/// `plan` must already be a topologically-sorted execution plan for
/// `graph`. The compiler walks `plan.steps`; the last step's evaluator
/// is the terminal and is responsible for emitting the `return` line
/// in `body` (its `outputs` are unused).
pub fn compile_brush_to_wgsl(
    graph: &crate::nodegraph::Graph<BrushWireType>,
    plan: &ExecutionPlan,
    evaluators: &HashMap<String, Box<dyn BrushNodeEvaluator>>,
) -> Result<CompiledBrush, CompileError> {
    if plan.steps.is_empty() {
        return Err(CompileError::NoTerminal);
    }

    // Preview-time view of the graph: ports flagged with
    // `PortDef::preview_value` have their incoming wires dropped and
    // their `default` replaced with the preview constant. The stroke
    // pass walks the original graph; the preview pass reuses every
    // upstream node body but rebuilds the terminal step's
    // `CompileWgslCtx` against this clone so the emitted preview body
    // literalizes the preview defaults regardless of what the live
    // user-facing scrubs are set to. Encoding the override inside the
    // compiler (rather than at every caller) means the active-brush
    // compile and `regenerate_brush_cursor_preview_with_pen_internal` — which
    // intentionally do not pre-mutate the user's graph — still emit a
    // correct preview shader.
    let preview_graph = {
        let mut g = graph.clone();
        g.apply_preview_overrides();
        g
    };

    let mut decls = String::new();
    // Non-terminal node bodies — shared between the stroke and preview
    // skeletons (they're upstream of the terminal and don't depend on
    // selection or scratch/atlas bindings).
    let mut shared_body = String::new();
    // Terminal node bodies, captured per-mode. `stroke_terminal_body`
    // comes from the terminal's `compile_wgsl`; `preview_terminal_body`
    // comes from `compile_cursor_preview_body`. For terminals that don't
    // override the latter, both are the same source.
    let mut stroke_terminal_body = String::new();
    let mut preview_terminal_body = String::new();
    let mut dab_fields = intrinsic_dab_header();
    let mut uniform_fields: Vec<UniformField> = Vec::new();
    // Captured from the last (terminal) step. Spliced into the
    // stroke-mode assembled shader after the framework's three
    // intrinsic bind groups so the terminal can add its own bindings
    // (e.g. `watercolor`'s pickup atlas). Preview mode omits
    // these — the preview body doesn't sample scratch / atlas.
    let mut terminal_bindings = String::new();

    // Graph-texture names contributed by `image`-style nodes, in the
    // order each new name was first requested. Each node mutates
    // this through `CompileWgslCtx::request_texture`; sharing the
    // accumulator across the walk gives stable, dedup'd slot indices
    // so two nodes sampling the same paper share a binding.
    let graph_textures_cell: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());

    // Track each output port's emitted expression so downstream nodes
    // can substitute.
    let mut output_exprs: HashMap<PortRef, String> = HashMap::new();

    // Reverse map: slot index → PortRef. Built up as we walk steps in
    // topological order — every wire's source must already exist when
    // we encounter the dest.
    let mut slot_to_port: HashMap<usize, PortRef> = HashMap::new();

    // Pre-pass: collect every PortRef that's consumed by some
    // downstream input. Nodes use this to skip emitting dab_fields /
    // expressions for ports nothing references.
    let consumed_sources: HashSet<PortRef> = plan
        .steps
        .iter()
        .flat_map(|s| s.input_slots.iter())
        .map(|sl| sl.source.clone())
        .collect();

    for step in &plan.steps {
        let evaluator =
            evaluators
                .get(&step.type_id)
                .ok_or_else(|| CompileError::NodeNotCompilable {
                    type_id: step.type_id.clone(),
                    reason: "no evaluator registered".into(),
                })?;

        // Resolve inputs from the slot table built so far.
        let mut inputs: HashMap<String, InputBinding> = HashMap::new();
        let node = graph
            .nodes()
            .get(&step.node_id)
            .expect("plan step references existing node");
        for slot_info in &step.input_slots {
            let src_port = slot_to_port.get(&slot_info.slot).cloned().or_else(|| {
                // Fall back to looking up the source from input_slots.source.
                output_exprs
                    .keys()
                    .find(|pr| **pr == slot_info.source)
                    .cloned()
            });
            let Some(src_port) = src_port else {
                continue;
            };
            let Some(expr) = output_exprs.get(&src_port).cloned() else {
                continue;
            };
            let remapped =
                apply_wire_remap(expr, &src_port, step.node_id, &slot_info.port_name, graph);
            inputs.insert(slot_info.port_name.clone(), InputBinding::Wired(remapped));
        }

        // Curve LUT (only present on nodes with a Curve param).
        let lut: Option<crate::brush::curve_math::CurveLut> =
            node.params.iter().find_map(|p| match p {
                crate::gpu::params::ParamValue::Curve(pts) if pts.len() >= 2 => {
                    Some(crate::brush::curve_math::CurveLut::from_points(pts))
                }
                _ => None,
            });

        // Collect this node's consumed output port names.
        let consumed_outputs: HashSet<String> = consumed_sources
            .iter()
            .filter(|pr| pr.node == step.node_id)
            .map(|pr| pr.port.clone())
            .collect();

        // For terminal steps, build the preview-mode cctx alongside the
        // stroke-mode one so `compile_cursor_preview_body` sees the overridden
        // port defaults. Non-terminals never have `compile_cursor_preview_body`
        // called, so we skip the extra work.
        let preview_cctx_parts = if step.is_terminal {
            let preview_node = preview_graph
                .nodes()
                .get(&step.node_id)
                .expect("preview-graph clone has the same node set as the original");
            // Drop wired bindings the override removed — those ports
            // must fall through to the preview-overridden defaults in
            // the cloned graph.
            let preview_inputs: HashMap<String, InputBinding> = inputs
                .iter()
                .filter(|(port_name, _)| {
                    preview_graph
                        .connections
                        .iter()
                        .any(|c| c.to.node == step.node_id && &c.to.port == *port_name)
                })
                .map(|(k, v)| (k.clone(), v.clone()))
                .collect();
            Some((preview_node, preview_inputs, consumed_outputs.clone()))
        } else {
            None
        };

        let cctx = CompileWgslCtx {
            node_id: step.node_id,
            params: &node.params,
            port_defs: &node.ports,
            inputs,
            lut: lut.as_ref(),
            consumed_outputs,
            graph_textures: &graph_textures_cell,
        };

        let result =
            evaluator
                .compile_wgsl(&cctx)
                .map_err(|reason| CompileError::NodeNotCompilable {
                    type_id: step.type_id.clone(),
                    reason,
                })?;

        if !result.decls.is_empty() {
            decls.push_str(&result.decls);
            if !result.decls.ends_with('\n') {
                decls.push('\n');
            }
        }
        let is_terminal = step.is_terminal;
        if !result.body.is_empty() {
            // Terminal bodies stay in their per-mode buckets; non-terminal
            // bodies are spliced into both modes.
            let target = if is_terminal {
                &mut stroke_terminal_body
            } else {
                &mut shared_body
            };
            target.push_str(&result.body);
            if !result.body.ends_with('\n') {
                target.push('\n');
            }
        }
        if is_terminal {
            // Preview body — call the terminal's preview-mode hook. The
            // default delegate returns the same NodeWgsl as `compile_wgsl`
            // (paint's stroke and preview bodies share one
            // source); watercolor/smudge/liquify override to emit a
            // neutral-color body that doesn't reference `@group(3)`.
            //
            // The preview cctx wraps the cloned graph (preview overrides
            // applied) so any `cctx.input(name).as_f32()` for a flagged
            // port literalizes the preview constant — regardless of
            // whether the caller pre-applied the override on the graph
            // they handed in.
            //
            // Only the `body` field is consumed here — decls / dab_fields
            // / uniform_fields / outputs / terminal_bindings are already
            // accumulated from the stroke pass and shared across modes
            // (helper functions a preview body references — e.g. liquify's
            // `falloff_fn` — live in `decls` and are visible to both
            // skeletons).
            let (preview_node, preview_inputs, preview_consumed) =
                preview_cctx_parts.expect("preview_cctx_parts built above for every terminal step");
            let preview_cctx = CompileWgslCtx {
                node_id: step.node_id,
                params: &preview_node.params,
                port_defs: &preview_node.ports,
                inputs: preview_inputs,
                lut: lut.as_ref(),
                consumed_outputs: preview_consumed,
                graph_textures: &graph_textures_cell,
            };
            let preview_result = evaluator
                .compile_cursor_preview_body(&preview_cctx)
                .map_err(|reason| CompileError::NodeNotCompilable {
                    type_id: step.type_id.clone(),
                    reason,
                })?;
            if !preview_result.body.is_empty() {
                preview_terminal_body.push_str(&preview_result.body);
                if !preview_result.body.ends_with('\n') {
                    preview_terminal_body.push('\n');
                }
            }
        }
        dab_fields.extend(result.dab_fields);
        uniform_fields.extend(result.uniform_fields);
        if !result.terminal_bindings.is_empty() {
            if !terminal_bindings.is_empty() {
                terminal_bindings.push('\n');
            }
            terminal_bindings.push_str(&result.terminal_bindings);
        }

        // Register this node's outputs so downstream nodes can resolve
        // their wires.
        for (port_name, slot_idx) in &step.output_slots {
            let pr = PortRef {
                node: step.node_id,
                port: port_name.clone(),
            };
            slot_to_port.insert(*slot_idx, pr.clone());
            if let Some(expr) = result.outputs.get(port_name) {
                output_exprs.insert(pr, expr.clone());
            }
        }
    }

    // Sort node-contributed dab fields by alignment-descending so
    // the std430 layout has no internal padding. The intrinsic
    // header (first `INTRINSIC_DAB_HEADER_FIELDS` entries) is
    // already aligned and stays at the front. Stable sort preserves
    // declaration order within an alignment class so individual
    // nodes' packers still see their fields in the order they
    // emitted them.
    {
        let (head, tail) = dab_fields.split_at_mut(INTRINSIC_DAB_HEADER_FIELDS);
        let _ = head;
        tail.sort_by_key(|f| std::cmp::Reverse(f.ty.align()));
    }
    // Same treatment for uniforms.
    uniform_fields.sort_by_key(|f| std::cmp::Reverse(f.ty.align()));

    // Compute per-dab record size with std430-aware alignment.
    let dab_record_size = compute_struct_size(&dab_fields);
    let uniform_size = compute_struct_size_for_uniforms(&uniform_fields);

    // Assemble the two shader variants. The non-terminal body splice
    // is identical for stroke and preview; the terminal body differs
    // (and preview drops `@group(2)` selection and `@group(3)`
    // terminal bindings).
    let stroke_body = format!("{shared_body}{stroke_terminal_body}");
    let preview_body = format!("{shared_body}{preview_terminal_body}");
    let graph_texture_names = graph_textures_cell.into_inner();
    // `@group(3)` collision check. Terminal `terminal_bindings`
    // (e.g. watercolor's pickup atlas) and the `image` node's
    // graph textures both target group 3 — the highest slot WebGPU's
    // default `max_bind_groups = 4` permits. Mixing the two would
    // need a different binding scheme (pack into a single bind
    // group with non-overlapping @binding indices, or request a
    // higher device limit). Reject the combination now so the
    // failure mode is "brush won't load" rather than a runtime
    // shader-binding mismatch.
    if !terminal_bindings.is_empty() && !graph_texture_names.is_empty() {
        return Err(CompileError::NodeNotCompilable {
            type_id: "image".into(),
            reason: format!(
                "graph combines an `image` node with a terminal that owns @group(3) \
                 bindings ({} requested); this combination is not yet supported",
                graph_texture_names.join(", ")
            ),
        });
    }
    let stroke_wgsl = assemble_shader(
        ShaderMode::Stroke,
        &dab_fields,
        &uniform_fields,
        &decls,
        &stroke_body,
        &terminal_bindings,
        &graph_texture_names,
    );
    let cursor_preview_wgsl = assemble_shader(
        ShaderMode::CursorPreview,
        &dab_fields,
        &uniform_fields,
        &decls,
        &preview_body,
        "",
        &graph_texture_names,
    );

    // Topology hash: stable across runs (uses DefaultHasher; if process
    // stability becomes an issue we can switch to xxhash).
    let topology_hash = hash_graph_topology(graph);

    let (brush_extent_factor, brush_extent_extra_px) =
        compose_brush_extent(graph, plan, evaluators);

    Ok(CompiledBrush {
        stroke_wgsl,
        cursor_preview_wgsl,
        dab_layout: dab_fields,
        dab_record_size,
        uniform_layout: uniform_fields,
        uniform_size,
        topology_hash,
        brush_extent_factor,
        brush_extent_extra_px,
        graph_texture_names,
    })
}

/// Pack one dab's worth of per-node values into the byte buffer the
/// terminal will upload as a storage-buffer element. The terminal
/// writes the intrinsic header (first [`INTRINSIC_DAB_HEADER_FIELDS`]
/// fields, 16 bytes) itself first, then calls this to append per-node
/// fields, then pads the buffer up to `dab_record_size`.
///
/// Each node's `compile_wgsl` is required to declare fields in
/// alignment-descending order within its contribution, and to have
/// each field's `pack` closure write exactly `field.ty.size()` bytes.
/// With those invariants this function is a straight iteration — no
/// runtime alignment dance.
pub fn pack_dab_record(
    compiled: &CompiledBrush,
    outputs: &HashMap<String, ScalarValue>,
    bytes: &mut Vec<u8>,
) {
    for field in compiled.dab_layout.iter().skip(INTRINSIC_DAB_HEADER_FIELDS) {
        let before = bytes.len();
        (field.pack)(outputs, bytes);
        debug_assert_eq!(
            bytes.len() - before,
            field.ty.size(),
            "DabField `{}` packer wrote {} bytes, expected {}",
            field.name,
            bytes.len() - before,
            field.ty.size(),
        );
    }
}

/// Pack the node-contributed portion of the uniform buffer. The
/// terminal packs the intrinsic header (`IntrinsicUniforms`) itself
/// before calling this.
pub fn pack_uniforms(
    compiled: &CompiledBrush,
    outputs: &HashMap<String, ScalarValue>,
    bytes: &mut Vec<u8>,
) {
    for field in &compiled.uniform_layout {
        let before = bytes.len();
        (field.pack)(outputs, bytes);
        debug_assert_eq!(
            bytes.len() - before,
            field.ty.size(),
            "UniformField `{}` packer wrote {} bytes, expected {}",
            field.name,
            bytes.len() - before,
            field.ty.size(),
        );
    }
}

/// Shared compiled-brush preview render path. Sized, packed, and
/// dispatched identically across paint / watercolor / smudge /
/// liquify — the only caller-supplied difference is `effective_radius`.
/// Rotation lives entirely in the rendered mask (via the skeleton's
/// `theta - view_rotation` and any wired `shape.rotation_input`), so
/// the overlay quad samples a pre-oriented mask without a CPU-side
/// rotation. Returns `Some(())` on success, `None` when the brush has
/// no compiled state or the preview mask refuses to allocate.
///
/// What this does:
/// 1. Grows the preview mask to fit `radius × brush_extent_factor +
///    brush_extent_extra_px` (rounded to the next power of two).
/// 2. Packs the intrinsic uniform header — `cursor_preview_centre` /
///    `cursor_preview_size` set live; `layer_offset` / `layer_size` /
///    `canvas_size` aliased to the preview mask so any node that
///    reads them in its `compile_wgsl` body sees a sane (mask-sized)
///    target.
/// 3. Packs node-contributed uniforms via [`pack_uniforms`].
/// 4. Packs one dab record at the preview centre — intrinsic header
///    (`pos`, `bbox_target_px`, `inv_radius_target_px`) plus node-
///    contributed dab fields via [`pack_dab_record`].
/// 5. Calls [`crate::brush::pipeline::BrushPipelines::render_preview`]
///    against the shared preview pipeline cache.
/// 6. Publishes [`crate::brush::eval::BrushCursorPreviewInfo`] for the
///    overlay's `KIND_MASKED_STAMP` primitive to consume.
pub fn render_compiled_cursor_preview(
    gpu: &mut crate::brush::gpu_context::BrushGpuContext,
    radius: f32,
) -> Option<()> {
    let compiled = gpu.dab_batch.compiled_brush.clone()?;
    // Brush-intrinsic bbox in canvas pixels — this is the dab's
    // footprint as it will be deposited on the canvas, and what the
    // overlay quad consumes via `half_extent_canvas_px` below.
    let bbox_canvas_px = radius * compiled.brush_extent_factor + compiled.brush_extent_extra_px;
    let (target_view, target_w, target_h) = gpu.ensure_cursor_preview_mask(bbox_canvas_px)?;
    if target_w == 0 || target_h == 0 || bbox_canvas_px < EPS_BBOX_CANVAS_PX {
        return None;
    }

    // Map canvas-px intrinsic frame → texel frame. The dab's bbox
    // unconditionally fills the inscribed half-side of the preview
    // mask; the radius scales by the same ratio so the fragment's
    // `local_uv = local * inv_radius_target_px` is dimensionless and
    // matches the value the stroke pass would produce at the same
    // intrinsic point. The overlay's displayed quad still spans
    // `±bbox_canvas_px`, so UV [0, 1] across that quad maps to UV [0, 1]
    // across the dab content in the mask.
    let texture_half = (target_w.min(target_h) as f32) * 0.5;
    let canvas_to_target = texture_half / bbox_canvas_px;
    let bbox_target_px = texture_half;
    let radius_target_px = (radius * canvas_to_target).max(EPS_RADIUS_TARGET_PX);
    let cursor_preview_centre = [target_w as f32 * 0.5, target_h as f32 * 0.5];

    // Pack the uniform buffer: intrinsic header first, node-contributed
    // uniforms after. Intrinsic field list lives on `BrushGpuContext` so
    // adding a future global field (e.g. `view_rotation`) edits one
    // helper, not every terminal + this preview path.
    let intrinsic = gpu.intrinsic_preview_header(target_w, target_h, cursor_preview_centre);
    let total_uniform_size = INTRINSIC_UNIFORMS_SIZE + compiled.uniform_size;
    let mut uniform_bytes: Vec<u8> = Vec::with_capacity(total_uniform_size);
    pack_intrinsic_uniforms(&mut uniform_bytes, intrinsic);
    let empty_outputs;
    let outputs = match gpu.dab_batch.slot_outputs.as_ref() {
        Some(o) => o,
        None => {
            empty_outputs = HashMap::new();
            &empty_outputs
        }
    };
    pack_uniforms(&compiled, outputs, &mut uniform_bytes);
    if uniform_bytes.len() < total_uniform_size {
        uniform_bytes.resize(total_uniform_size, 0);
    }

    // Pack the single preview dab record: intrinsic header + node
    // fields. The header is in *target-pixel* space (preview mask
    // texels), so the vertex/fragment math is unit-coherent against
    // the preview target without needing any mode awareness.
    let mut dab_bytes: Vec<u8> = Vec::with_capacity(compiled.dab_record_size);
    pack_intrinsic_dab_header(
        &mut dab_bytes,
        cursor_preview_centre,
        bbox_target_px,
        radius_target_px,
    );
    pack_dab_record(&compiled, outputs, &mut dab_bytes);
    if dab_bytes.len() < compiled.dab_record_size {
        dab_bytes.resize(compiled.dab_record_size, 0);
    }

    gpu.pipelines.render_preview(
        gpu.device,
        gpu.queue,
        &mut gpu.encoder,
        &compiled,
        &target_view,
        (target_w, target_h),
        &uniform_bytes,
        &dab_bytes,
    );

    // The overlay consumer expects canvas px — its displayed quad
    // spans `±half_extent_canvas_px`, and the mask sampler maps
    // UV [0, 1] across the quad. With the dab filling the mask's
    // inscribed disc by construction (above), this matches.
    if let Some(preview) = gpu.preview.as_mut() {
        preview.info = Some(crate::brush::eval::BrushCursorPreviewInfo {
            half_extent_canvas_px: [bbox_canvas_px, bbox_canvas_px],
        });
    }
    Some(())
}

// ── Helpers ─────────────────────────────────────────────────────────────

/// Wire-boundary scalar remap, mirroring [`crate::brush::eval`]'s
/// `remap_for_wire` but emitted as a WGSL expression. When both ends of
/// a connection declare `natural_range`, we wrap the source expression
/// in an affine map from src to dst range. Otherwise the expression
/// passes through.
fn apply_wire_remap(
    expr: String,
    source: &PortRef,
    dest_node: NodeId,
    dest_port: &str,
    graph: &crate::nodegraph::Graph<BrushWireType>,
) -> String {
    let src_range = graph
        .nodes()
        .get(&source.node)
        .and_then(|n| {
            n.ports
                .iter()
                .find(|p| p.name == source.port && p.dir == PortDir::Output)
        })
        .and_then(|p| p.natural_range);
    let dst_range = graph
        .nodes()
        .get(&dest_node)
        .and_then(|n| {
            n.ports
                .iter()
                .find(|p| p.name == dest_port && p.dir == PortDir::Input)
        })
        .and_then(|p| p.natural_range);
    let (Some((src_min, src_max)), Some((dst_min, dst_max))) = (src_range, dst_range) else {
        return expr;
    };
    if (src_min - dst_min).abs() < 1e-6 && (src_max - dst_max).abs() < 1e-6 {
        return expr;
    }
    let denom = src_max - src_min;
    if denom.abs() < 1e-6 {
        return format!("{:.6}", dst_min);
    }
    let scale = (dst_max - dst_min) / denom;
    let bias = dst_min - src_min * scale;
    // `(expr) * scale + bias`
    format!("(({}) * {:.6} + {:.6})", expr, scale, bias)
}

fn hash_graph_topology(graph: &crate::nodegraph::Graph<BrushWireType>) -> u64 {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    let mut hasher = DefaultHasher::new();
    let mut node_ids: Vec<_> = graph.nodes().keys().copied().collect();
    node_ids.sort_by_key(|n| n.0);
    for id in &node_ids {
        let node = &graph.nodes()[id];
        id.0.hash(&mut hasher);
        node.type_id.hash(&mut hasher);
        // Hash params by serialising — order is stable; values that
        // affect compilation (algorithm enum, curve points) end up in
        // the hash.
        if let Ok(s) = serde_json::to_string(&node.params) {
            s.hash(&mut hasher);
        }
        for port in &node.ports {
            port.name.hash(&mut hasher);
            port.default.to_bits().hash(&mut hasher);
        }
    }
    let mut conns: Vec<_> = graph.connections.iter().collect();
    conns.sort_by_key(|c| {
        (
            c.from.node.0,
            c.from.port.clone(),
            c.to.node.0,
            c.to.port.clone(),
        )
    });
    for c in conns {
        c.from.node.0.hash(&mut hasher);
        c.from.port.hash(&mut hasher);
        c.to.node.0.hash(&mut hasher);
        c.to.port.hash(&mut hasher);
    }
    hasher.finish()
}

// ── Shader assembly ─────────────────────────────────────────────────────

fn assemble_shader(
    mode: ShaderMode,
    dab_fields: &[DabField],
    uniform_fields: &[UniformField],
    node_decls: &str,
    fs_body: &str,
    terminal_bindings: &str,
    graph_texture_names: &[String],
) -> String {
    let mut out = String::new();
    // Shared canvas-window helpers (plane_to_selection_uv) — WGSL has no
    // `#include`, so prepend the lib ahead of the assembled brush shader.
    out.push_str(crate::gpu::canvas_lib::CANVAS_LIB);
    out.push('\n');
    out.push_str(include_str!("../../../shaders/brush/_shape.wgsl"));
    out.push('\n');
    out.push_str(include_str!("../../../shaders/brush/_noise.wgsl"));
    out.push('\n');
    out.push_str(include_str!("../../../shaders/brush/_prelude.wgsl"));
    out.push('\n');

    // Generated DabRecord struct.
    out.push_str("struct DabRecord {\n");
    for f in dab_fields {
        out.push_str(&format!("    {}: {},\n", f.name, f.ty.wgsl_name()));
    }
    out.push_str("};\n\n");

    // Generated Uniforms struct (always has the intrinsic terminal
    // uniforms, defined in _prelude.wgsl as
    // `IntrinsicUniforms`).
    if uniform_fields.is_empty() {
        out.push_str("struct Uniforms {\n");
        out.push_str("    intrinsic: IntrinsicUniforms,\n");
        out.push_str("};\n\n");
    } else {
        out.push_str("struct Uniforms {\n");
        out.push_str("    intrinsic: IntrinsicUniforms,\n");
        for f in uniform_fields {
            out.push_str(&format!("    {}: {},\n", f.name, f.ty.wgsl_name()));
        }
        out.push_str("};\n\n");
    }

    // Bind groups: group(0) = uniforms (both modes), group(1) = dabs
    // storage (both modes). In stroke mode group(2) = selection and
    // optional terminal `@group(3)` bindings. Preview mode omits both
    // — the skeleton hard-codes `sel = 1.0` and the preview body never
    // samples scratch / atlas.
    out.push_str("@group(0) @binding(0) var<uniform> u: Uniforms;\n");
    out.push_str("@group(1) @binding(0) var<storage, read> dabs: array<DabRecord>;\n");
    if mode == ShaderMode::Stroke {
        out.push_str("@group(2) @binding(0) var sel_tex: texture_2d<f32>;\n");
        out.push_str("@group(2) @binding(1) var sel_smp: sampler;\n");
        if !terminal_bindings.is_empty() {
            out.push_str(terminal_bindings);
            if !terminal_bindings.ends_with('\n') {
                out.push('\n');
            }
        }
    }
    // `@group(3)` — named graph textures (`image` nodes). WebGPU's
    // default `max_bind_groups` is 4 (groups 0..=3), so this is the
    // highest slot a shader can use without requesting non-default
    // device limits. Declared in *both* shader variants so the same
    // node WGSL works in stroke and preview without the node knowing
    // which mode it's compiling into; the preview pipeline cache
    // points at the same registry-owned textures the stroke pipeline
    // uses, so cursor thumbnails sample the paper grain too.
    //
    // Group 3 is the same slot terminals like watercolor use for
    // their own `terminal_bindings` (pickup atlas). The compile walk
    // rejects graphs that try to claim both — see the early-return
    // check in [`compile_brush_to_wgsl`].
    if !graph_texture_names.is_empty() {
        out.push_str("@group(3) @binding(0) var graph_smp: sampler;\n");
        for (i, _) in graph_texture_names.iter().enumerate() {
            out.push_str(&format!(
                "@group(3) @binding({}) var graph_tex_{}: texture_2d<f32>;\n",
                1 + i,
                i
            ));
        }
    }
    out.push('\n');

    // Node-level declarations (helper functions, const arrays).
    out.push_str(node_decls);
    out.push('\n');

    // Vertex stage — paint.wgsl-style instanced quad in stroke mode,
    // single quad at `dab.pos ± dab.bbox_target_px` mapped into the
    // preview-mask viewport in preview mode.
    match mode {
        ShaderMode::Stroke => out.push_str(STROKE_VERTEX_STAGE_WGSL),
        ShaderMode::CursorPreview => out.push_str(PREVIEW_VERTEX_STAGE_WGSL),
    }
    out.push('\n');

    // Fragment stage — header binds the fragment-local helpers, then
    // splices in the node bodies, then ends with the terminal's
    // `return` line (emitted into `fs_body`). The `sel` binding line
    // differs between modes: stroke samples a real texture, preview
    // hard-codes 1.0 (the full footprint, ignoring any active
    // selection — matches master's preview behavior).
    out.push_str("@fragment\n");
    out.push_str("fn fs_main(in: VsOut) -> @location(0) vec4<f32> {\n");
    out.push_str("    let d = dabs[in.dab_idx];\n");
    // `target_pos` is in the target texture's pixel space — canvas px
    // for stroke (target ≡ canvas), preview-mask texels for preview.
    // `d.pos` / `d.bbox_target_px` / `d.inv_radius_target_px` live in
    // the same frame, so `local` is unit-coherent regardless of mode.
    out.push_str("    let target_pos = in.target_pos;\n");
    out.push_str("    let local = target_pos - d.pos;\n");
    out.push_str("    let local_dist_px = length(local);\n");
    out.push_str("    if (local_dist_px >= d.bbox_target_px) {\n");
    out.push_str("        discard;\n");
    out.push_str("    }\n");
    out.push_str("    let local_uv = local * d.inv_radius_target_px;\n");
    out.push_str("    let local_dist = length(local_uv);\n");
    // Brush stamp rotation counteracts view rotation: shape nodes use
    // `theta - p.rotation`, so subtracting `view_rotation` from `theta`
    // here makes the shader render the stamp at canvas-rotation
    // `p.rotation + view_rotation`. The present shader's canvas → screen
    // rotation (which subtracts `view_rotation` again, per
    // `ViewTransform::from_pan_zoom_rotate`) lands the stamp at on-
    // screen rotation `p.rotation` — invariant under view rotation.
    // The fix is at this one line: every existing and future shape
    // node consuming `theta` is screen-relative without further code.
    out.push_str("    let theta = atan2(local_uv.y, local_uv.x) - u.intrinsic.view_rotation;\n");
    out.push_str("    let canvas_size = vec2<f32>(\n");
    out.push_str("        f32(u.intrinsic.canvas_size.x),\n");
    out.push_str("        f32(u.intrinsic.canvas_size.y),\n");
    out.push_str("    );\n");
    out.push_str("    let canvas_origin = vec2<f32>(\n");
    out.push_str("        f32(u.intrinsic.canvas_origin.x),\n");
    out.push_str("        f32(u.intrinsic.canvas_origin.y),\n");
    out.push_str("    );\n");
    match mode {
        // Stroke: `target_pos` is a plane position; the window-anchored
        // selection mask maps via `(target_pos - canvas_origin) / canvas_size`
        // (see shaders/lib/canvas.wgsl).
        ShaderMode::Stroke => out.push_str(
            "    let sel = textureSampleLevel(sel_tex, sel_smp, plane_to_selection_uv(target_pos, canvas_origin, canvas_size), 0.0).r;\n",
        ),
        ShaderMode::CursorPreview => out.push_str("    let sel: f32 = 1.0;\n"),
    }
    out.push_str(fs_body);
    out.push_str("}\n");

    out
}

/// Stroke-mode vertex stage — instanced quad per dab, mapped against
/// the layer's NDC viewport. Used by every compiled brush in stroke
/// mode. Includes `VsOut` + `quad_corner` since the preview vertex
/// stage uses them too and we splice exactly one of the two stages
/// into each assembled shader.
const STROKE_VERTEX_STAGE_WGSL: &str = r#"
struct VsOut {
    @builtin(position) clip:        vec4<f32>,
    @location(0) target_pos:        vec2<f32>,
    @location(1) @interpolate(flat) dab_idx: u32,
};

fn quad_corner(vi: u32) -> vec2<f32> {
    var corners = array<vec2<f32>, 6>(
        vec2<f32>(0.0, 0.0),
        vec2<f32>(1.0, 0.0),
        vec2<f32>(0.0, 1.0),
        vec2<f32>(1.0, 0.0),
        vec2<f32>(1.0, 1.0),
        vec2<f32>(0.0, 1.0),
    );
    return corners[vi];
}

@vertex
fn vs_main(
    @builtin(vertex_index)   vi: u32,
    @builtin(instance_index) ii: u32,
) -> VsOut {
    let dab = dabs[ii];
    let corner = quad_corner(vi);
    // `dab.bbox_target_px` is the dab's bbox half-extent in the target's
    // pixel space (stroke target ≡ canvas px). The fragment stage
    // discards past the same bound, so the quad covers exactly what
    // the shader can write — no waste, no clipping. The CPU side packs
    // the same value into the dab record and uses it for the
    // layer-clip bbox, so the save-point system tracks the same
    // footprint the shader writes.
    let quad_half = dab.bbox_target_px;
    let target_pos = dab.pos + (corner * 2.0 - vec2<f32>(1.0, 1.0)) * quad_half;
    let layer_offset = u.intrinsic.layer_offset;
    let layer_size = u.intrinsic.layer_size;
    let local = target_pos - vec2<f32>(f32(layer_offset.x), f32(layer_offset.y));
    let layer_w = f32(layer_size.x);
    let layer_h = f32(layer_size.y);
    let clip = vec2<f32>(
        local.x / layer_w * 2.0 - 1.0,
        1.0 - local.y / layer_h * 2.0,
    );
    var out: VsOut;
    out.clip       = vec4<f32>(clip, 0.0, 1.0);
    out.target_pos = target_pos;
    out.dab_idx    = ii;
    return out;
}
"#;

/// Preview-mode vertex stage — single quad centred at
/// `u.intrinsic.cursor_preview_centre`, mapped against the preview mask's
/// NDC viewport (`u.intrinsic.cursor_preview_size`). The fragment shader
/// reads `dabs[0]` for the (single) record's pose; the per-fragment
/// math is unchanged from stroke mode. Repeats the `VsOut` /
/// `quad_corner` declarations so the two vertex stages are
/// drop-in alternatives — assemble_shader splices exactly one.
const PREVIEW_VERTEX_STAGE_WGSL: &str = r#"
struct VsOut {
    @builtin(position) clip:        vec4<f32>,
    @location(0) target_pos:        vec2<f32>,
    @location(1) @interpolate(flat) dab_idx: u32,
};

fn quad_corner(vi: u32) -> vec2<f32> {
    var corners = array<vec2<f32>, 6>(
        vec2<f32>(0.0, 0.0),
        vec2<f32>(1.0, 0.0),
        vec2<f32>(0.0, 1.0),
        vec2<f32>(1.0, 0.0),
        vec2<f32>(1.0, 1.0),
        vec2<f32>(0.0, 1.0),
    );
    return corners[vi];
}

@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
    let dab = dabs[0];
    let corner = quad_corner(vi);
    // Read `dab.pos` instead of `u.intrinsic.cursor_preview_centre` so the
    // dab record is the single source of truth for positioning. The
    // CPU side packs `pos = cursor_preview_centre`, making the two equivalent
    // by construction, but threading through `dab.pos` keeps the
    // vertex structurally identical to stroke's modulo the clip-space
    // mapping — the invariant is the same: target-space pos, bbox in
    // target px.
    let target_pos = dab.pos + (corner * 2.0 - vec2<f32>(1.0, 1.0)) * dab.bbox_target_px;
    let cursor_preview_size_f = vec2<f32>(
        f32(u.intrinsic.cursor_preview_size.x),
        f32(u.intrinsic.cursor_preview_size.y),
    );
    let clip = vec2<f32>(
        target_pos.x / cursor_preview_size_f.x * 2.0 - 1.0,
        1.0 - target_pos.y / cursor_preview_size_f.y * 2.0,
    );
    var out: VsOut;
    out.clip       = vec4<f32>(clip, 0.0, 1.0);
    out.target_pos = target_pos;
    out.dab_idx    = 0u;
    return out;
}
"#;