diff --git a/.github/workflows/docs-artifact.yml b/.github/workflows/docs-artifact.yml new file mode 100644 index 00000000..e33dbeec --- /dev/null +++ b/.github/workflows/docs-artifact.yml @@ -0,0 +1,101 @@ +name: Docs artifact + +# Publishes the documentation artifact for a release: the hand-written manual +# under `docs/manual/` plus `metadata.json`, the projection of every registry +# Darkly has. Both go in one tarball under one `version`, so a consumer can +# never pair prose from one build with metadata from another. +# +# The consumer is pull-based and is not named here on purpose — this repo is +# public and knows nothing about who reads the artifact. Whoever wants it +# resolves this asset off the release and unpacks it. +# +# `docs/manual/` is **pure markdown**. It must not reference components from any +# consumer, or the public repo acquires an invisible dependency on private code. +# Anything needing a component is a generated page on the consumer side. +# +# No GPU and no `render-docs` here: `export-docs` builds from `&'static` +# registration data alone. Rendered previews are a separate, later artifact. +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: >- + Existing release tag to attach the artifact to. Leave blank to build + and upload for inspection without touching any release. + required: false + +jobs: + docs-artifact: + name: export-docs + manual → release asset + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # Full checkout (with .git) so build.rs's `git describe --tags` resolves — + # that string is what stamps `version`, and a shallow checkout breaks it. + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Install fontconfig (native font enumeration via fontique) + run: | + sudo apt-get update + sudo apt-get install -y libfontconfig1-dev + - uses: dtolnay/rust-toolchain@stable + + - name: Export registry metadata + run: cargo run -p darkly --bin export-docs -- --out out/metadata.json + + # An icon name is not self-describing, and `local:` icons exist nowhere but + # this repo — so the artifact carries resolved SVG rather than asking a + # consumer to own an icon toolchain. Needs the frontend's @iconify deps. + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: npm ci + working-directory: frontend + run: npm ci + - name: Resolve icons + run: node frontend/scripts/export-doc-icons.mjs --metadata out/metadata.json --out out/icons.json + + - name: Stage the manual + run: cp -r docs/manual out/manual + + # Which release this ends up on: the pushed tag, or the one a manual run + # names. A dispatch with no tag names nothing and stops after the upload + # below — a real dry run of the export that touches no release. + - name: Resolve target release + env: + INPUT_TAG: ${{ inputs.tag }} + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + echo "TARGET=$GITHUB_REF_NAME" >> "$GITHUB_ENV" + else + echo "TARGET=$INPUT_TAG" >> "$GITHUB_ENV" + fi + + - name: Pack + run: | + NAME="darkly-docs-${TARGET:-${GITHUB_REF_NAME//\//-}}.tar.gz" + echo "ARTIFACT=$NAME" >> "$GITHUB_ENV" + tar czf "$NAME" -C out . + tar tzf "$NAME" + + - name: Upload for inspection + uses: actions/upload-artifact@v4 + with: + name: docs-artifact + path: ${{ env.ARTIFACT }} + + # The release object for a tag is produced outside this repo, so this job + # may run before or after it exists — create-if-missing plus --clobber + # makes either order work, and makes a re-run idempotent. + - name: Attach to the release + if: env.TARGET != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release view "$TARGET" >/dev/null 2>&1 \ + || gh release create "$TARGET" --generate-notes + gh release upload "$TARGET" "$ARTIFACT" --clobber diff --git a/.gitignore b/.gitignore index 33047852..0c0d0df9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ target Graphite /krita /gimp +/gegl .claude frontend/wasm/pkg frontend/dist diff --git a/AGENTS.md b/AGENTS.md index 307c755f..3f9d71ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,8 @@ flowchart LR ``` crates/darkly/src/ document/ Authoritative model (layer tree, canvas, ...) - layer_kinds/ ★ group, raster, void - modifiers/ ★ mask, selection + layer_kinds/ ★ group, raster, vector, void, filter + filters/ ★ mask, selection engine/ DarklyEngine — session + per-domain dispatch (painting, rendering, load/save, export, floating, flatten, merge, undo_dispatch, …) @@ -71,8 +71,6 @@ crates/darkly/src/ nodegraph/ Generic node-graph (graph, compiler, layout) frontend/wasm/ WASM bridge (wasm-bindgen) — single API surface frontend/src/ Svelte UI -shared/styles/ @darkly/styles — tokens + themes (UI + website) -website/ Astro + Starlight site (splash, docs, /demo/) ``` ### Coordinate Systems diff --git a/README.md b/README.md index b65d3e3a..25faa373 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Darkly is a Photoshop alternative where painters are first-class citizens. It ha **Try the demo [here](https://demo.darkly.art).** +Documentation [here](https://darkly.art/docs/). + ### Darkly pledges to: - 🛐 Honor human imagination @@ -35,24 +37,7 @@ https://github.com/user-attachments/assets/1fc0632d-5846-4c64-bac8-e39b0794b8b5 ![brush-engine-screenshot](https://github.com/user-attachments/assets/67f8826e-a5b5-4cbe-83e1-3e29246c293c) -Darkly features a unified node-based brush system. Every brush type -- clone, liquify, watercolor, etc. -- all live in a single engine. This enables infinite customizability, mixing and matching of brush features, and on-the-fly creation of custom brushes. - -### Familiar Hotkeys - - - -On first launch, Darkly will ask you which editor preset you want. Currently we support GIMP, Krita, and Photoshop. I come from Krita, so that one's gotten the most TLC. But we want everyone to feel at home no matter which editor they come from. If you find any gaps, please let us know! - -### Hotkey Cheatsheet - -Full documentation is on the way; however, Darkly is mostly self-documenting, meaning if you can't find something, you can quickly search with `CTRL+F` and immediately see its hotkey, description, etc. - without leaving the app. - - - -If you like using hotkeys, we also have a cheat sheet just for you. You can print it or put it on a second screen. - - - +Darkly's unique brushes live inside a node-based system. This enables infinite customizability, mixing and matching of brush features, and on-the-fly creation of custom brushes. ## Dark Arts @@ -246,7 +231,7 @@ See the [crate README](crates/darkly/README.md) for a runnable example, and the ## Contribution -We love hackers as much as artists. Contributions are welcome! Please see [AGENTS.md](./AGENTS.md) for details. +We love hackers as much as we love artists. Contributions are welcome! Please see [AGENTS.md](./AGENTS.md) for details on how to contribute and rules of thumb for the repo. ### Use of AI diff --git a/crates/darkly/Cargo.toml b/crates/darkly/Cargo.toml index c6cca5e1..ebbf1cce 100644 --- a/crates/darkly/Cargo.toml +++ b/crates/darkly/Cargo.toml @@ -61,6 +61,16 @@ pollster = "1.0" naga = { version = "29.0", features = ["wgsl-in"] } syn = { version = "3", features = ["visit", "full"] } +# The hyphenated name does not match the file stem, so cargo cannot infer the +# path from it. No `required-features`: the exporter needs no GPU. +[[bin]] +name = "export-docs" +path = "src/bin/export_docs.rs" + +[[bin]] +name = "render_docs" +required-features = ["testing"] + [[bin]] name = "stroke_replay_bench" required-features = ["testing"] diff --git a/crates/darkly/brushes/airbrush.yaml b/crates/darkly/brushes/airbrush.yaml index bb0b4ded..c96df70d 100644 --- a/crates/darkly/brushes/airbrush.yaml +++ b/crates/darkly/brushes/airbrush.yaml @@ -1,5 +1,6 @@ name: Airbrush category: Basic +description: A fully soft disc that builds color up gradually; hold it in one place and the tone deepens. nodes: pen_input: type: pen_input diff --git a/crates/darkly/brushes/blur.yaml b/crates/darkly/brushes/blur.yaml index a241d617..793562f4 100644 --- a/crates/darkly/brushes/blur.yaml +++ b/crates/darkly/brushes/blur.yaml @@ -1,5 +1,6 @@ name: Blur category: Effects +description: Softens whatever is already on the layer instead of laying down color. nodes: pen_input: type: pen_input diff --git a/crates/darkly/brushes/calligraphy.yaml b/crates/darkly/brushes/calligraphy.yaml index 06334bd1..ed4299f3 100644 --- a/crates/darkly/brushes/calligraphy.yaml +++ b/crates/darkly/brushes/calligraphy.yaml @@ -1,5 +1,6 @@ name: Calligraphy category: Basic +description: A broad elliptical nib held at a fixed angle, so strokes thicken and thin with direction. nodes: pen_input: type: pen_input diff --git a/crates/darkly/brushes/charcoal.yaml b/crates/darkly/brushes/charcoal.yaml index 9c6cd62e..0b8b8a43 100644 --- a/crates/darkly/brushes/charcoal.yaml +++ b/crates/darkly/brushes/charcoal.yaml @@ -1,5 +1,6 @@ name: Charcoal category: Dry Media +description: A grainy stick that catches on the paper's tooth, laying color down heavily where you press and skipping where you don't. nodes: brush_settings: type: brush_settings diff --git a/crates/darkly/brushes/clone.yaml b/crates/darkly/brushes/clone.yaml index 948f8625..ee2d577b 100644 --- a/crates/darkly/brushes/clone.yaml +++ b/crates/darkly/brushes/clone.yaml @@ -1,5 +1,6 @@ name: Clone category: Misc +description: Paints with pixels sampled from elsewhere on the canvas rather than with the current color. nodes: pen_input: type: pen_input diff --git a/crates/darkly/brushes/hair.yaml b/crates/darkly/brushes/hair.yaml index 18486e52..af4106d5 100644 --- a/crates/darkly/brushes/hair.yaml +++ b/crates/darkly/brushes/hair.yaml @@ -1,5 +1,6 @@ name: Hair category: Dry Media +description: A bundle of fine strands that breaks each dab into separate hairs, for fur, grass and dry-bristle texture. nodes: add: type: add @@ -18,10 +19,10 @@ nodes: curve: - - 0.0 - 1.0 - - - 0.682973 - - 0.5305826 + - - 0.48145637 + - 0.795719 - - 1.0 - - 0.0 + - 0.6756355 divide: type: divide levels: @@ -34,10 +35,18 @@ nodes: type: multiply inputs: a: 0.5 + ranges: + a: + - -1.0 + - 1.0 multiply_3: type: multiply inputs: - b: 0.085 + b: 0.1217829 + ranges: + b: + - 0.03296951 + - 0.21059628 noise: type: noise inputs: @@ -54,7 +63,7 @@ nodes: subtract: type: subtract inputs: - a: 0.12 + a: 0.11999999731779099 connections: - add.result -> levels.input - brush_settings.size -> divide.b diff --git a/crates/darkly/brushes/ink_pen.yaml b/crates/darkly/brushes/ink_pen.yaml index 792a9f72..eff1617e 100644 --- a/crates/darkly/brushes/ink_pen.yaml +++ b/crates/darkly/brushes/ink_pen.yaml @@ -1,43 +1,33 @@ name: Ink Pen category: Basic +description: A crisp-edged nib with a slow pressure ramp, for confident line work that holds its weight. nodes: - pen_input: - type: pen_input brush_settings: type: brush_settings inputs: stabilize: 0.6 - paint_color: - type: paint_color circle: type: circle inputs: softness: 0.1 - stamp: - type: stamp paint: type: paint - curve: - type: curve - inputs: - curve: - - - 0.0 - - 0.0 - - - 0.4 - - 0.7 - - - 1.0 - - 1.0 + paint_color: + type: paint_color + pen_input: + type: pen_input + stamp: + type: stamp connections: -- 'pen_input.position -> paint.position' -- 'pen_input.pressure -> paint.flow' -- 'pen_input.pressure -> curve.input' -- 'paint_color.color -> stamp.color' -- 'circle.mask -> stamp.tip' -- 'stamp.dab -> paint.rgba' -- 'curve.output -> paint.size' +- circle.mask -> stamp.tip +- paint_color.color -> stamp.color +- pen_input.position -> paint.position +- pen_input.pressure -> paint.flow +- pen_input.pressure -> paint.size +- stamp.dab -> paint.rgba exposed_ports: - "brush_settings.stabilize": {} - "brush_settings.size": {} - "circle.softness": {} - "paint.flow": {} - "paint.opacity": {} + brush_settings.stabilize: {} + brush_settings.size: {} + circle.softness: {} + paint.flow: {} + paint.opacity: {} diff --git a/crates/darkly/brushes/liquify.yaml b/crates/darkly/brushes/liquify.yaml index a0b9a36f..e8e14140 100644 --- a/crates/darkly/brushes/liquify.yaml +++ b/crates/darkly/brushes/liquify.yaml @@ -1,13 +1,14 @@ name: Liquify category: Effects +description: Pushes the pixels under the cursor along the stroke, warping the image without repainting it. nodes: pen_input: type: pen_input brush_settings: type: brush_settings inputs: - spacing: 0.0 - spacing_min_px: 4.0 + spacing: 0.05 + spacing_min_px: 0.0 size: 0.3 liquify: type: liquify diff --git a/crates/darkly/brushes/rough_ink.yaml b/crates/darkly/brushes/rough_ink.yaml index 11e98ec7..274f7795 100644 --- a/crates/darkly/brushes/rough_ink.yaml +++ b/crates/darkly/brushes/rough_ink.yaml @@ -1,54 +1,44 @@ name: Rough Ink category: Basic +description: An ink nib whose edge is reshaped at random every dab — line work that looks bitten rather than printed. nodes: - pen_input: - type: pen_input brush_settings: type: brush_settings inputs: stabilize: 0.6 + circle: + type: circle + inputs: + algorithm: 1 + frequency: 8.0 + octaves: 4.0 + softness: 0.1 + paint: + type: paint paint_color: type: paint_color - curve: - type: curve - inputs: - curve: - - - 0.0 - - 0.0 - - - 0.4 - - 0.7 - - - 1.0 - - 1.0 + pen_input: + type: pen_input random: type: random random_2: type: random random_3: type: random - circle: - type: circle - inputs: - algorithm: 1 - frequency: 8.0 - octaves: 4.0 - softness: 0.1 stamp: type: stamp - paint: - type: paint connections: -- 'pen_input.position -> paint.position' -- 'pen_input.pressure -> curve.input' -- 'pen_input.pressure -> paint.flow' -- 'paint_color.color -> stamp.color' -- 'curve.output -> paint.size' -- 'random.value -> circle.amplitude' -- 'random_2.value -> circle.rotation_input' -- 'random_3.value -> circle.seed' -- 'circle.mask -> stamp.tip' -- 'stamp.dab -> paint.rgba' +- circle.mask -> stamp.tip +- paint_color.color -> stamp.color +- pen_input.position -> paint.position +- pen_input.pressure -> paint.flow +- pen_input.pressure -> paint.size +- random.value -> circle.amplitude +- random_2.value -> circle.rotation_input +- random_3.value -> circle.seed +- stamp.dab -> paint.rgba exposed_ports: - "brush_settings.stabilize": {} - "brush_settings.size": {} - "paint.flow": {} - "paint.opacity": {} + brush_settings.stabilize: {} + brush_settings.size: {} + paint.flow: {} + paint.opacity: {} diff --git a/crates/darkly/brushes/rough_watercolor.yaml b/crates/darkly/brushes/rough_watercolor.yaml index 28d9e293..11248dd3 100644 --- a/crates/darkly/brushes/rough_watercolor.yaml +++ b/crates/darkly/brushes/rough_watercolor.yaml @@ -1,5 +1,6 @@ name: Rough Watercolor category: Wet Media +description: The same bleeding pigment over a rougher paper — granulated, with a broken edge. nodes: pen_input: type: pen_input diff --git a/crates/darkly/brushes/round.yaml b/crates/darkly/brushes/round.yaml index 1eb39b9c..7b35fd48 100644 --- a/crates/darkly/brushes/round.yaml +++ b/crates/darkly/brushes/round.yaml @@ -1,5 +1,6 @@ name: Round category: Basic +description: A plain soft disc that grows with pressure — the default brush, and the starting point for most of the others. nodes: pen_input: type: pen_input diff --git a/crates/darkly/brushes/smooth_watercolor.yaml b/crates/darkly/brushes/smooth_watercolor.yaml index aa036168..70632a74 100644 --- a/crates/darkly/brushes/smooth_watercolor.yaml +++ b/crates/darkly/brushes/smooth_watercolor.yaml @@ -1,5 +1,6 @@ name: Smooth Watercolor category: Wet Media +description: Wet pigment that pools and blends into what is already on the canvas, with a soft, even edge. nodes: pen_input: type: pen_input diff --git a/crates/darkly/brushes/smudge.yaml b/crates/darkly/brushes/smudge.yaml index 3ecebeb3..bead1893 100644 --- a/crates/darkly/brushes/smudge.yaml +++ b/crates/darkly/brushes/smudge.yaml @@ -1,5 +1,6 @@ name: Smudge category: Effects +description: Drags existing pigment along the stroke, the way a finger pulls through wet paint. nodes: pen_input: type: pen_input diff --git a/crates/darkly/brushes/sponge.yaml b/crates/darkly/brushes/sponge.yaml index 714a4c77..72578adf 100644 --- a/crates/darkly/brushes/sponge.yaml +++ b/crates/darkly/brushes/sponge.yaml @@ -1,5 +1,6 @@ name: Sponge category: Dry Media +description: Hair strands that coil as the stroke moves, for curled fur, smoke and windblown texture. nodes: add: type: add diff --git a/crates/darkly/build.rs b/crates/darkly/build.rs index 67ef4739..7d01cbbc 100644 --- a/crates/darkly/build.rs +++ b/crates/darkly/build.rs @@ -68,20 +68,50 @@ fn main() { generate_handler_registry(&src.join("engine")); - generate_registry(&src.join("gpu/veils"), "crate::gpu::veil::VeilRegistration"); + // Registries whose variants are browsable metadata. `catalog_sources` is + // what `crate::catalog` is generated from — see `generate_catalog_registry`. + let mut catalog_sources: Vec<(String, String)> = Vec::new(); + + generate_catalog_registry( + &src.join("actions"), + "crate::action::ActionCategory", + &src, + &mut catalog_sources, + ); - generate_registry(&src.join("gpu/voids"), "crate::gpu::void::VoidRegistration"); + generate_catalog_registry( + &src.join("gpu/veils"), + "crate::gpu::veil::VeilRegistration", + &src, + &mut catalog_sources, + ); - generate_registry( + generate_catalog_registry( + &src.join("gpu/voids"), + "crate::gpu::void::VoidRegistration", + &src, + &mut catalog_sources, + ); + + generate_catalog_registry( &src.join("gpu/filters"), "crate::gpu::filter::FilterPipelineRegistration", + &src, + &mut catalog_sources, ); - generate_registry(&src.join("tools"), "crate::tool::ToolRegistration"); + generate_catalog_registry( + &src.join("tools"), + "crate::tool::ToolRegistration", + &src, + &mut catalog_sources, + ); - generate_registry( + generate_catalog_registry( &src.join("brush/nodes"), "crate::brush::BrushNodeRegistration", + &src, + &mut catalog_sources, ); generate_registry( @@ -94,32 +124,181 @@ fn main() { "crate::config::schema::SchemaSection", ); - generate_registry( + generate_catalog_registry( &src.join("document/filters"), "crate::document::filter::FilterEntityRegistration", + &src, + &mut catalog_sources, ); - generate_registry( + generate_catalog_registry( &src.join("document/layer_kinds"), "crate::document::layer_kind::LayerKindRegistration", + &src, + &mut catalog_sources, ); - generate_registry( + generate_catalog_registry( &src.join("gpu/blend_modes"), "crate::gpu::blend_mode::BlendModeRegistration", + &src, + &mut catalog_sources, ); - generate_yaml_presets(&PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("presets")); - + // Brushes are a directory of YAML data rather than of `register()` modules, + // so they record their catalog source from inside their own scan. Must run + // before `generate_catalog_sources`, which consumes the vector. generate_builtin_brushes( &PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("brushes"), + &mut catalog_sources, ); + generate_catalog_sources(catalog_sources, &src); + + generate_yaml_presets(&PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("presets")); + generate_texture_registry( &PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("resources/textures"), ); } +/// [`generate_registry`], additionally recording the directory as a source of +/// browsable catalog metadata. +/// +/// Which function a registry directory is scanned by *is* the decision about +/// whether its variants are documentation. Both `catalogs()` and +/// `catalog_sources()` are generated from what this records, so a registry +/// cannot be projected in one and forgotten in the other — and a directory +/// scanned by plain [`generate_registry`] (brush nodes, stabilizers, request +/// handler groups) contributes to neither. +/// +/// The registry module — the parent module of the registration type — must +/// export `CATALOG_ID` and `catalog()`, and may export `preview_mechanism()`. +fn generate_catalog_registry( + dir: &Path, + registration_type: &str, + src: &Path, + sources: &mut Vec<(String, String)>, +) { + generate_registry(dir, registration_type); + let rel = dir + .strip_prefix(src) + .unwrap_or(dir) + .to_str() + .unwrap() + .replace('\\', "/"); + record_catalog_source( + &rel, + registration_type.rsplit_once("::").unwrap().0, + sources, + ); +} + +/// Record a scanned directory as a source of browsable catalog metadata. +/// +/// Split out from [`generate_catalog_registry`] for the scans whose directory +/// holds data rather than `register()` modules — `brushes/` is a directory of +/// YAML, but its catalog is documentation on the same footing as a registry's. +/// `module` must export `CATALOG_ID` and `catalog()`, and may export a preview +/// mechanism. +fn record_catalog_source(dir: &str, module: &str, sources: &mut Vec<(String, String)>) { + sources.push((dir.to_string(), module.to_string())); +} + +/// Resolve a module path (`crate::a::b`) to the file that holds it, trying +/// `src/a/b.rs` then `src/a/b/mod.rs`. `None` when neither exists. +fn module_source(module: &str, src: &Path) -> Option { + let rel = module.trim_start_matches("crate::").replace("::", "/"); + let flat = src.join(format!("{rel}.rs")); + if flat.exists() { + return Some(flat); + } + let dir = src.join(&rel).join("mod.rs"); + dir.exists().then_some(dir) +} + +/// Emit `OUT_DIR/catalog_sources_gen.rs`: the list of scanned catalog-producing +/// registry directories, the `catalogs()` projection over them, and the +/// `preview_mechanisms()` projection over the subset that has one. Generated +/// rather than hand-written so the export and the test that checks the export +/// is complete both read from what the build actually found on disk. +fn generate_catalog_sources(mut sources: Vec<(String, String)>, src: &Path) { + sources.sort(); + + let mut code = String::new(); + code.push_str("// @generated by build.rs — do not edit manually.\n"); + code.push_str( + "// One entry per registry directory scanned by `generate_catalog_registry`.\n\n", + ); + + code.push_str("/// A registry directory the build scan found to produce a catalog.\n"); + code.push_str("pub struct CatalogSource {\n"); + code.push_str(" /// The directory the build scan walked, as that scan named it:\n"); + code.push_str(" /// `gpu/veils` and friends relative to `crates/darkly/src`,\n"); + code.push_str(" /// `brushes` beside it.\n"); + code.push_str(" pub dir: &'static str,\n"); + code.push_str(" /// Id of the catalog the registry in that directory produces.\n"); + code.push_str(" pub id: &'static str,\n"); + code.push_str("}\n\n"); + + code.push_str("/// Every module directory the build scan found that produces a catalog.\n"); + code.push_str("#[rustfmt::skip]\n"); + code.push_str("pub fn catalog_sources() -> Vec {\n"); + code.push_str(" vec![\n"); + for (dir, module) in &sources { + code.push_str(&format!( + " CatalogSource {{ dir: \"{dir}\", id: {module}::CATALOG_ID }},\n" + )); + } + code.push_str(" ]\n"); + code.push_str("}\n\n"); + + code.push_str("/// Every registry, projected. Requires no GPU device.\n"); + code.push_str("#[rustfmt::skip]\n"); + code.push_str("pub fn catalogs() -> Vec {\n"); + code.push_str(" vec![\n"); + for (_, module) in &sources { + code.push_str(&format!(" {module}::catalog(),\n")); + } + code.push_str(" ]\n"); + code.push_str("}\n\n"); + + // One row per catalog whose registry module exports a preview mechanism. + // A catalog that has none writes nothing — which is what keeps the + // document-layer registries free of a `wgpu`-taking trait rather than + // making each of them hand-write a negative. + code.push_str( + "/// Every catalog that can render a preview, keyed by catalog id. A catalog\n\ + /// whose registry module exports no `preview_mechanism` is absent, which is\n\ + /// how a non-previewable catalog answers without writing anything.\n", + ); + code.push_str("#[rustfmt::skip]\n"); + code.push_str( + "pub fn preview_mechanisms() -> Vec<(&'static str, &'static dyn crate::gpu::preview::PreviewMechanism)> {\n", + ); + code.push_str(" vec![\n"); + for (_, module) in &sources { + let Some(path) = module_source(module, src) else { + continue; + }; + println!("cargo:rerun-if-changed={}", path.display()); + let Ok(text) = fs::read_to_string(&path) else { + continue; + }; + if text.contains("pub fn preview_mechanism") { + code.push_str(&format!( + " ({module}::CATALOG_ID, {module}::preview_mechanism()),\n" + )); + } + } + code.push_str(" ]\n"); + code.push_str("}\n"); + + let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); + let out_path = PathBuf::from(out_dir).join("catalog_sources_gen.rs"); + fs::write(&out_path, code).unwrap(); +} + /// Scan a directory for .rs module files (excluding mod.rs) and generate /// a mod.rs that re-exports all modules and provides a `registrations()` /// function collecting each module's `register()` return value. @@ -448,7 +627,20 @@ fn titlecase(s: &str) -> String { /// brushes are loaded by `crate::brush::builtin_brushes::all()` at /// engine startup — adding a new one is "drop a `.yaml` file in the /// directory" with no other code touched. -fn generate_builtin_brushes(dir: &Path) { +/// +/// Also records the directory as a catalog source, from the scan itself +/// rather than from a second hand-written name — the same "derived from +/// what is on disk" property [`generate_catalog_registry`] gives the +/// module directories, and what `every_catalog_source_is_exported` rests on. +fn generate_builtin_brushes(dir: &Path, catalog_sources: &mut Vec<(String, String)>) { + record_catalog_source( + dir.file_name() + .and_then(|s| s.to_str()) + .expect("brush directory has a name"), + "crate::brush::builtin_brushes", + catalog_sources, + ); + let mut brushes: Vec<(String, PathBuf)> = Vec::new(); if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { diff --git a/crates/darkly/src/action.rs b/crates/darkly/src/action.rs new file mode 100644 index 00000000..e74cb06b --- /dev/null +++ b/crates/darkly/src/action.rs @@ -0,0 +1,118 @@ +//! Every command the editor can run, as data. +//! +//! An action has two halves. The documentable half — its id, label, icon and +//! one-line description — is static data, and lives here beside the +//! `presets/*.yaml` bindings that name the same ids. The behavioural half — +//! what running it does — closes over Svelte runes and lives in +//! `frontend/src/actions/`. The two join by id. +//! +//! Actions group one file per category (`actions/edit.rs`, `actions/view.rs`, +//! …), each a `const ACTIONS` table plus one `register()` — the same +//! many-items-per-file shape `config/sections/` uses, and the shape GIMP's +//! per-domain `GimpActionEntry` tables use. `build.rs` discovers the files, so +//! a new category is a new file and nothing else. + +use crate::catalog::{Catalog, CatalogEntry}; + +/// One action's documentation. +pub struct ActionDef { + /// Stable id, named by the bindings in `presets/*.yaml` and by the handler + /// that implements the action. + pub id: &'static str, + pub display_name: &'static str, + /// One sentence describing what running the action does. The command + /// palette's substring search indexes it, so it should carry the words a + /// user would reach for. + pub description: &'static str, + /// Iconify name, rendered in the menu gutter, the command-palette row and + /// the reference manual's table. + pub icon: &'static str, +} + +/// What each file in `actions/` returns from its `register()`: a category's id +/// and every action in it. A category is a group rather than an item, so one +/// file carries many actions and states the grouping once. +pub struct ActionCategory { + /// Grouping id, also the label the cheat sheet and the hotkeys tab show. + pub id: &'static str, + pub actions: &'static [ActionDef], +} + +/// Id of the catalog this registry projects into. +pub const CATALOG_ID: &str = "actions"; + +impl ActionDef { + pub fn catalog_entry(&self, category: &'static str) -> CatalogEntry { + CatalogEntry::new(self.id, self.display_name) + .with_icon(self.icon) + .with_description(self.description) + .with_category(category) + // An action *is* the thing a binding names, so the id it binds is + // its own. Declaring it means one rule — "the entry whose + // `hotkey_action` matches" — resolves a bound chord to its + // documentation for tools, filters and actions alike. + .with_hotkey_action(self.id) + } +} + +/// The action catalog — every registered action, grouped by category. +pub fn catalog() -> Catalog { + let categories = crate::actions::registrations(); + Catalog::new( + CATALOG_ID, + "Actions", + categories + .iter() + .flat_map(|cat| cat.actions.iter().map(|a| a.catalog_entry(cat.id))) + .collect(), + ) + .with_description( + "Every command the editor can run — from a menu, the command palette, or a hotkey.", + ) + .with_shared_icons() +} + +#[cfg(test)] +mod tests { + /// The category is the grouping the cheat sheet and the hotkeys tab render, + /// and it is declared once per file — so two files claiming the same one + /// would silently merge into a section with no single owner. + #[test] + fn every_category_declares_a_unique_id() { + let categories = crate::actions::registrations(); + let mut seen: Vec<&str> = Vec::new(); + for cat in &categories { + assert!(!cat.id.is_empty(), "an action category has an empty id"); + assert!( + !cat.actions.is_empty(), + "action category `{}` declares no actions", + cat.id + ); + assert!( + !seen.contains(&cat.id), + "two action categories claim the id `{}`", + cat.id + ); + seen.push(cat.id); + } + } + + /// Every action carries the four columns the reference manual's table has. + /// `catalog.rs` demands the name and the description of every catalog + /// entry, but not the icon — that is `Option` there because other + /// registries decline it, whereas an action always has one to show in the + /// menu gutter. + #[test] + fn every_action_declares_an_id_and_an_icon() { + for cat in crate::actions::registrations() { + for a in cat.actions { + assert!( + !a.id.is_empty(), + "an action in `{}` has an empty id", + cat.id + ); + assert!(!a.icon.is_empty(), "action `{}` declares no icon", a.id); + } + } + } +} diff --git a/crates/darkly/src/actions/brush.rs b/crates/darkly/src/actions/brush.rs new file mode 100644 index 00000000..5a3147d3 --- /dev/null +++ b/crates/darkly/src/actions/brush.rs @@ -0,0 +1,41 @@ +use crate::action::{ActionCategory, ActionDef}; + +const ACTIONS: &[ActionDef] = &[ + ActionDef { + id: "brushSizeUp", + display_name: "Increase Brush Size", + description: "Step the active brush's size up one notch.", + icon: "fa6-solid:plus", + }, + ActionDef { + id: "brushSizeDown", + display_name: "Decrease Brush Size", + description: "Step the active brush's size down one notch.", + icon: "fa6-solid:minus", + }, + ActionDef { + id: "brushSizeAdjust", + display_name: "Adjust Brush Size (drag)", + description: "Hold the modifier and drag sideways to scrub the brush size continuously.", + icon: "fa6-solid:up-right-and-down-left-from-center", + }, + ActionDef { + id: "setCloneSource", + display_name: "Set Clone Source", + description: "Hold the modifier and click on the canvas to set the point the Clone brush copies from.", + icon: "fa6-solid:crosshairs", + }, + ActionDef { + id: "addBrushNode", + display_name: "Add Brush Node", + description: "Open the add-node menu at the cursor (brush builder).", + icon: "fa6-solid:diagram-project", + }, +]; + +pub fn register() -> ActionCategory { + ActionCategory { + id: "brush", + actions: ACTIONS, + } +} diff --git a/crates/darkly/src/actions/colors.rs b/crates/darkly/src/actions/colors.rs new file mode 100644 index 00000000..3a905744 --- /dev/null +++ b/crates/darkly/src/actions/colors.rs @@ -0,0 +1,30 @@ +use crate::action::{ActionCategory, ActionDef}; + +const ACTIONS: &[ActionDef] = &[ + ActionDef { + id: "swapColors", + display_name: "Swap Colors", + description: "Swap the foreground and background colors.", + icon: "fa6-solid:right-left", + }, + ActionDef { + id: "resetColors", + display_name: "Reset Colors", + description: "Reset the foreground/background to black and white.", + icon: "fa6-solid:circle-half-stroke", + }, + ActionDef { + id: "sampleColor", + display_name: "Sample Color", + description: + "Hold the modifier and drag on the canvas to sample a color into the foreground swatch.", + icon: "fa6-solid:eye-dropper", + }, +]; + +pub fn register() -> ActionCategory { + ActionCategory { + id: "colors", + actions: ACTIONS, + } +} diff --git a/crates/darkly/src/actions/edit.rs b/crates/darkly/src/actions/edit.rs new file mode 100644 index 00000000..21ddd88d --- /dev/null +++ b/crates/darkly/src/actions/edit.rs @@ -0,0 +1,95 @@ +use crate::action::{ActionCategory, ActionDef}; + +const ACTIONS: &[ActionDef] = &[ + ActionDef { + id: "undo", + display_name: "Undo", + description: "Undo the last action.", + icon: "fa6-solid:rotate-left", + }, + ActionDef { + id: "redo", + display_name: "Redo", + description: "Redo the last undone action.", + icon: "fa6-solid:rotate-right", + }, + ActionDef { + id: "cut", + display_name: "Cut", + description: "Cut the active layer to the clipboard.", + icon: "fa6-solid:scissors", + }, + ActionDef { + id: "copy", + display_name: "Copy", + description: "Copy the active layer to the clipboard.", + icon: "fa6-solid:copy", + }, + ActionDef { + id: "paste", + display_name: "Paste", + description: "Paste an image or layer from the clipboard.", + icon: "fa6-solid:paste", + }, + ActionDef { + id: "pasteInPlace", + display_name: "Paste in Place", + description: "Paste from the clipboard at its original position.", + icon: "fa6-solid:clipboard", + }, + ActionDef { + id: "resizeCanvas", + display_name: "Resize Canvas", + description: "Resize the canvas with a 9-point anchor.", + icon: "fa6-solid:up-right-and-down-left-from-center", + }, + ActionDef { + id: "rescaleImage", + display_name: "Scale Image to New Size", + description: "Resize all layers to new document dimensions.", + icon: "fa6-solid:expand", + }, + ActionDef { + id: "cropToSelection", + display_name: "Crop to Selection", + description: "Crop the canvas to the current selection bounds.", + icon: "fa6-solid:crop-simple", + }, + ActionDef { + id: "flipCanvasH", + display_name: "Flip Canvas Horizontally", + description: "Mirror the whole canvas left-to-right.", + icon: "fa6-solid:arrows-left-right", + }, + ActionDef { + id: "flipCanvasV", + display_name: "Flip Canvas Vertically", + description: "Mirror the whole canvas top-to-bottom.", + icon: "fa6-solid:arrows-up-down", + }, + ActionDef { + id: "rotateCanvasCW", + display_name: "Rotate Canvas 90° CW", + description: "Rotate the whole canvas a quarter turn clockwise.", + icon: "fa6-solid:rotate-right", + }, + ActionDef { + id: "rotateCanvasCCW", + display_name: "Rotate Canvas 90° CCW", + description: "Rotate the whole canvas a quarter turn counter-clockwise.", + icon: "fa6-solid:rotate-left", + }, + ActionDef { + id: "rotateCanvas180", + display_name: "Rotate Canvas 180°", + description: "Rotate the whole canvas a half turn.", + icon: "fa6-solid:rotate", + }, +]; + +pub fn register() -> ActionCategory { + ActionCategory { + id: "edit", + actions: ACTIONS, + } +} diff --git a/crates/darkly/src/actions/file.rs b/crates/darkly/src/actions/file.rs new file mode 100644 index 00000000..9264022a --- /dev/null +++ b/crates/darkly/src/actions/file.rs @@ -0,0 +1,41 @@ +use crate::action::{ActionCategory, ActionDef}; + +const ACTIONS: &[ActionDef] = &[ + ActionDef { + id: "newDocument", + display_name: "New", + description: "Open a fresh document in a new tab. Prompts for canvas size and background color.", + icon: "fa6-solid:file", + }, + ActionDef { + id: "open", + display_name: "Open", + description: "Open a `.darkly` document or image (PNG / JPEG / WebP) in a new tab.", + icon: "fa6-solid:folder-open", + }, + ActionDef { + id: "saveDocument", + display_name: "Save", + description: "Save the current document. Re-saves to the same `.darkly` file after the first Save As; otherwise opens the Save picker (`.darkly`, or PNG / JPEG / WebP to export the canvas).", + icon: "fa6-solid:floppy-disk", + }, + ActionDef { + id: "saveDocumentAs", + display_name: "Save As", + description: "Save the current document to a new file — `.darkly`, or PNG / JPEG / WebP to export the canvas.", + icon: "fa6-solid:file-export", + }, + ActionDef { + id: "exportTimelapse", + display_name: "Export Timelapse…", + description: "Export the process recording as an MP4 or GIF timelapse.", + icon: "fa6-solid:video", + }, +]; + +pub fn register() -> ActionCategory { + ActionCategory { + id: "file", + actions: ACTIONS, + } +} diff --git a/crates/darkly/src/actions/layers.rs b/crates/darkly/src/actions/layers.rs new file mode 100644 index 00000000..3d5f5135 --- /dev/null +++ b/crates/darkly/src/actions/layers.rs @@ -0,0 +1,83 @@ +use crate::action::{ActionCategory, ActionDef}; + +const ACTIONS: &[ActionDef] = &[ + ActionDef { + id: "newLayer", + display_name: "New Layer", + description: "Add a new layer above the active one.", + icon: "fa6-solid:square-plus", + }, + ActionDef { + id: "newGroup", + display_name: "New Group", + description: "Group the selected layers together, or add an empty group if nothing is selected.", + icon: "fa6-solid:folder-plus", + }, + ActionDef { + id: "duplicateLayer", + display_name: "Duplicate Layer", + description: "Make a copy of each selected layer.", + icon: "fa6-solid:clone", + }, + ActionDef { + id: "deleteLayer", + display_name: "Delete Layer", + description: "Delete the selected layers.", + icon: "fa6-solid:trash", + }, + ActionDef { + id: "flipLayerH", + display_name: "Flip Horizontally", + description: "Mirror the active layer (or selection) left-to-right.", + icon: "fa6-solid:arrows-left-right", + }, + ActionDef { + id: "flipLayerV", + display_name: "Flip Vertically", + description: "Mirror the active layer (or selection) top-to-bottom.", + icon: "fa6-solid:arrows-up-down", + }, + ActionDef { + id: "toggleVisibility", + display_name: "Toggle Layer Visibility", + description: "Show or hide the active layer.", + icon: "fa6-solid:eye", + }, + ActionDef { + id: "toggleLock", + display_name: "Toggle Layer Lock", + description: "Lock or unlock the active layer.", + icon: "fa6-solid:lock", + }, + ActionDef { + id: "isolateLayer", + display_name: "Isolate Layer", + description: "Solo a layer so only it shows in the canvas. Press again to bring everything else back.", + icon: "fa6-solid:circle-dot", + }, + ActionDef { + id: "addMask", + display_name: "Add Mask", + description: "Add a mask modifier to the active layer or group and activate it for painting.", + icon: "radix-icons:mask-on", + }, + ActionDef { + id: "mergeDown", + display_name: "Merge Down", + description: "Merge the active layer into the one below it, or combine multiple selected layers into a single layer.", + icon: "fa6-solid:arrows-down-to-line", + }, + ActionDef { + id: "flatten", + display_name: "Flatten", + description: "Bake modifiers into the layer (apply mask), or flatten a group into a single raster that inherits the group’s blend props.", + icon: "fa6-solid:layer-group", + }, +]; + +pub fn register() -> ActionCategory { + ActionCategory { + id: "layers", + actions: ACTIONS, + } +} diff --git a/crates/darkly/src/actions/mod.rs b/crates/darkly/src/actions/mod.rs new file mode 100644 index 00000000..dcc0a54f --- /dev/null +++ b/crates/darkly/src/actions/mod.rs @@ -0,0 +1,30 @@ +// @generated by build.rs — do not edit manually. +// To add a new module, create a .rs file in this directory +// that exports `pub fn register() -> crate::action::ActionCategory`. + +pub mod brush; +pub mod colors; +pub mod edit; +pub mod file; +pub mod layers; +pub mod selection; +pub mod tools; +pub mod transform; +pub mod view; + +use crate::action::ActionCategory; + +#[rustfmt::skip] +pub fn registrations() -> Vec { + vec![ + brush::register(), + colors::register(), + edit::register(), + file::register(), + layers::register(), + selection::register(), + tools::register(), + transform::register(), + view::register(), + ] +} diff --git a/crates/darkly/src/actions/selection.rs b/crates/darkly/src/actions/selection.rs new file mode 100644 index 00000000..60507596 --- /dev/null +++ b/crates/darkly/src/actions/selection.rs @@ -0,0 +1,77 @@ +use crate::action::{ActionCategory, ActionDef}; + +const ACTIONS: &[ActionDef] = &[ + ActionDef { + id: "selectAll", + display_name: "Select All", + description: "Select the entire canvas.", + icon: "fa6-solid:vector-square", + }, + ActionDef { + id: "clearSelection", + display_name: "Deselect", + description: "Clear the active selection.", + icon: "fa6-solid:ban", + }, + ActionDef { + id: "invertSelection", + display_name: "Invert Selection", + description: "Invert the current selection.", + icon: "tabler:flip-horizontal", + }, + ActionDef { + id: "maskToSelection", + display_name: "Mask to Selection", + description: "Load the active layer's mask as the selection.", + icon: "radix-icons:mask-off", + }, + ActionDef { + id: "clearSelectionContents", + display_name: "Clear Selection Contents", + description: "Erase the pixels inside the selection.", + icon: "fa6-solid:eraser", + }, + ActionDef { + id: "growSelection", + display_name: "Grow Selection", + description: "Expand the selection edge outward by a number of pixels.", + icon: "fa6-solid:up-right-and-down-left-from-center", + }, + ActionDef { + id: "shrinkSelection", + display_name: "Shrink Selection", + description: "Contract the selection edge inward by a number of pixels.", + icon: "fa6-solid:down-left-and-up-right-to-center", + }, + ActionDef { + id: "borderSelection", + display_name: "Border Selection", + description: "Replace the selection with a band straddling its edge.", + icon: "fa6-solid:border-all", + }, + ActionDef { + id: "smoothSelection", + display_name: "Smooth Selection", + description: "Round off jagged edges and remove small specks.", + icon: "fa6-solid:wand-magic-sparkles", + }, + ActionDef { + id: "featherSelection", + display_name: "Feather Selection", + description: "Soften the selection edge with a Gaussian blur.", + icon: "fa6-solid:feather", + }, + ActionDef { + id: "antialiasSelection", + display_name: "Antialias Selection", + description: "Soften the staircase of a hard-edged selection.", + icon: "fa6-solid:wand-magic", + }, +]; + +pub fn register() -> ActionCategory { + ActionCategory { + id: "selection", + actions: ACTIONS, + } +} diff --git a/crates/darkly/src/actions/tools.rs b/crates/darkly/src/actions/tools.rs new file mode 100644 index 00000000..f7390287 --- /dev/null +++ b/crates/darkly/src/actions/tools.rs @@ -0,0 +1,19 @@ +use crate::action::{ActionCategory, ActionDef}; + +/// Selecting a tool is not declared here: each tool names the action that +/// selects it on its own `ToolRegistration` (`hotkey_action`), so the `tools` +/// catalog already documents those twelve. What is left is the tool state a +/// hotkey can flip without being a tool of its own. +const ACTIONS: &[ActionDef] = &[ActionDef { + id: "toggleEraseMode", + display_name: "Toggle Erase Mode", + description: "Toggle erase mode on the brush tool. Switches to the brush tool first if another tool is active.", + icon: "fa6-solid:eraser", +}]; + +pub fn register() -> ActionCategory { + ActionCategory { + id: "tools", + actions: ACTIONS, + } +} diff --git a/crates/darkly/src/actions/transform.rs b/crates/darkly/src/actions/transform.rs new file mode 100644 index 00000000..ee3bc904 --- /dev/null +++ b/crates/darkly/src/actions/transform.rs @@ -0,0 +1,23 @@ +use crate::action::{ActionCategory, ActionDef}; + +const ACTIONS: &[ActionDef] = &[ + ActionDef { + id: "commitFloating", + display_name: "Commit Floating", + description: "Stamp the floating content down into its layer, ending the transform.", + icon: "fa6-solid:check", + }, + ActionDef { + id: "cancelFloating", + display_name: "Cancel Floating", + description: "Discard the floating content and leave the layer as it was.", + icon: "fa6-solid:xmark", + }, +]; + +pub fn register() -> ActionCategory { + ActionCategory { + id: "transform", + actions: ACTIONS, + } +} diff --git a/crates/darkly/src/actions/view.rs b/crates/darkly/src/actions/view.rs new file mode 100644 index 00000000..1b247ed8 --- /dev/null +++ b/crates/darkly/src/actions/view.rs @@ -0,0 +1,77 @@ +use crate::action::{ActionCategory, ActionDef}; + +const ACTIONS: &[ActionDef] = &[ + ActionDef { + id: "openSettings", + display_name: "Settings", + description: "Show the preferences modal.", + icon: "fa6-solid:gear", + }, + ActionDef { + id: "commandPalette", + display_name: "Command Palette", + description: "Search and run any command.", + icon: "fa6-solid:magnifying-glass", + }, + ActionDef { + id: "mirrorViewH", + display_name: "Mirror View", + description: "Flip the canvas horizontally for fresh-eyes review. View-only — the document is unchanged.", + icon: "fa6-solid:left-right", + }, + ActionDef { + id: "resetView", + display_name: "Reset View", + description: "Reset rotation, mirror, pan, and zoom-to-fit. View-only — the document is unchanged.", + icon: "fa6-solid:expand", + }, + ActionDef { + id: "fitToScreen", + display_name: "Fit to Screen", + description: "Zoom and recenter so the whole canvas fills the viewport, keeping the current rotation and mirror. View-only — the document is unchanged.", + icon: "fa6-solid:maximize", + }, + ActionDef { + id: "centerView", + display_name: "Center View", + description: "Recenter the canvas in the viewport without changing zoom, rotation, or mirror. View-only — the document is unchanged.", + icon: "fa6-solid:crosshairs", + }, + ActionDef { + id: "openCheatsheet", + display_name: "Hotkey Cheat Sheet", + description: "Open a searchable, printable list of every keyboard shortcut.", + icon: "fa6-solid:keyboard", + }, + ActionDef { + id: "openDocs", + display_name: "Documentation", + description: "Open the Darkly documentation in a new tab.", + icon: "fa6-solid:book", + }, + ActionDef { + id: "openWebsite", + display_name: "Website", + description: "Open the Darkly website in a new tab.", + icon: "fa6-solid:globe", + }, + ActionDef { + id: "openGithub", + display_name: "GitHub Repository", + description: "Open the Darkly source repository on GitHub.", + icon: "fa6-brands:github", + }, + ActionDef { + id: "aboutDarkly", + display_name: "About Darkly", + description: "Show version and credits.", + icon: "fa6-solid:circle-info", + }, +]; + +pub fn register() -> ActionCategory { + ActionCategory { + id: "view", + actions: ACTIONS, + } +} diff --git a/crates/darkly/src/bin/export_docs.rs b/crates/darkly/src/bin/export_docs.rs new file mode 100644 index 00000000..654c21e5 --- /dev/null +++ b/crates/darkly/src/bin/export_docs.rs @@ -0,0 +1,125 @@ +//! Write one JSON file describing everything Darkly registers. +//! +//! The file is self-describing: a consumer needs no Darkly source, no shared +//! helpers, no knowledge of Darkly's hotkey resolution, and no particular +//! renderer. Everything requiring Darkly knowledge to compute — layered preset +//! resolution, chord rendering for both platform conventions, unit-suffixed +//! display strings — is computed here. +//! +//! Needs no GPU: every registry constructor is pure, so the catalogs build from +//! `&'static` registration data alone. +//! +//! ```text +//! cargo run -p darkly --bin export-docs -- --out /tmp/darkly-docs/metadata.json +//! ``` + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::process::ExitCode; + +use darkly::catalog::{catalogs, settings_catalogs, Catalog}; +use darkly::config::{preset_bindings, Binding, OVERLAYS}; + +/// The whole artifact. `schema` is the shape's own version — bumped when a +/// consumer would need to change to keep reading it. +#[derive(serde::Serialize)] +struct DocsManifest { + schema: u32, + /// `git describe` of the build that wrote this, which is also what + /// `render-docs` stamps — the pairing key between the two artifacts. + version: &'static str, + catalogs: Vec, + /// Preset name → action id → the chords it resolves to. Keyed by the + /// preset's own name, with `defaults` for the editor-agnostic baseline. + bindings: BTreeMap>>, +} + +const SCHEMA_VERSION: u32 = 1; + +const HELP: &str = "\ +export-docs — write Darkly's registry metadata to a JSON file + +USAGE: + export-docs --out + +OPTIONS: + --out File to write. Parent directories are created. + -h, --help Show this message. +"; + +fn parse_args() -> Result { + let mut out: Option = None; + let mut argv = std::env::args().skip(1); + while let Some(a) = argv.next() { + match a.as_str() { + "--out" => { + let v = argv.next().ok_or("--out needs a path")?; + out = Some(PathBuf::from(v)); + } + "-h" | "--help" => { + print!("{HELP}"); + std::process::exit(0); + } + other => return Err(format!("unrecognized argument `{other}`")), + } + } + out.ok_or_else(|| "--out is required".to_string()) +} + +fn main() -> ExitCode { + let out = match parse_args() { + Ok(p) => p, + Err(e) => { + eprintln!("export-docs: {e}\n\n{HELP}"); + return ExitCode::FAILURE; + } + }; + + let mut all = catalogs(); + all.extend(settings_catalogs()); + + let mut bindings = BTreeMap::new(); + bindings.insert("defaults".to_string(), preset_bindings(None)); + for (name, _) in OVERLAYS { + bindings.insert((*name).to_string(), preset_bindings(Some(name))); + } + + let entries: usize = all.iter().map(|c| c.entries.len()).sum(); + let manifest = DocsManifest { + schema: SCHEMA_VERSION, + version: darkly::VERSION, + catalogs: all, + bindings, + }; + + let json = match serde_json::to_string_pretty(&manifest) { + Ok(j) => j, + Err(e) => { + eprintln!("export-docs: failed to serialize: {e}"); + return ExitCode::FAILURE; + } + }; + + if let Some(parent) = out.parent() { + if !parent.as_os_str().is_empty() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!("export-docs: cannot create {}: {e}", parent.display()); + return ExitCode::FAILURE; + } + } + } + if let Err(e) = std::fs::write(&out, &json) { + eprintln!("export-docs: cannot write {}: {e}", out.display()); + return ExitCode::FAILURE; + } + + println!( + "{} — {} catalogs, {} entries, {} presets, {} KB", + out.display(), + manifest.catalogs.len(), + entries, + manifest.bindings.len(), + json.len() / 1024, + ); + ExitCode::SUCCESS +} diff --git a/crates/darkly/src/bin/render_docs.rs b/crates/darkly/src/bin/render_docs.rs new file mode 100644 index 00000000..0715498f --- /dev/null +++ b/crates/darkly/src/bin/render_docs.rs @@ -0,0 +1,57 @@ +//! Writes an animated preview — a PNG frame sequence — for every previewable +//! registry entry, plus a small JSON index of what it wrote. +//! +//! ```text +//! cargo run -p darkly --bin render_docs --features testing -- --out +//! ``` +//! +//! Kept separate from `export-docs` because that one is GPU-free by +//! construction: folding both into one binary would drag the metadata export +//! behind a GPU device and the `testing` feature it does not need. +//! +//! Everything except argument handling and reporting lives in +//! [`darkly::docs_render`], which the integration tests call directly — +//! coverage tooling runs test targets and never executes a `[[bin]]`. + +use std::process::ExitCode; + +use darkly::docs_render::{self, Args}; + +fn main() -> ExitCode { + let args = match docs_render::parse_args(std::env::args().skip(1)) { + Ok(a) => a, + Err(e) => { + eprintln!("render_docs: {e}\n\n{}", docs_render::USAGE); + return ExitCode::FAILURE; + } + }; + let Args { out: Some(out) } = args else { + print!("{}", docs_render::USAGE); + return ExitCode::SUCCESS; + }; + + match docs_render::render_all(&out) { + Ok(manifest) => { + for (catalog, entries) in &manifest.assets { + let frames: u32 = entries.values().map(|a| a.frames).sum(); + // Every entry in a catalog is rendered by one renderer, so one + // entry's size describes the whole catalog. + let size = entries + .values() + .next() + .map(|a| format!("{} × {}", a.width, a.height)) + .unwrap_or_default(); + println!( + "{catalog}: {} assets, {frames} frames, {size}", + entries.len() + ); + } + println!("{} — version {}", out.display(), manifest.version); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("render_docs: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/darkly/src/brush/builtin_brushes.rs b/crates/darkly/src/brush/builtin_brushes.rs index b7059dd6..9097f233 100644 --- a/crates/darkly/src/brush/builtin_brushes.rs +++ b/crates/darkly/src/brush/builtin_brushes.rs @@ -6,32 +6,143 @@ //! that directory at compile time — adding a new brush is "drop a //! file, no code changes." See the modularity rules in CLAUDE.md. +use std::sync::OnceLock; + use crate::brush::bundle::Brush; +use crate::brush::library::BrushInfo; use crate::brush::portable::PortableBrush; +use crate::catalog::{Catalog, CatalogEntry}; +use crate::gpu::preview::PreviewAnim; // `BUILTIN_BRUSHES_YAML: &[(filename, yaml_source)]` — generated by // `crates/darkly/build.rs` from `crates/darkly/brushes/*.yaml`. include!(concat!(env!("OUT_DIR"), "/builtin_brushes_gen.rs")); -/// All built-in brushes, parsed from their YAML sources. +/// Every built-in brush, paired with its YAML file stem. /// /// Parse and import failures panic — a built-in brush failing to load /// is a build-time bug in the shipped YAML, not a runtime error the /// caller can recover from. -pub fn all() -> Vec { +fn parsed() -> Vec<(&'static str, Brush)> { let registry = crate::brush::registry(); BUILTIN_BRUSHES_YAML .iter() .map(|(filename, yaml)| { + let stem = filename + .strip_suffix(".yaml") + .expect("build.rs names every embedded brush `.yaml`"); let portable: PortableBrush = serde_yaml_ng::from_str(yaml) .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}")); - portable + let brush = portable .into_brush(registry) - .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}")) + .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}")); + (stem, brush) }) .collect() } +/// All built-in brushes, parsed from their YAML sources. +pub fn all() -> Vec { + parsed().into_iter().map(|(_, brush)| brush).collect() +} + +/// The shipped brushes' listable metadata, parsed once for the process, +/// each paired with its YAML file stem. +/// +/// Every brush is embedded at compile time, so the set is fixed and a +/// process-lifetime cache is the data's real lifetime rather than an +/// approximation of it. Holding the parse here is what lets a +/// [`CatalogEntry`]'s `&'static str` fields borrow strings that arrived as +/// owned YAML values — without leaking, and without widening the catalog +/// types to `Cow` for the one registry that is not wholly static. +/// +/// The row is [`BrushInfo`] itself rather than a parallel doc struct, so the +/// picker and the documentation read the identical summary by construction. +/// The file stem is the only thing `BrushInfo` does not carry, so it rides +/// alongside. +pub fn docs() -> &'static [(&'static str, BrushInfo)] { + static DOCS: OnceLock> = OnceLock::new(); + DOCS.get_or_init(|| { + parsed() + .into_iter() + .map(|(stem, brush)| (stem, BrushInfo::from(&brush.metadata))) + .collect() + }) + .as_slice() +} + +/// Id of the catalog the shipped brushes project into. +pub const CATALOG_ID: &str = "brushes"; + +/// How a brush's preview plays back: it does not. A brush's preview is one +/// finished stroke, and the finished stroke is the answer to "what does this +/// brush look like" — watching the line appear adds nothing the way watching a +/// veil's parameter sweep does, and it would cost forty-eight full re-renders +/// per brush for it. Krita's live preview repaints the whole stroke on every +/// change and never animates it either. +/// +/// Declared on the catalog rather than on any single brush for the same reason +/// blend modes declare theirs there: the motion — or the lack of it — is the +/// same for every entry, so a fifteenth brush is still one YAML file and +/// inherits this for free. A brush that ever wants motion is a `preview` field +/// on its metadata and a fallback to this in [`preview`], local to this file. +pub static PREVIEW: PreviewAnim = PreviewAnim::STILL; + +/// How long a brush's preview runs. Every shipped brush inherits [`PREVIEW`]; +/// an unknown stem gets `None`. +/// +/// The single authority on whether a brush is previewable — [`catalog`] derives +/// the flag by asking this rather than asserting it, so declaring an animation +/// stays the one way to say an entry has a preview. +pub fn preview(type_id: &str) -> Option { + docs() + .iter() + .any(|(stem, _)| *stem == type_id) + .then_some(PREVIEW) +} + +/// The brush catalog — every shipped built-in, sorted by `type_id`. +/// +/// `type_id` is the YAML file stem (`rough_watercolor`), not the brush's +/// name: it is snake_case like every other catalog's type ids, and +/// `docs_render` uses `catalog.id / entry.type_id` as a real directory path, +/// which `"Rough Watercolor"` would not survive. `displayName` carries the +/// name, and the name is what a consumer keys by against the running app — +/// [`BrushLibrary`](crate::brush::library::BrushLibrary) stores brushes under +/// it. +/// +/// Alone among the catalogs this one parses on its first call, so it inherits +/// [`parsed`]'s panic on malformed shipped YAML — which the whole of +/// `catalogs()` then carries. That is the same bug `all()` already panics on +/// at engine init, surfacing one call earlier. +pub fn catalog() -> Catalog { + Catalog::new( + CATALOG_ID, + "Brushes", + docs() + .iter() + .map(|(stem, info)| { + let entry = CatalogEntry::new(stem, info.name.as_str()) + .with_description(info.description.as_str()) + .with_category(info.category.as_str()) + // Brushes carry no `preview` field of their own — the recipe + // lives on the catalog — so previewability is the same + // question put to the same authority. + .with_supports_preview(preview(stem).is_some()); + match info.icon { + Some(icon) => entry.with_icon(icon), + None => entry, + } + }) + .collect(), + ) + .with_description( + "The brushes Darkly ships with, grouped by the medium they imitate. \ + This is the shipped set — a running session's library also holds \ + whatever the painter has loaded.", + ) +} + #[cfg(test)] mod tests { use super::*; @@ -105,12 +216,12 @@ mod tests { } /// Content-dependent brushes (their graphs sample existing canvas - /// pixels, so the flat preview bake renders blank) must carry a - /// preview fallback icon on their `BrushInfo`; content-free brushes - /// must not. Rough Watercolor is the discriminator that proves the - /// trigger is `preview_fallback_icon` and not `supports_erase` — - /// its terminal sets `supports_erase = false` yet its preview bake - /// is meaningful, so it gets no icon. + /// pixels, so a still dab over the flat preview bake renders blank) + /// must carry a preview fallback icon on their `BrushInfo`; + /// content-free brushes must not. Rough Watercolor is the + /// discriminator that proves the trigger is `preview_staging` and not + /// `supports_erase` — its terminal sets `supports_erase = false` yet + /// its preview bake is meaningful, so it gets no icon. #[test] fn content_dependent_brushes_get_preview_icons() { use crate::brush::library::BrushInfo; @@ -138,6 +249,21 @@ mod tests { } } + /// The catalog projects exactly the shipped set, keyed by file stem. + /// Catches a brush file that stops being projected — that the + /// descriptions are non-empty is + /// `catalog::tests::every_catalog_entry_is_documented`'s job, for every + /// catalog at once. + #[test] + fn the_brush_catalog_covers_every_shipped_brush() { + let projected: Vec<&str> = catalog().entries.iter().map(|e| e.type_id).collect(); + let stems: Vec<&str> = BUILTIN_BRUSHES_YAML + .iter() + .map(|(filename, _)| filename.strip_suffix(".yaml").unwrap()) + .collect(); + assert_eq!(projected, stems, "brushes is not the shipped set"); + } + #[test] fn builtin_brushes_unique_names() { let brushes = all(); diff --git a/crates/darkly/src/brush/checkpoint_ring.rs b/crates/darkly/src/brush/checkpoint_ring.rs index 8d6d6874..d4132d39 100644 --- a/crates/darkly/src/brush/checkpoint_ring.rs +++ b/crates/darkly/src/brush/checkpoint_ring.rs @@ -31,6 +31,11 @@ struct CheckpointSlot { /// Dimensions of the allocated texture (may be larger than bbox). tex_w: u32, tex_h: u32, + /// Format the slot was allocated in. The ring snapshots the stroke + /// scratch, whose format is the terminal's business — colour for most + /// brushes, a float displacement field for warp terminals — and + /// `copy_texture_to_texture` requires the two to match. + tex_format: wgpu::TextureFormat, /// The bbox region this checkpoint covers, in canvas pixel coords. /// Stable across mid-stroke layer growth. canvas_bbox: CanvasRect, @@ -50,6 +55,7 @@ impl CheckpointSlot { texture: None, tex_w: 0, tex_h: 0, + tex_format: crate::brush::node::COLOR_SCRATCH_FORMAT, canvas_bbox: CanvasRect::from_xywh(0, 0, 0, 0), save_point_index: 0, vector_index: 0, @@ -65,9 +71,18 @@ impl CheckpointSlot { } } - /// Ensure the texture is at least `w × h`. Reallocate if needed. - fn ensure_texture(&mut self, device: &wgpu::Device, w: u32, h: u32) { - if self.tex_w >= w && self.tex_h >= h && self.texture.is_some() { + /// Ensure the texture is at least `w × h` and in `format`. + /// Reallocate if needed — including on a format change, since a + /// slot cached from a colour stroke cannot receive a warp field. + fn ensure_texture( + &mut self, + device: &wgpu::Device, + w: u32, + h: u32, + format: wgpu::TextureFormat, + ) { + if self.tex_w >= w && self.tex_h >= h && self.tex_format == format && self.texture.is_some() + { return; } // Allocate with some headroom to reduce reallocation frequency. @@ -83,12 +98,13 @@ impl CheckpointSlot { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, + format, usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, view_formats: &[], })); self.tex_w = alloc_w; self.tex_h = alloc_h; + self.tex_format = format; } } @@ -260,7 +276,12 @@ impl CheckpointRing { let slot_idx = self.pick_slot(tip_vi, max_div_window, vector_index); let slot = &mut self.slots[slot_idx]; - slot.ensure_texture(device, layer_rect.width, layer_rect.height); + slot.ensure_texture( + device, + layer_rect.width, + layer_rect.height, + stroke.texture.format(), + ); slot.canvas_bbox = clipped_canvas; slot.save_point_index = save_point_index; slot.vector_index = vector_index; diff --git a/crates/darkly/src/brush/eval.rs b/crates/darkly/src/brush/eval.rs index cdfe1dab..2d29c034 100644 --- a/crates/darkly/src/brush/eval.rs +++ b/crates/darkly/src/brush/eval.rs @@ -725,6 +725,26 @@ impl BrushGraphRunner { self.plan.steps.iter().any(|step| step.is_terminal) } + /// Texel format the stroke scratch must be allocated in for this + /// brush — the terminal's declared + /// [`scratch_format`](crate::brush::node::BrushNodeRegistration::scratch_format). + /// + /// Type-owned dispatch, same shape as [`Self::has_terminal`]: the + /// terminal answers what its scratch holds, and callers + /// (`StrokeBuffer::new`, the preview renderer) just pass the answer + /// through. A terminal-less graph gets the colour default; it never + /// renders anyway. + pub fn scratch_format(&self) -> wgpu::TextureFormat { + let registry = crate::brush::registry(); + self.plan + .steps + .iter() + .filter(|step| step.is_terminal) + .find_map(|step| registry.get(&step.type_id)) + .map(|reg| reg.scratch_format) + .unwrap_or(crate::brush::node::COLOR_SCRATCH_FORMAT) + } + /// Build a name → value map of every output slot in the graph, /// keyed by `n{node_id}_{port_name}` (matching the convention /// [`crate::brush::wgsl::CompileWgslCtx::dab_field_name`] diff --git a/crates/darkly/src/brush/gpu_context.rs b/crates/darkly/src/brush/gpu_context.rs index ae05a295..ee65fbd4 100644 --- a/crates/darkly/src/brush/gpu_context.rs +++ b/crates/darkly/src/brush/gpu_context.rs @@ -360,6 +360,14 @@ impl<'a> StrokeResources<'a> { pub fn source_texture(&self) -> &'a wgpu::Texture { self.source_override.unwrap_or(self.pre_stroke_texture) } + + /// A view over [`Self::source_texture`], for consumers that bind the + /// source without a pre-built bind group — the warp-field resolve + /// builds its own two-texture group per commit. + pub fn source_view(&self) -> wgpu::TextureView { + self.source_texture() + .create_view(&wgpu::TextureViewDescriptor::default()) + } } /// Everything a GPU brush node needs to record render passes. diff --git a/crates/darkly/src/brush/mod.rs b/crates/darkly/src/brush/mod.rs index 3d243650..88f36d7a 100644 --- a/crates/darkly/src/brush/mod.rs +++ b/crates/darkly/src/brush/mod.rs @@ -30,12 +30,14 @@ pub mod state; pub mod stroke_buffer; pub mod stroke_engine; pub mod texture_source; +pub mod warp_field; pub mod wgsl; pub mod wire; use std::collections::HashMap; use std::sync::OnceLock; +use crate::gpu::preview::PreviewBackdrop; use crate::nodegraph::NodeRegistration; use wire::BrushWireType; @@ -104,9 +106,14 @@ impl BrushNodeRegistry { self.map.get(type_id) } - /// All registered node types. - pub fn types(&self) -> impl Iterator { - self.map.values() + /// All registered node types, sorted by `type_id` for deterministic + /// output. Sorting here rather than at each consumer is what makes both + /// the exported catalog and the frontend's node palette reproducible — + /// `map` is a `HashMap`, so its iteration order differs between processes. + pub fn types(&self) -> Vec<&BrushNodeRegistration> { + let mut v: Vec<&BrushNodeRegistration> = self.map.values().collect(); + v.sort_by_key(|reg| reg.node.type_id); + v } /// The bare `NodeRegistration` map for the nodegraph compiler @@ -140,6 +147,30 @@ pub fn registry() -> &'static BrushNodeRegistry { REGISTRY.get_or_init(BrushNodeRegistry::build) } +/// Id of the catalog this registry projects into. +pub const CATALOG_ID: &str = "brushNodes"; + +/// The brush-node catalog — every registered node type, sorted by `type_id`. +/// +/// Entries carry no `params`: see +/// [`NodeRegistration::catalog_entry`](crate::nodegraph::NodeRegistration::catalog_entry) +/// for why a port is not a parameter. +pub fn catalog() -> crate::catalog::Catalog { + crate::catalog::Catalog::new( + CATALOG_ID, + "Brush Nodes", + registry() + .types() + .into_iter() + .map(|reg| reg.node.catalog_entry()) + .collect(), + ) + .with_description( + "The signal blocks a brush graph is built from — inputs, math, shapes, \ + and the terminals that put pigment down.", + ) +} + /// Convenience over [`crate::nodegraph::Graph::find_terminal`] for /// brush graphs: builds a fresh [`BrushNodeRegistry`] and delegates. /// Use this from any graph-only call site (benches, tests, the WASM @@ -161,20 +192,24 @@ pub struct BrushGraphCapabilities { /// terminal registers `supports_erase = false`. The brush-tool /// options bar hides the erase toggle when false. pub supports_erase: bool, - /// Iconify icon to show in place of baked dab/stroke thumbnails, - /// contributed by the first node whose registration sets - /// `preview_fallback_icon` — content-dependent nodes (clone, blur, - /// smudge, liquify) whose preview bake renders blank. + /// Iconify icon to show in the dab slot in place of a baked thumbnail, + /// contributed by the first node whose registration declares + /// `preview_staging` — content-dependent nodes (clone, blur, smudge, + /// liquify) whose still-dab bake renders blank. pub preview_fallback_icon: Option<&'static str>, + /// Field the stroke preview is rendered over, from the same declaration + /// the icon comes from. [`PreviewBackdrop::Flat`] for a brush that deposits + /// pigment and so needs nothing staged under it. + pub preview_backdrop: PreviewBackdrop, } /// Derive [`BrushGraphCapabilities`] from a graph in one registry walk. /// /// Type-owned dispatch — each node's `register()` declares its own -/// `supports_erase` / `preview_fallback_icon`; nothing here knows which +/// `supports_erase` / `preview_staging`; nothing here knows which /// node types exist. Nodes are visited terminals-first, then ascending /// id ([`Graph::nodes`] is a HashMap, so raw iteration order would make -/// the "first icon wins" rule nondeterministic on multi-icon graphs). +/// the "first staging wins" rule nondeterministic on multi-staging graphs). pub fn graph_capabilities( graph: &crate::nodegraph::Graph, ) -> BrushGraphCapabilities { @@ -195,13 +230,17 @@ pub fn graph_capabilities( let mut caps = BrushGraphCapabilities { supports_erase: true, preview_fallback_icon: None, + preview_backdrop: PreviewBackdrop::Flat, }; + let mut staged = false; for (_, reg) in nodes { if reg.is_terminal && !reg.supports_erase { caps.supports_erase = false; } - if caps.preview_fallback_icon.is_none() { - caps.preview_fallback_icon = reg.preview_fallback_icon; + if let (false, Some(staging)) = (staged, reg.preview_staging) { + caps.preview_fallback_icon = Some(staging.icon); + caps.preview_backdrop = staging.backdrop; + staged = true; } } caps @@ -620,4 +659,27 @@ mod tests { runner.err() ); } + + /// The documentation projection covers the registry exactly, in the + /// registry's own order. Catches a node type that stops being projected, + /// and the `HashMap` iteration order that would otherwise make + /// `metadata.json` differ between the exporter process and any other. + #[test] + fn the_node_catalog_covers_every_registered_node() { + let catalog = super::catalog(); + let registered: Vec<&str> = super::registry() + .types() + .into_iter() + .map(|reg| reg.node.type_id) + .collect(); + let projected: Vec<&str> = catalog.entries.iter().map(|e| e.type_id).collect(); + assert_eq!(projected, registered, "brushNodes is not the registry"); + + let mut sorted = projected.clone(); + sorted.sort_unstable(); + assert_eq!( + projected, sorted, + "brushNodes entries are not sorted by type id" + ); + } } diff --git a/crates/darkly/src/brush/node.rs b/crates/darkly/src/brush/node.rs index 2bdb8e6c..5a98ab2b 100644 --- a/crates/darkly/src/brush/node.rs +++ b/crates/darkly/src/brush/node.rs @@ -62,8 +62,26 @@ pub struct BrushNodeRegistration { pub evaluator: fn() -> Box, /// Framework-managed stroke prologue. See [`Lifecycle`]. pub lifecycle: Lifecycle, + /// Texel format of the stroke scratch this terminal renders into. + /// + /// Color terminals leave this at [`COLOR_SCRATCH_FORMAT`]. Warp + /// terminals accumulate a displacement field rather than pixels and + /// declare a two-channel float format instead — the scratch *is* the + /// field, so everything that already tracks the scratch (grow, rebase, + /// read mirror, checkpoint ring) tracks the field for free. See + /// [`crate::brush::warp_field`]. + /// + /// The stroke buffer pairs the format with the matching canvas-copy + /// bind group layout via + /// [`BrushPipelines::canvas_copy_layout_for`](crate::brush::pipeline::BrushPipelines::canvas_copy_layout_for), + /// so a terminal never has to think about filterability. + pub scratch_format: wgpu::TextureFormat, } +/// Scratch format for terminals that accumulate colour — the default, and +/// what every terminal but `liquify` uses. +pub const COLOR_SCRATCH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + impl BrushNodeRegistration { /// Construct a compute-only node (no GPU pipelines, no lifecycle). pub fn compute( @@ -75,6 +93,7 @@ impl BrushNodeRegistration { pipelines: Vec::new(), evaluator, lifecycle: Lifecycle::None, + scratch_format: COLOR_SCRATCH_FORMAT, } } diff --git a/crates/darkly/src/brush/nodes/blur.rs b/crates/darkly/src/brush/nodes/blur.rs index 1bcfba33..40f33739 100644 --- a/crates/darkly/src/brush/nodes/blur.rs +++ b/crates/darkly/src/brush/nodes/blur.rs @@ -40,6 +40,7 @@ use crate::brush::read_mirror_terminal::{ }; use crate::brush::wgsl::{CompileWgslCtx, DabField, NodeWgsl, WgslType}; use crate::brush::wire::{BrushWireType, ScalarValue}; +use crate::gpu::preview::{PreviewBackdrop, PreviewStaging}; use crate::nodegraph::{NodeRegistration, PortDef, UnitType}; /// Per-dab strength below which the dab is dropped — `mix(orig, blurred, 0)` @@ -68,6 +69,7 @@ pub fn register() -> BrushNodeRegistration { pipelines: vec![read_mirror_pipeline_reg("blur")], evaluator: || Box::new(BlurEvaluator), lifecycle: crate::brush::node::Lifecycle::SeedScratchFromPreStroke, + scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT, node: NodeRegistration { type_id: TYPE_ID, category: "output", @@ -91,6 +93,16 @@ pub fn register() -> BrushNodeRegistration { .with_unit(UnitType::Percent) .with_icon("fa6-solid:gauge-high") .exposed() + // A preview stroke is read at a canonical strength, not at + // the brush's own. The default's kernel is + // `0.05 * 36 * MAX_KERNEL_FRACTION` ≈ 0.45 px against a + // preview dab radius of ~36 px, so at the shipped value the + // stroke changes a 194 x 35 patch of a 1024 x 768 canvas and + // the thumbnail framer crops that fragment and blows it up + // to fill the tile — two stripes and no stroke. Pinned, the + // stroke spans the S-curve and the tile is a picture of it. + // The smallest value measured to do that with margin. + .with_preview_value(0.6) .with_description( "How wide a neighborhood each touch averages, as a fraction of the brush \ radius. Higher values soften more per touch.", @@ -115,7 +127,10 @@ pub fn register() -> BrushNodeRegistration { is_gpu: true, is_terminal: true, supports_erase: false, - preview_fallback_icon: Some("mdi:blur"), + preview_staging: Some(PreviewStaging { + icon: "mdi:blur", + backdrop: PreviewBackdrop::Stripes, + }), }, } } diff --git a/crates/darkly/src/brush/nodes/brush_settings.rs b/crates/darkly/src/brush/nodes/brush_settings.rs index ac5296f8..f2016bc5 100644 --- a/crates/darkly/src/brush/nodes/brush_settings.rs +++ b/crates/darkly/src/brush/nodes/brush_settings.rs @@ -163,7 +163,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, || Box::new(BrushSettingsEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/circle.rs b/crates/darkly/src/brush/nodes/circle.rs index 88c702de..cef31af5 100644 --- a/crates/darkly/src/brush/nodes/circle.rs +++ b/crates/darkly/src/brush/nodes/circle.rs @@ -44,6 +44,7 @@ pub fn register() -> BrushNodeRegistration { pipelines: vec![], evaluator: || Box::new(ShapeEvaluator), lifecycle: crate::brush::node::Lifecycle::None, + scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT, node: NodeRegistration { type_id: TYPE_ID, category: "shape", @@ -177,7 +178,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: true, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, } } diff --git a/crates/darkly/src/brush/nodes/clone_source.rs b/crates/darkly/src/brush/nodes/clone_source.rs index 4133e4c9..3995acbb 100644 --- a/crates/darkly/src/brush/nodes/clone_source.rs +++ b/crates/darkly/src/brush/nodes/clone_source.rs @@ -63,6 +63,7 @@ use crate::brush::wgsl::{ sample_graph_texture, CompileWgslCtx, InputBinding, NodeWgsl, UniformField, WgslType, }; use crate::brush::wire::{BrushWireType, ScalarValue}; +use crate::gpu::preview::{PreviewBackdrop, PreviewStaging}; use crate::nodegraph::{NodeRegistration, PortDef}; pub const TYPE_ID: &str = "clone_source"; @@ -73,7 +74,7 @@ pub fn register() -> BrushNodeRegistration { type_id: TYPE_ID, category: "texture", display_name: "Clone Source", - description: "Samples pixels from a set source point onto the canvas under your cursor. Set the source with the clone set-source gesture, then paint. Feed into a Stamp Tip's colour input.", + description: "Samples pixels from a set source point onto the canvas under your cursor. Set the source with the clone set-source gesture, then paint. Feed into a Stamp Tip's color input.", ports: vec![ PortDef::input("center", BrushWireType::Vec2) .with_description("Per-dab pen position in canvas pixels (wire Pen Input → Position)."), @@ -109,7 +110,10 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: Some("fa6-solid:clone"), + preview_staging: Some(PreviewStaging { + icon: "fa6-solid:clone", + backdrop: PreviewBackdrop::Stripes, + }), }, || Box::new(CloneSourceEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/curve.rs b/crates/darkly/src/brush/nodes/curve.rs index 0eebb520..f20d247f 100644 --- a/crates/darkly/src/brush/nodes/curve.rs +++ b/crates/darkly/src/brush/nodes/curve.rs @@ -45,7 +45,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, || Box::new(CurveEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/image.rs b/crates/darkly/src/brush/nodes/image.rs index 9fbc0624..ce7d16ce 100644 --- a/crates/darkly/src/brush/nodes/image.rs +++ b/crates/darkly/src/brush/nodes/image.rs @@ -91,7 +91,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, || Box::new(ImageEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/levels.rs b/crates/darkly/src/brush/nodes/levels.rs index 4ed927f6..3b892cb3 100644 --- a/crates/darkly/src/brush/nodes/levels.rs +++ b/crates/darkly/src/brush/nodes/levels.rs @@ -54,7 +54,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, || Box::new(LevelsEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/liquify.rs b/crates/darkly/src/brush/nodes/liquify.rs index 23be8b11..21df5234 100644 --- a/crates/darkly/src/brush/nodes/liquify.rs +++ b/crates/darkly/src/brush/nodes/liquify.rs @@ -1,4 +1,4 @@ -//! Liquify terminal — per-dab fragment-pass warp with a per-brush +//! Liquify terminal — per-dab displacement-field warp with a per-brush //! compiled WGSL shader. //! //! Rides the shared [read-mirror terminal](crate::brush::read_mirror_terminal) @@ -7,26 +7,68 @@ //! file owns only what's liquify-specific: the read half-extent and the //! variant WGSL (including the softness falloff helper). //! -//! Per dab the fragment shader samples the scratch read mirror at a -//! *displaced* UV inside a circular brush disc and writes the warped -//! sample back into the scratch. Successive dabs compound because each -//! reads the cumulatively-warped scratch — the per-dab serialization is -//! semantically required, not a perf bug. +//! Unlike its read-mirror siblings, liquify's scratch holds a +//! [warp field](crate::brush::warp_field) rather than colour. Per dab the +//! fragment shader advects the accumulated displacement and adds this +//! dab's own — it never touches a pixel. The picture is produced once, at +//! commit, by sampling the pre-stroke snapshot through the finished +//! field. +//! +//! That is not a shortcut around per-dab compounding, it is how the +//! compounding is made lossless. Later dabs still displace content +//! earlier dabs displaced: the `field(p + nv)` term reads the previous +//! field at the displaced location and carries it along, which composes +//! the two maps exactly. What it does *not* do is resample the picture +//! each time — at 4 px spacing under a 77 px brush that was ~38 chained +//! bilinear filters per swipe, and a chain of bilinear filters is a +//! low-pass cascade. Detail is now independent of dab count. +//! +//! The per-dab serialization is therefore still semantically required, +//! not a perf bug: dab *n+1* must read dab *n*'s field. +//! +//! Inherited caveat, unchanged by the field model: the read-mirror fetch +//! addresses the mirror by `textureDimensions`, while only the copied +//! `copy_w × copy_h` sub-rect of that lazily-grown texture is valid +//! (`scratch.rs`), so a dab clipped at the layer edge can address stale +//! texels. The helper clamps to the texture, not to the valid rect. //! //! Displacement magnitude is `strength × |pen.motion|` — the cursor's -//! per-dab travel scaled by strength. With the Liquify brush's fixed -//! `pen_input.spacing_min_px = LIQUIFY_SPACING_PX`, `|motion|` is the -//! same constant at any brush size, so: +//! per-dab travel scaled by strength. So: //! * `strength = 1` locks pixels to the cursor (per-dab push = //! per-dab cursor motion); -//! * `strength < 1` produces a strength-fraction drag; -//! * brush size controls only the warped *extent* (the disc), never -//! the *intensity*. +//! * `strength < 1` produces a strength-fraction drag. //! //! Pen speed enters only via dab density along the path; the per-dab -//! push is identical for slow and fast drags. **Liquify is deliberately -//! size-invariant** — the size slider scales the warped extent, not the -//! push strength. +//! push is identical for slow and fast drags. +//! +//! ## Why spacing is proportional, not pinned +//! +//! Dab spacing uses the ordinary proportional rule +//! ([`SpacingConfig`](crate::brush::spacing::SpacingConfig)) at +//! [`LIQUIFY_SPACING_RATIO`], rather than the flat pixel floor it once +//! carried. Total displacement over a drag does not depend on spacing: +//! per dab it is `strength × |motion| = strength × spacing`, and a drag +//! of length `L` places `L / spacing` dabs, so the total is +//! `strength × L` — spacing cancels. It only sets how finely the warp is +//! discretised. +//! +//! That is a property of accumulating a *field*. Under the per-dab image +//! warp this replaced, spacing also cancelled geometrically, but each dab +//! cost a resample — so the dab count could not be traded for performance +//! without trading away detail, and the spacing was pinned flat at 4 px. +//! Pinned spacing makes cost `O(radius²)` per unit of travel: dab count +//! stays constant while each dab's mirror copy and fragment pass grow +//! with the disc. Proportional spacing makes it `O(radius)`. +//! +//! The ratio is bounded by banding, not by intensity. Measured on a +//! straight drag at radius 76.8: peak displacement moves 45.01 → 45.11 px +//! (+0.2 %) from 4 px to 8 px spacing, then 45.52 at 16 px and 48.71 +//! (+8 %, visibly stepped) at 32 px. Spacing up to ~0.1 × radius is +//! faithful; beyond that the discretisation starts showing. +//! +//! GIMP's warp tool reaches the same place — `step = effect_size × +//! stroke_spacing / 100` (`app/tools/gimpwarptool.c:432`), spacing +//! proportional to brush size, at a comparable default density. //! //! ## Softness waveshape //! @@ -49,28 +91,28 @@ use crate::brush::read_mirror_terminal::{ }; use crate::brush::wgsl::{CompileWgslCtx, NodeWgsl}; use crate::brush::wire::{BrushWireType, ScalarValue}; +use crate::gpu::preview::{PreviewBackdrop, PreviewStaging}; use crate::nodegraph::{NodeRegistration, PortDef, UnitType}; // ── Constants ─────────────────────────────────────────────────────────── -/// Dab spacing for the Liquify brush, in canvas pixels. The brush -/// pins `pen_input.spacing_min_px` to this value (and sets ratio to -/// zero) so spacing stays fixed at any brush size. Per-dab -/// displacement is then `strength × |pen.motion| ≈ strength × -/// LIQUIFY_SPACING_PX`, which makes: -/// * `strength = 1` lock pixels to the cursor (per-dab push equals -/// per-dab cursor motion); -/// * `strength = 0.5` lag the cursor by 50% (the "drag" feel); -/// * the absolute pixel push size-invariant — the size slider -/// controls the warped *extent* (the disc), not the *intensity*. +/// Dab spacing for the Liquify brush, as a fraction of dab **diameter** +/// — the value `brushes/liquify.yaml` sets on `brush_settings.spacing`. +/// +/// 0.05 of diameter is 0.1 of radius, the density the module doc's +/// measurements put at the edge of faithful: at that spacing the warp is +/// within 0.2 % of a 4 px reference, and it holds the per-unit-travel +/// cost at `O(radius)` instead of the `O(radius²)` a pinned pixel +/// spacing forced. /// -/// Tuned to 4 px: tight enough for smooth-looking warps without dab -/// banding, large enough not to blow up the dab count at huge -/// brushes (perf scales with `diameter / spacing`). -pub const LIQUIFY_SPACING_PX: f32 = 4.0; +/// Declared here rather than only in the YAML because it is a property +/// of how this terminal behaves, and because the module doc's reasoning +/// is what justifies the number. Keep the two in step. +pub const LIQUIFY_SPACING_RATIO: f32 = 0.05; -/// Per-dab strength below which the dab is dropped — `mix(orig, warped, _·sel)` -/// collapses to identity and the per-dab pass would be a no-op. +/// Per-dab strength below which the dab is dropped — the dab's +/// displacement collapses to zero, so advecting the field by it and +/// adding it back is an identity write. const STRENGTH_EPSILON: f32 = 1.0e-4; /// Brush radius below which the dab is dropped — sub-pixel discs warp @@ -88,7 +130,12 @@ pub fn register() -> BrushNodeRegistration { BrushNodeRegistration { pipelines: vec![read_mirror_pipeline_reg("liquify")], evaluator: || Box::new(LiquifyEvaluator), - lifecycle: crate::brush::node::Lifecycle::SeedScratchFromPreStroke, + // A transparent clear *is* a zero field: no displacement + // anywhere, so the first resolve reproduces the pre-stroke image + // exactly. Seeding from pre-stroke would be meaningless — the + // scratch holds offsets, not colour. + lifecycle: crate::brush::node::Lifecycle::ClearScratchToTransparent, + scratch_format: crate::brush::warp_field::FIELD_FORMAT, node: NodeRegistration { type_id: TYPE_ID, category: "output", @@ -156,7 +203,10 @@ pub fn register() -> BrushNodeRegistration { is_gpu: true, is_terminal: true, supports_erase: false, - preview_fallback_icon: Some("tabler:ripple"), + preview_staging: Some(PreviewStaging { + icon: "tabler:ripple", + backdrop: PreviewBackdrop::Stripes, + }), }, } } @@ -230,17 +280,28 @@ impl ReadMirrorTerminal for LiquifyEvaluator { \x20 let t = (softness - sine_break) / (1.0 - sine_break);\n\ \x20 return mix(sine, 1.0, t);\n\ \x20 }}\n\ - }}\n" + }}\n\ + {}\n", + crate::brush::warp_field::FIELD_HELPERS_WGSL, ); // Fragment body: `local_dist` and `target_pos` come from the // framework wrapper; the framework already discards past // `d.bbox_target_px`. We additionally discard past - // `local_dist >= 1.0` so the warp stays inside the disc. + // `local_dist >= 1.0` so the warp stays outside the disc alone. // The falloff helper takes `0 = spike` / `1 = square`. The // user-facing slider is labelled "Softness" with the opposite // intuition — `1 = soft / feathery`, `0 = hard / sharp`. Invert // before passing to the helper so the slider matches the label. + // + // `sel` and `warp_mask` scale the *displacement*, never the + // result. A geometric operation that cross-faded warped against + // unwarped colour would be a literal double exposure inside a + // soft selection edge; here every output pixel remains exactly + // one sample of the source, and a masked-out fragment simply + // contributes a zero offset (leaving the accumulated field + // untouched). + let offset_expr = "-dir * (length(motion_vec) * strength) * f * sel * warp_mask"; wgsl.body = format!( " if (local_dist >= 1.0) {{ discard; }}\n\ \x20 let warp_mask = clamp({mask_expr}, 0.0, 1.0);\n\ @@ -251,14 +312,8 @@ impl ReadMirrorTerminal for LiquifyEvaluator { \x20 let motion_vec = {motion_expr};\n\ \x20 let f = {falloff_fn}(local_dist, falloff_param);\n\ \x20 let dir = vec2(cos(direction_angle), sin(direction_angle));\n\ - \x20 let displacement = length(motion_vec) * strength;\n\ - \x20 let source_pos = target_pos - dir * displacement * f;\n\ - \x20 let mirror_dims = vec2(textureDimensions(scratch_mirror_tex));\n\ - \x20 let copy_uv = (source_pos - d.{copy_origin_field}) / mirror_dims;\n\ - \x20 let warped = textureSampleLevel(scratch_mirror_tex, scratch_mirror_smp, copy_uv, 0.0);\n\ - \x20 let original_uv = (target_pos - d.{copy_origin_field}) / mirror_dims;\n\ - \x20 let original = textureSampleLevel(scratch_mirror_tex, scratch_mirror_smp, original_uv, 0.0);\n\ - \x20 return mix(original, warped, sel * warp_mask);\n", + {}", + crate::brush::warp_field::advect_wgsl(offset_expr, copy_origin_field), ); Ok(wgsl) diff --git a/crates/darkly/src/brush/nodes/noise.rs b/crates/darkly/src/brush/nodes/noise.rs index 9d53a881..798b0b54 100644 --- a/crates/darkly/src/brush/nodes/noise.rs +++ b/crates/darkly/src/brush/nodes/noise.rs @@ -141,7 +141,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, || Box::new(NoiseEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/paint.rs b/crates/darkly/src/brush/nodes/paint.rs index 1c05aaab..869e613f 100644 --- a/crates/darkly/src/brush/nodes/paint.rs +++ b/crates/darkly/src/brush/nodes/paint.rs @@ -359,6 +359,7 @@ pub fn register() -> BrushNodeRegistration { pipelines: vec![paint_pipeline_reg()], evaluator: || Box::new(PaintEvaluator), lifecycle: crate::brush::node::Lifecycle::ClearScratchToTransparent, + scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT, node: NodeRegistration { type_id: TYPE_ID, category: "output", @@ -406,7 +407,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: true, is_terminal: true, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, } } diff --git a/crates/darkly/src/brush/nodes/paint_color.rs b/crates/darkly/src/brush/nodes/paint_color.rs index c6f1b554..50492815 100644 --- a/crates/darkly/src/brush/nodes/paint_color.rs +++ b/crates/darkly/src/brush/nodes/paint_color.rs @@ -26,7 +26,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, || Box::new(PaintColorEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/pen_input.rs b/crates/darkly/src/brush/nodes/pen_input.rs index f8a09fa2..5a7cc653 100644 --- a/crates/darkly/src/brush/nodes/pen_input.rs +++ b/crates/darkly/src/brush/nodes/pen_input.rs @@ -86,7 +86,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, || Box::new(PenInputEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/polygon.rs b/crates/darkly/src/brush/nodes/polygon.rs index 8152d46f..54ca60a3 100644 --- a/crates/darkly/src/brush/nodes/polygon.rs +++ b/crates/darkly/src/brush/nodes/polygon.rs @@ -32,6 +32,7 @@ pub fn register() -> BrushNodeRegistration { pipelines: vec![], evaluator: || Box::new(PolygonEvaluator), lifecycle: crate::brush::node::Lifecycle::None, + scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT, node: NodeRegistration { type_id: TYPE_ID, // Shared UI grouping with `circle` and `stamp` — the tip @@ -127,7 +128,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: true, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, } } diff --git a/crates/darkly/src/brush/nodes/random.rs b/crates/darkly/src/brush/nodes/random.rs index ffa7d9e3..fb911294 100644 --- a/crates/darkly/src/brush/nodes/random.rs +++ b/crates/darkly/src/brush/nodes/random.rs @@ -48,7 +48,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, || Box::new(RandomEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/smudge.rs b/crates/darkly/src/brush/nodes/smudge.rs index 22851f44..d6c722be 100644 --- a/crates/darkly/src/brush/nodes/smudge.rs +++ b/crates/darkly/src/brush/nodes/smudge.rs @@ -28,6 +28,7 @@ use crate::brush::read_mirror_terminal::{ }; use crate::brush::wgsl::{CompileWgslCtx, NodeWgsl}; use crate::brush::wire::{BrushWireType, ScalarValue}; +use crate::gpu::preview::{PreviewBackdrop, PreviewStaging}; use crate::nodegraph::{NodeRegistration, PortDef, UnitType}; /// Motion magnitude (canvas pixels) below which the dab is treated as @@ -42,6 +43,7 @@ pub fn register() -> BrushNodeRegistration { pipelines: vec![read_mirror_pipeline_reg("smudge")], evaluator: || Box::new(SmudgeEvaluator), lifecycle: crate::brush::node::Lifecycle::SeedScratchFromPreStroke, + scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT, node: NodeRegistration { type_id: TYPE_ID, category: "output", @@ -91,7 +93,10 @@ pub fn register() -> BrushNodeRegistration { is_gpu: true, is_terminal: true, supports_erase: false, - preview_fallback_icon: Some("mdi:gesture-swipe"), + preview_staging: Some(PreviewStaging { + icon: "mdi:gesture-swipe", + backdrop: PreviewBackdrop::Stripes, + }), }, } } diff --git a/crates/darkly/src/brush/nodes/split_color.rs b/crates/darkly/src/brush/nodes/split_color.rs index 0cca670d..eb232bbc 100644 --- a/crates/darkly/src/brush/nodes/split_color.rs +++ b/crates/darkly/src/brush/nodes/split_color.rs @@ -47,7 +47,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, || Box::new(SplitColorEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/stamp.rs b/crates/darkly/src/brush/nodes/stamp.rs index 41284849..dd0daf09 100644 --- a/crates/darkly/src/brush/nodes/stamp.rs +++ b/crates/darkly/src/brush/nodes/stamp.rs @@ -23,6 +23,7 @@ pub fn register() -> BrushNodeRegistration { pipelines: vec![], evaluator: || Box::new(StampEvaluator), lifecycle: crate::brush::node::Lifecycle::None, + scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT, node: NodeRegistration { type_id: TYPE_ID, category: "shape", @@ -40,7 +41,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: true, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, } } diff --git a/crates/darkly/src/brush/nodes/switch.rs b/crates/darkly/src/brush/nodes/switch.rs index c5274988..cf8ab14b 100644 --- a/crates/darkly/src/brush/nodes/switch.rs +++ b/crates/darkly/src/brush/nodes/switch.rs @@ -90,7 +90,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, || Box::new(SwitchEvaluator), ) diff --git a/crates/darkly/src/brush/nodes/watercolor.rs b/crates/darkly/src/brush/nodes/watercolor.rs index 79ff559a..ad772063 100644 --- a/crates/darkly/src/brush/nodes/watercolor.rs +++ b/crates/darkly/src/brush/nodes/watercolor.rs @@ -580,6 +580,7 @@ pub fn register() -> BrushNodeRegistration { pipelines: vec![watercolor_pipeline_reg()], evaluator: || Box::new(WatercolorEvaluator), lifecycle: crate::brush::node::Lifecycle::ClearScratchToTransparent, + scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT, node: NodeRegistration { type_id: TYPE_ID, category: "output", @@ -652,7 +653,7 @@ pub fn register() -> BrushNodeRegistration { is_gpu: true, is_terminal: true, supports_erase: false, - preview_fallback_icon: None, + preview_staging: None, }, } } diff --git a/crates/darkly/src/brush/pipeline.rs b/crates/darkly/src/brush/pipeline.rs index 2797ea84..6cc3fd7e 100644 --- a/crates/darkly/src/brush/pipeline.rs +++ b/crates/darkly/src/brush/pipeline.rs @@ -222,7 +222,10 @@ pub struct BrushPipelineRegistration { /// dropping its registration into this list; the harvest loop picks /// it up automatically. pub fn plumbing_registrations() -> Vec { - vec![crate::brush::composite_pipeline::composite_pipeline_registration()] + vec![ + crate::brush::composite_pipeline::composite_pipeline_registration(), + crate::brush::warp_field::warp_field_resolve_registration(), + ] } // ── BrushPipelines: shared infra + plumbing + per-node registry ────────── @@ -250,9 +253,14 @@ pub struct BrushPipelines { uniform_bgl: wgpu::BindGroupLayout, selection_bgl: wgpu::BindGroupLayout, canvas_copy_bgl: wgpu::BindGroupLayout, + /// Non-filtering twin of `canvas_copy_bgl`, for scratches holding a + /// float32 warp field rather than colour. + canvas_copy_unfilterable_bgl: wgpu::BindGroupLayout, // ── Shared samplers / default bind groups ──────────────────────── canvas_copy_sampler: wgpu::Sampler, + /// Nearest sampler paired with `canvas_copy_unfilterable_bgl`. + canvas_copy_nearest_sampler: wgpu::Sampler, /// 1×1 white selection (= fully selected). Bound when no selection /// is active. `pub` because hot-path call sites take its address /// directly via `unwrap_or(&self.brush_pipelines.default_selection_bind_group)`. @@ -351,6 +359,35 @@ impl BrushPipelines { ], }); + // Same shape, non-filtering. Warp terminals put a float32 + // displacement field in the scratch, and `Rg32Float` is not + // filterable in core WebGPU (only with the optional + // `float32-filterable` feature). Their shaders fetch it with + // `textureLoad` and interpolate by hand, so a non-filtering + // layout costs them nothing. + let canvas_copy_unfilterable_bgl = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("brush-canvas-copy-unfilterable-bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + ], + }); + // ── Default selection (1×1 white = fully selected) ───────── let sel_texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("brush-default-selection"), @@ -417,6 +454,17 @@ impl BrushPipelines { ..Default::default() }); + // Nearest counterpart, for the non-filtering layout above. Warp + // terminals never sample through it — it exists because the + // layout declares a sampler slot — but a `Filtering` sampler is + // illegal against a `NonFiltering` entry, so it must be nearest. + let canvas_copy_nearest_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("brush-canvas-copy-nearest-sampler"), + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + // ── Plumbing pipelines (no owning node) ──────────────────── // Blit: stretch a UV sub-rect of the source across the target viewport. @@ -591,7 +639,9 @@ impl BrushPipelines { uniform_bgl, selection_bgl, canvas_copy_bgl, + canvas_copy_unfilterable_bgl, canvas_copy_sampler, + canvas_copy_nearest_sampler, default_selection_bind_group, blit_pipeline, blit_uniform_ring, @@ -713,6 +763,30 @@ impl BrushPipelines { &self.canvas_copy_sampler } + /// The canvas-copy BGL + sampler a `Scratch` of `format` must be built + /// against. Color scratches get the filtering pair; float32 warp + /// fields get the non-filtering pair, because `Rg32Float` is not + /// filterable without the optional `float32-filterable` feature. + /// Callers pass the result straight to `Scratch::new` — this is the + /// single place the pairing is decided. + pub fn canvas_copy_layout_for( + &self, + format: wgpu::TextureFormat, + ) -> (&wgpu::BindGroupLayout, &wgpu::Sampler) { + if format + .guaranteed_format_features(wgpu::Features::empty()) + .flags + .contains(wgpu::TextureFormatFeatureFlags::FILTERABLE) + { + (&self.canvas_copy_bgl, &self.canvas_copy_sampler) + } else { + ( + &self.canvas_copy_unfilterable_bgl, + &self.canvas_copy_nearest_sampler, + ) + } + } + /// The 1×1 white selection bind group — bound when no selection is /// active. Exposed for out-of-crate tests that construct a /// `BrushGpuContext` manually and need a default selection mask. diff --git a/crates/darkly/src/brush/portable.rs b/crates/darkly/src/brush/portable.rs index 70e58d8d..1b191067 100644 --- a/crates/darkly/src/brush/portable.rs +++ b/crates/darkly/src/brush/portable.rs @@ -93,6 +93,18 @@ pub struct PortableNode { /// a compact diff. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub inputs: BTreeMap, + /// Per-input slider-bound overrides, keyed by input name, as + /// `[min, max]`. Diffed against the registration exactly like `inputs`, + /// so only genuinely re-ranged ports serialize. + /// + /// Where `inputs` authors *where the knob sits*, this authors *how far it + /// travels* — the escape hatch for a port whose registration range is a + /// poor fit for one brush. A math node declaring `0..1` can be given a + /// bipolar `[-1.0, 1.0]` control, or a port whose useful band is a sliver + /// of its declared range can be narrowed onto it, without a helper node + /// in the graph doing the arithmetic. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub ranges: BTreeMap, } /// A wire serialized as `". -> ."`. @@ -208,12 +220,28 @@ impl PortableBrush { } } + // Slider bounds: the same diff-against-registration treatment, + // so a brush that never re-ranges anything emits no `ranges` key. + let mut ranges = BTreeMap::new(); + for port in &node.ports { + if port.dir != PortDir::Input { + continue; + } + let Some(reg_port) = reg.ports.iter().find(|p| p.name == port.name) else { + continue; + }; + if reg_port.min != port.min || reg_port.max != port.max { + ranges.insert(port.name.clone(), [port.min, port.max]); + } + } + nodes.insert( id.0.clone(), PortableNode { type_id: node.type_id.clone(), comment: node.comment.clone(), inputs, + ranges, }, ); } @@ -316,6 +344,14 @@ impl PortableBrush { .set_node_comment(&new_id, pn.comment.clone()) .expect("node just added by add_node must exist"); } + // Applied through the graph setter rather than onto the cloned + // ports above so the ascending-and-finite invariant is enforced + // in one place for every author — yaml, editor, and paste alike. + for (name, [min, max]) in &pn.ranges { + graph + .set_port_range(&new_id, name, *min, *max) + .map_err(|e| format!("range '{name}' on '{}': {e}", pn.type_id))?; + } id_map.insert(yaml_id.clone(), new_id); } @@ -471,6 +507,69 @@ mod tests { assert_eq!(meta.icon, "fa6-solid:circle-half-stroke"); } + /// A re-ranged port survives the yaml round trip, and a graph that + /// re-ranges nothing emits no `ranges` key at all — the diff stays a + /// diff, so untouched brushes' yaml doesn't churn. + #[test] + fn port_ranges_survive_and_stay_a_diff() { + let registry = registry(); + let mut graph = crate::brush::default_graph(); + let circle = graph + .nodes() + .iter() + .find(|(_, n)| n.type_id == "circle") + .map(|(id, _)| id.clone()) + .expect("default has a circle node"); + + // Untouched graph: no node carries a `ranges` entry. + let clean = PortableBrush::from_graph_only(&graph, registry).unwrap(); + assert!( + clean.nodes.values().all(|n| n.ranges.is_empty()), + "unmodified graph must not serialize any ranges" + ); + assert!(!serde_yaml_ng::to_string(&clean).unwrap().contains("ranges")); + + graph + .set_port_range(&circle, "softness", -1.0, 2.5) + .unwrap(); + + let portable = PortableBrush::from_graph_only(&graph, registry).unwrap(); + let yaml = serde_yaml_ng::to_string(&portable).unwrap(); + let restored = serde_yaml_ng::from_str::(&yaml) + .unwrap() + .into_graph(registry) + .unwrap(); + let port = restored + .nodes() + .values() + .find(|n| n.type_id == "circle") + .expect("restored graph has a circle node") + .ports + .iter() + .find(|p| p.name == "softness") + .unwrap(); + assert_eq!((port.min, port.max), (-1.0, 2.5)); + } + + /// A hand-edited yaml carrying a degenerate or inverted range is + /// rejected at import rather than producing a control whose normalize + /// and clamp arithmetic silently misbehaves. + #[test] + fn inverted_yaml_range_is_rejected_at_import() { + let registry = registry(); + let graph = crate::brush::default_graph(); + let mut portable = PortableBrush::from_graph_only(&graph, registry).unwrap(); + let circle = portable + .nodes + .values_mut() + .find(|n| n.type_id == "circle") + .expect("default has a circle node"); + circle.ranges.insert("softness".into(), [1.0, 0.0]); + + let err = portable.into_graph(registry).unwrap_err(); + assert!(err.contains("softness"), "unexpected error: {err}"); + } + /// The unified model's headline guarantee: a non-wirable input (here /// the circle node's `algorithm` Enum) is fully *exposable*, and both the /// exposure and a non-default enum value survive a YAML round trip. diff --git a/crates/darkly/src/brush/preview_renderer.rs b/crates/darkly/src/brush/preview_renderer.rs index 839a8463..54b0ede9 100644 --- a/crates/darkly/src/brush/preview_renderer.rs +++ b/crates/darkly/src/brush/preview_renderer.rs @@ -22,19 +22,40 @@ use super::stabilizer::PassThrough; use super::stroke_buffer::StrokeBuffer; use super::stroke_engine::StrokeEngine; use super::wire::BrushWireType; +use crate::gpu::preview::PreviewBackdrop; use crate::nodegraph::Graph; +/// Stroke seed every preview render uses. +/// +/// A preview is a picture of a brush, not of one stroke of it: five shipped +/// brushes contain `random`/`noise` nodes, and seeding those from the clock +/// would make a cached thumbnail differ from its own re-bake and a +/// documentation asset differ from its own rebuild. The value is arbitrary; that +/// it never changes is the point. +const PREVIEW_STROKE_SEED: u32 = 0x5EED_B00C; + /// Reusable GPU scratch + layer textures for preview rendering. struct PreviewTarget { width: u32, height: u32, + /// Scratch format the cached `stroke_buffer` was built for. Part of + /// the cache key: this renderer is reused across brushes, and a warp + /// terminal's scratch holds a float field rather than colour, so a + /// buffer cached for one is unbindable by the other. + scratch_format: wgpu::TextureFormat, layer_texture: wgpu::Texture, layer_view: wgpu::TextureView, stroke_buffer: StrokeBuffer, } impl PreviewTarget { - fn new(device: &wgpu::Device, width: u32, height: u32, pipelines: &BrushPipelines) -> Self { + fn new( + device: &wgpu::Device, + width: u32, + height: u32, + pipelines: &BrushPipelines, + scratch_format: wgpu::TextureFormat, + ) -> Self { let layer_texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("brush-preview-layer"), size: wgpu::Extent3d { @@ -53,10 +74,11 @@ impl PreviewTarget { view_formats: &[], }); let layer_view = layer_texture.create_view(&wgpu::TextureViewDescriptor::default()); - let stroke_buffer = StrokeBuffer::new(device, width, height, pipelines); + let stroke_buffer = StrokeBuffer::new(device, width, height, pipelines, scratch_format); Self { width, height, + scratch_format, layer_texture, layer_view, stroke_buffer, @@ -90,6 +112,7 @@ impl BrushStrokePreviewRenderer { path: &[PaintInformation], fg_color: [f32; 4], bg_color: [f32; 4], + backdrop: PreviewBackdrop, width: u32, height: u32, base_size_override: Option, @@ -100,43 +123,46 @@ impl BrushStrokePreviewRenderer { // Fresh compile so callers can edit the graph between renders. let runner = super::compile_graph(graph).ok()?; - // Ensure scratch + layer textures match the requested size. + // Ensure scratch + layer textures match the requested size *and* + // the brush's scratch format — the cached target is shared across + // brushes, so previewing a warp terminal after a colour one must + // reallocate rather than bind a colour scratch to a field pipeline. + let scratch_format = runner.scratch_format(); let target_changed = match &self.target { - Some(t) => t.width != width || t.height != height, + Some(t) => t.width != width || t.height != height || t.scratch_format != scratch_format, None => true, }; if target_changed { - self.target = Some(PreviewTarget::new(device, width, height, pipelines)); + self.target = Some(PreviewTarget::new( + device, + width, + height, + pipelines, + scratch_format, + )); } let target = self.target.as_mut().unwrap(); - // Pre-fill the layer with the background color, then snapshot it as - // the pre-stroke. `color_output::commit` composites the stroke - // scratch onto this snapshot and writes the result back to the - // layer — so seeding `bg` here is how the background gets shown. + // Pre-fill the layer with the backdrop, then snapshot it as the + // pre-stroke. `color_output::commit` composites the stroke scratch onto + // this snapshot and writes the result back to the layer — so painting + // the backdrop here is how it gets shown. It is also the only way one + // reaches a terminal that *transports* the destination: those sample + // `source_override.unwrap_or(pre_stroke_texture)` (`gpu_context.rs`), + // and the preview captures no source snapshot, so the pre-stroke is + // what they smear, warp, blur or clone. let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("brush-preview-pre-fill"), }); - { - let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("brush-preview-bg-clear"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &target.layer_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: bg_color[0] as f64, - g: bg_color[1] as f64, - b: bg_color[2] as f64, - a: bg_color[3] as f64, - }), - store: wgpu::StoreOp::Store, - }, - })], - ..Default::default() - }); - } + backdrop.fill( + queue, + &mut encoder, + &target.layer_view, + &target.layer_texture, + (width, height), + fg_color, + bg_color, + ); let paint_target = crate::gpu::paint_target::GpuPaintTarget::from_canvas_texture( &target.layer_texture, &target.layer_view, @@ -164,16 +190,34 @@ impl BrushStrokePreviewRenderer { // to survive the crop-and-downscale to the thumbnail. The stroke // preview passes `None` and keeps the graph-driven size. let base_size = base_size_override.unwrap_or_else(|| brush_settings::base_size(graph)); - // No clone source in the editor preview — a clone brush renders - // its synthetic preview stroke without a set-source anchor. + + // A brush that transports pixels from elsewhere has nowhere to + // transport them from unless the preview says where. The offset comes + // from the backdrop — the only thing that knows what displacement + // escapes its own field — and the compiled graph says whether anything + // will use it, so no node authors a coordinate and any future + // source-sampling node gets a working preview for free. + let clone_source_anchor = runner.samples_source().then(|| { + let [du, dv] = backdrop.source_offset(); + [ + path[0].pos[0] + du * width as f32, + path[0].pos[1] + dv * height as f32, + ] + }); let mut engine = StrokeEngine::new( runner, fg_color, spacing, base_size, Box::new(PassThrough::new()), - None, + clone_source_anchor, + PREVIEW_STROKE_SEED, ); + if clone_source_anchor.is_some() { + // The snapshot being sampled is the pre-stroke, which covers the + // whole preview target. + engine.set_clone_source_frame(crate::coord::CanvasRect::from_xywh(0, 0, width, height)); + } // Pre-cooked points: pass them through a pass-through stabilizer so // `render_from_stabilized_range_to` walks them verbatim. No @@ -189,9 +233,9 @@ impl BrushStrokePreviewRenderer { macro_rules! make_gpu_ctx { ($label:expr) => {{ // The preview stroke buffer never captures a source - // snapshot — a source-sampling brush previews off its own - // (blank) pre-stroke snapshot, so the thumbnail stays - // neutral. + // snapshot, so a source-sampling brush previews off the + // pre-stroke snapshot — which is the backdrop, and is what + // gives it something to transport. let (scratch, pre_stroke_texture, pre_stroke_bind_group, source_override) = target.stroke_buffer.parts_for_brush_ctx(); BrushGpuContext { diff --git a/crates/darkly/src/brush/read_mirror_terminal.rs b/crates/darkly/src/brush/read_mirror_terminal.rs index 5c2c696c..029f98fb 100644 --- a/crates/darkly/src/brush/read_mirror_terminal.rs +++ b/crates/darkly/src/brush/read_mirror_terminal.rs @@ -141,7 +141,12 @@ struct PerBrushPipeline { } impl PerBrushPipeline { - fn build(ctx: &BuildContext, compiled: &CompiledBrush, label: &str) -> Self { + fn build( + ctx: &BuildContext, + compiled: &CompiledBrush, + label: &str, + target_format: wgpu::TextureFormat, + ) -> Self { let shader = ctx .device .create_shader_module(wgpu::ShaderModuleDescriptor { @@ -181,9 +186,13 @@ impl PerBrushPipeline { immediate_size: 0, }); - // REPLACE blend — the fragment shader writes the final pixel; + // No blending — the fragment shader writes the final value; // outside the disc it discards so LoadOp::Load preserves the - // scratch. + // scratch. `blend: None` rather than `BlendState::REPLACE` + // because a warp terminal's target is `Rg32Float`, and wgpu gates + // *any* blend state on a format being BLENDABLE, which float32 + // formats are not without the optional `FLOAT32_BLENDABLE` + // feature. A pure replace is what REPLACE meant anyway. let pipeline = ctx .device .create_render_pipeline(&wgpu::RenderPipelineDescriptor { @@ -199,8 +208,8 @@ impl PerBrushPipeline { module: &shader, entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: Some(wgpu::BlendState::REPLACE), + format: target_format, + blend: None, write_mask: wgpu::ColorWrites::ALL, })], compilation_options: Default::default(), @@ -281,11 +290,17 @@ impl ReadMirrorPipeline { } } - fn ensure_pipeline(&self, ctx: &BuildContext, compiled: &CompiledBrush, label: &str) { + fn ensure_pipeline( + &self, + ctx: &BuildContext, + compiled: &CompiledBrush, + label: &str, + target_format: wgpu::TextureFormat, + ) { let mut cache = self.cache.borrow_mut(); cache .entry(compiled.topology_hash) - .or_insert_with(|| PerBrushPipeline::build(ctx, compiled, label)); + .or_insert_with(|| PerBrushPipeline::build(ctx, compiled, label, target_format)); } fn with_pipeline(&self, hash: u64, f: impl FnOnce(&PerBrushPipeline) -> R) -> R { @@ -489,8 +504,16 @@ pub fn flush_dabs(gpu: &mut BrushGpuContext) { gpu.perf .record_dab_flush_workload(total_dabs, union_w, union_h); + // The pass renders into the scratch, so the pipeline's colour target + // and its `@group(3)` layout both follow the scratch's format — colour + // for smudge/blur, a float displacement field for liquify. + let target_format = gpu + .stroke + .as_ref() + .map(|s| s.scratch.format()) + .unwrap_or(crate::brush::node::COLOR_SCRATCH_FORMAT); let pipeline_ref = gpu.pipelines.get::(T::PIPELINE_ID); - ensure_per_brush_pipeline(gpu, pipeline_ref, &compiled, T::LABEL); + ensure_per_brush_pipeline(gpu, pipeline_ref, &compiled, T::LABEL, target_format); let stroke = gpu .stroke @@ -590,13 +613,40 @@ pub fn flush_dabs(gpu: &mut BrushGpuContext) { gpu.perf.record_dab_flush(total_dabs); } -/// Direct blit scratch → layer. The scratch already holds the finished -/// image; commit just copies it across. `gpu.blend_mode` is ignored — -/// erase semantics aren't meaningful for these read-back transforms. +/// Scratch → layer. `gpu.blend_mode` is ignored — erase semantics aren't +/// meaningful for these read-back transforms. +/// +/// Color terminals (smudge, blur) hold the finished image in the +/// scratch, so commit is a direct blit. A warp terminal's scratch holds a +/// displacement field instead, so commit is the single resample that +/// turns it into pixels — sampling the pre-stroke snapshot (or a +/// clone-style `source_override`) through the field across the layer's +/// full extent. Which one runs is decided by the scratch's own format, +/// not by a list of terminal names here. pub fn commit(gpu: &mut BrushGpuContext) { let Some(stroke) = gpu.stroke.as_ref() else { return; }; + + if stroke.scratch.format() == crate::brush::warp_field::FIELD_FORMAT { + let extent = stroke.paint_target.layer_extent(); + let source_view = stroke.source_view(); + gpu.pipelines + .get::( + crate::brush::warp_field::RESOLVE_PIPELINE_ID, + ) + .resolve( + gpu.device, + &mut gpu.encoder, + stroke.scratch.write_view(), + &source_view, + stroke.paint_target.view(), + stroke.paint_target.format(), + (extent.width, extent.height), + ); + return; + } + stroke.paint_target.commit_scratch_blit( gpu.device, &mut gpu.encoder, @@ -657,22 +707,27 @@ fn ensure_per_brush_pipeline( pipe: &ReadMirrorPipeline, compiled: &CompiledBrush, label: &str, + target_format: wgpu::TextureFormat, ) { if pipe.cache.borrow().contains_key(&compiled.topology_hash) { return; } + // `@group(3)` binds the scratch's own read-mirror bind group, so the + // pipeline layout has to be the one that scratch was built against. + let (canvas_copy_bgl, canvas_copy_sampler) = + gpu.pipelines.canvas_copy_layout_for(target_format); let ctx = BuildContext { device: gpu.device, queue: gpu.queue, uniform_bgl: gpu.pipelines.uniform_bind_group_layout(), selection_bgl: gpu.pipelines.selection_bind_group_layout(), - canvas_copy_bgl: gpu.pipelines.canvas_copy_bind_group_layout(), - canvas_copy_sampler: gpu.pipelines.canvas_copy_sampler(), + canvas_copy_bgl, + canvas_copy_sampler, min_uniform_align: gpu.device.limits().min_uniform_buffer_offset_alignment, texture_registry: gpu.pipelines.texture_registry(), baked_sources: gpu.pipelines.baked_sources(), }; - pipe.ensure_pipeline(&ctx, compiled, label); + pipe.ensure_pipeline(&ctx, compiled, label, target_format); } #[cfg(test)] diff --git a/crates/darkly/src/brush/scalar_binary.rs b/crates/darkly/src/brush/scalar_binary.rs index ceefc4d0..cfd3f5b8 100644 --- a/crates/darkly/src/brush/scalar_binary.rs +++ b/crates/darkly/src/brush/scalar_binary.rs @@ -77,7 +77,7 @@ impl ScalarBinaryNode { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, self.evaluator, ) diff --git a/crates/darkly/src/brush/scratch.rs b/crates/darkly/src/brush/scratch.rs index 0f93e89c..39022ff6 100644 --- a/crates/darkly/src/brush/scratch.rs +++ b/crates/darkly/src/brush/scratch.rs @@ -88,6 +88,11 @@ pub struct Scratch { /// Sampler for the write-side bind group. Nearest filter — no sub- /// pixel reads in the consumers (commit blit is integer-aligned). write_sampler: wgpu::Sampler, + /// Texel format of both sides. Color terminals use `Rgba8Unorm`; + /// warp terminals store a two-channel displacement field instead of + /// pixels (see [`crate::brush::warp_field`]) and declare their own + /// format on [`crate::brush::node::BrushNodeRegistration`]. + format: wgpu::TextureFormat, } impl Scratch { @@ -101,12 +106,19 @@ impl Scratch { /// /// `canvas_copy_sampler` is shared across the canvas-copy BGL bind /// groups. Linear filter (liquify needs sub-pixel sampling). + /// + /// `format` is the terminal's declared scratch format. For anything + /// other than `Rgba8Unorm` the caller must pass the matching + /// non-filtering BGL and sampler from + /// [`BrushPipelines::canvas_copy_layout_for`](crate::brush::pipeline::BrushPipelines::canvas_copy_layout_for) + /// — float32 formats are not filterable in core WebGPU. pub fn new( device: &wgpu::Device, layer_w: u32, layer_h: u32, canvas_copy_bgl: &wgpu::BindGroupLayout, canvas_copy_sampler: &wgpu::Sampler, + format: wgpu::TextureFormat, ) -> Self { let write_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("scratch-write-sampler"), @@ -116,12 +128,16 @@ impl Scratch { }); let read_mirror_sampler = canvas_copy_sampler.clone(); - let (write_texture, write_view) = create_write_texture(device, layer_w, layer_h); + let (write_texture, write_view) = create_write_texture(device, layer_w, layer_h, format); let write_bind_group = build_write_bind_group(device, canvas_copy_bgl, &write_view, &write_sampler); - let (read_mirror_texture, read_mirror_view) = - create_read_mirror_texture(device, READ_MIRROR_INITIAL_DIM, READ_MIRROR_INITIAL_DIM); + let (read_mirror_texture, read_mirror_view) = create_read_mirror_texture( + device, + READ_MIRROR_INITIAL_DIM, + READ_MIRROR_INITIAL_DIM, + format, + ); let read_mirror_bind_group = build_read_mirror_bind_group( device, canvas_copy_bgl, @@ -144,6 +160,7 @@ impl Scratch { canvas_copy_bgl: canvas_copy_bgl.clone(), read_mirror_sampler, write_sampler, + format, } } @@ -151,6 +168,11 @@ impl Scratch { &self.write_texture } + /// Texel format of both sides. + pub fn format(&self) -> wgpu::TextureFormat { + self.format + } + /// Stroke-prologue helper: clear the write side to fully transparent /// in a single attachment-clear render pass. Used by terminals whose /// composite accumulates from zero (paint, watercolor) — see @@ -323,7 +345,7 @@ impl Scratch { let target_w = new_w.max(self.write_w); let target_h = new_h.max(self.write_h); - let (new_texture, new_view) = create_write_texture(device, target_w, target_h); + let (new_texture, new_view) = create_write_texture(device, target_w, target_h, self.format); // Copy existing scratch contents into the new texture at the // canvas-anchored offset. Old regions outside the source rect @@ -376,7 +398,7 @@ impl Scratch { /// bind group that references it. Contents are not preserved; the /// next `sync_read_mirror` call re-populates from the write side. fn grow_read_mirror(&mut self, device: &wgpu::Device, new_w: u32, new_h: u32) { - let (new_texture, new_view) = create_read_mirror_texture(device, new_w, new_h); + let (new_texture, new_view) = create_read_mirror_texture(device, new_w, new_h, self.format); let new_read_bg = build_read_mirror_bind_group( device, @@ -398,6 +420,7 @@ fn create_write_texture( device: &wgpu::Device, width: u32, height: u32, + format: wgpu::TextureFormat, ) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("scratch-write"), @@ -409,7 +432,7 @@ fn create_write_texture( mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, + format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST @@ -424,6 +447,7 @@ fn create_read_mirror_texture( device: &wgpu::Device, width: u32, height: u32, + format: wgpu::TextureFormat, ) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("scratch-read-mirror"), @@ -435,7 +459,7 @@ fn create_read_mirror_texture( mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, + format, usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); diff --git a/crates/darkly/src/brush/stabilizers/laplacian.rs b/crates/darkly/src/brush/stabilizers/laplacian.rs index 91ac1509..8ffb81ce 100644 --- a/crates/darkly/src/brush/stabilizers/laplacian.rs +++ b/crates/darkly/src/brush/stabilizers/laplacian.rs @@ -16,12 +16,11 @@ use crate::brush::stabilizer::{ }; use crate::gpu::params::{ParamDef, ParamValue}; -const PARAMS: &[ParamDef] = &[ParamDef::Float { - name: "strength", - min: 0.0, - max: 1.0, - default: 0.5, -}]; +const PARAMS: &[ParamDef] = &[ParamDef::float("strength", 0.0, 1.0, 0.5) + .with_label("Strength") + .with_description( + "How firmly the stroke is smoothed as you draw; higher lags further behind the cursor.", + )]; pub fn register() -> StabilizerRegistration { StabilizerRegistration { diff --git a/crates/darkly/src/brush/stroke_buffer.rs b/crates/darkly/src/brush/stroke_buffer.rs index 587aa8bd..5d182656 100644 --- a/crates/darkly/src/brush/stroke_buffer.rs +++ b/crates/darkly/src/brush/stroke_buffer.rs @@ -66,13 +66,26 @@ impl StrokeBuffer { /// /// `pipelines` provides the canvas-copy BGL/sampler that the embedded /// `Scratch` needs for both its read-mirror and write bind groups. - pub fn new(device: &wgpu::Device, width: u32, height: u32, pipelines: &BrushPipelines) -> Self { + /// `scratch_format` comes from the stroke's terminal + /// ([`BrushNodeRegistration::scratch_format`](crate::brush::node::BrushNodeRegistration::scratch_format)). + /// The pre-stroke snapshot stays `Rgba8Unorm` regardless — it holds the + /// layer's pixels, which a warp terminal resolves *through* its field. + pub fn new( + device: &wgpu::Device, + width: u32, + height: u32, + pipelines: &BrushPipelines, + scratch_format: wgpu::TextureFormat, + ) -> Self { + let (canvas_copy_bgl, canvas_copy_sampler) = + pipelines.canvas_copy_layout_for(scratch_format); let scratch = Scratch::new( device, width, height, - pipelines.canvas_copy_bind_group_layout(), - pipelines.canvas_copy_sampler(), + canvas_copy_bgl, + canvas_copy_sampler, + scratch_format, ); let pre_stroke_texture = device.create_texture(&wgpu::TextureDescriptor { diff --git a/crates/darkly/src/brush/stroke_engine.rs b/crates/darkly/src/brush/stroke_engine.rs index 40a75e9f..326a9559 100644 --- a/crates/darkly/src/brush/stroke_engine.rs +++ b/crates/darkly/src/brush/stroke_engine.rs @@ -94,7 +94,10 @@ impl StrokeEngine { /// /// `runner` is a pre-compiled brush graph. `color` is the foreground /// color (raw sRGB RGBA, as picked). `spacing` controls dab placement. - /// `stabilizer` is the stroke stabilization algorithm. + /// `stabilizer` is the stroke stabilization algorithm. `stroke_seed` + /// drives every `random`/`noise` node in the graph — a real stroke passes + /// [`Self::random_seed`], a render that has to be reproducible passes a + /// constant. pub fn new( mut runner: BrushGraphRunner, color: [f32; 4], @@ -102,6 +105,7 @@ impl StrokeEngine { base_size: f32, stabilizer: Box, clone_source_anchor: Option<[f32; 2]>, + stroke_seed: u32, ) -> Self { // Base brush size is stroke-constant, read out-of-band from // `pen_input.size` at stroke start. Injected as ambient state so every @@ -109,11 +113,6 @@ impl StrokeEngine { // see one consistent value. runner.set_base_size(base_size); - 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, @@ -134,6 +133,26 @@ impl StrokeEngine { } } + /// A seed drawn from the wall clock, so two strokes of the same brush + /// scatter differently. What a stroke the painter is making wants — and + /// what a stroke rendered into a cached thumbnail or a documentation asset + /// must not have, which is why it is the caller's to choose. + /// Texel format the stroke scratch must be allocated in for this + /// stroke's brush — see + /// [`BrushGraphRunner::scratch_format`](crate::brush::eval::BrushGraphRunner::scratch_format). + /// The engine builds its `StrokeEngine` before its `StrokeBuffer`, so + /// this is available at allocation time. + pub fn scratch_format(&self) -> wgpu::TextureFormat { + self.runner.scratch_format() + } + + pub fn random_seed() -> u32 { + web_time::SystemTime::now() + .duration_since(web_time::SystemTime::UNIX_EPOCH) + .map(|d| d.as_nanos() as u32) + .unwrap_or(42) + } + /// Set the clone source snapshot's plane-space frame for the current /// stroke. Called by the engine every pen event, before rendering — /// see the field doc for what the frame is. diff --git a/crates/darkly/src/brush/warp_field.rs b/crates/darkly/src/brush/warp_field.rs new file mode 100644 index 00000000..077bb9bd --- /dev/null +++ b/crates/darkly/src/brush/warp_field.rs @@ -0,0 +1,338 @@ +//! Warp field — the stroke scratch as a displacement map, and the single +//! resample that turns it back into pixels. +//! +//! A warp terminal (liquify, and any pinch/swirl/bloat sibling) does not +//! paint. Its whole stroke is expressible as one coordinate map, so +//! rasterising the intermediate states is not just wasteful, it is +//! destructive: each dab would resample the picture, and a chain of +//! bilinear filters is a low-pass cascade, not a bilinear filter. At +//! liquify's 4 px dab spacing a pixel passes under dozens of dabs per +//! swipe and the detail is gone. +//! +//! So a warp terminal's scratch holds the *map* instead of the picture. +//! Per dab it advects and accumulates a two-channel displacement in plane +//! pixels; at commit the pre-stroke snapshot is sampled **once** through +//! the accumulated field. Detail is then independent of dab count. +//! +//! This is the architecture both reference implementations converged on: +//! +//! * GEGL's `gegl:warp` iterates a two-component float coordinate buffer +//! (`operations/common-cxx/warp.cc:321`) and per stamp does +//! `field'(p) = field(p + nv) + nv` (`:700-704`) — the exact update +//! [`advect_wgsl`] emits. GIMP wires the accumulated buffer through +//! `gegl:map-relative` to sample the drawable once +//! (`app/tools/gimpwarptool.c:913,1059-1071`). +//! * Krita's `KisLiquifyTransformWorker` keeps `originalPoints` / +//! `transformedPoints` grids (`libs/image/kis_liquify_transform_worker.cpp:31-32`), +//! moves only the grid per touch (`:251-253`), and rasterises once from +//! the source device in `run()` (`:414-440`). +//! +//! ## Displacement is relative, and that is load-bearing +//! +//! The field stores `source − target`, not an absolute source coordinate. +//! `Scratch::grow_write` rebases the scratch when the layer grows +//! mid-stroke; a relative delta survives that untouched, and the +//! zero-filled new region is exactly the right identity. Absolute +//! coordinates would all have to be rewritten. +//! +//! ## Why `Rg32Float` +//! +//! At full strength liquify locks pixels to the cursor, so the field is +//! the *cumulative* drag — a 1200 px drag stores values near 1200, where +//! half-float ULP is a whole pixel (measured f16-vs-f32 error over such a +//! drag: p90 3.6 px, p99 15.7 px). GEGL and GIMP both use float32 here. +//! `Rg32Float` is renderable in core WebGPU but not *filterable*, so both +//! the per-dab advect and the resolve fetch it with `textureLoad` and +//! interpolate in [`FIELD_HELPERS_WGSL`]. That also makes the resolve +//! provably exact where the field is zero: at zero displacement the +//! interpolation weights are exactly 0 and `mix` returns the source texel +//! bit-for-bit, on every backend. Since the resolve rewrites the whole +//! layer on every pen event, anything less would re-introduce the +//! softening this module exists to remove. + +use std::any::Any; + +use crate::brush::pipeline::{BrushPipelineEntry, BrushPipelineRegistration, BuildContext}; + +/// Texel format of a warp terminal's scratch. Two channels of `f32`: +/// the displacement from target pixel to source pixel, in plane pixels. +pub const FIELD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg32Float; + +/// Registry id of the shared resolve pipeline. +pub const RESOLVE_PIPELINE_ID: &str = "warp_field_resolve"; + +/// Manual bilinear fetch of a two-channel field, by `textureLoad`. +/// +/// `p` is in texels from the texture's origin, integer `p` naming a texel +/// *corner* — the same convention the read-mirror UV math uses, where +/// `copy_origin` is floored to integers and `target_pos` interpolates to +/// fragment centres. Hence the `-0.5` before `floor`. +/// +/// Edge-clamped: a dab clipped at the layer edge addresses outside the +/// mirror's valid region, and clamping there reads the nearest valid +/// displacement rather than a stale texel. +pub const FIELD_HELPERS_WGSL: &str = "\ +fn warp_field_texel(t: texture_2d, c: vec2) -> vec2 { + let dims = vec2(textureDimensions(t)); + let cc = clamp(c, vec2(0, 0), dims - vec2(1, 1)); + return textureLoad(t, cc, 0).xy; +} +fn warp_field_bilinear(t: texture_2d, p: vec2) -> vec2 { + let q = p - vec2(0.5, 0.5); + let base = floor(q); + let frac = q - base; + let b = vec2(base); + let top = mix(warp_field_texel(t, b), + warp_field_texel(t, b + vec2(1, 0)), frac.x); + let bot = mix(warp_field_texel(t, b + vec2(0, 1)), + warp_field_texel(t, b + vec2(1, 1)), frac.x); + return mix(top, bot, frac.y); +} +"; + +/// The per-dab fragment tail every warp terminal shares: advect the +/// accumulated field by this dab's offset, add the offset, write it back. +/// +/// `offset_expr` must evaluate to a `vec2` — the displacement this +/// dab contributes at the current fragment, already shaped by whatever +/// falloff, selection and mask attenuation the terminal wants. Pointing +/// it *backward* along travel makes content move *forward* with the +/// cursor (GEGL does the same: `motion_x = priv->last_x - x`, +/// `warp.cc:412`). +/// +/// A new warp behaviour is therefore one file supplying one expression — +/// `scale`/`rotate` offsets are functions of `local`, which the framework +/// wrapper already provides. +pub fn advect_wgsl(offset_expr: &str, copy_origin_field: &str) -> String { + format!( + " let nv = {offset_expr};\n\ + \x20 let prev = warp_field_bilinear(\n\ + \x20 scratch_mirror_tex, target_pos + nv - d.{copy_origin_field});\n\ + \x20 return vec4(prev + nv, 0.0, 0.0);\n" + ) +} + +// ── Resolve pipeline ──────────────────────────────────────────────────── + +const RESOLVE_WGSL: &str = r#" +@group(0) @binding(0) var field_tex: texture_2d; +@group(0) @binding(1) var source_tex: texture_2d; + +struct VsOut { + @builtin(position) clip_pos: vec4, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { + // Oversized triangle covering the viewport. + var xy = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0), + ); + var out: VsOut; + out.clip_pos = vec4(xy[vi], 0.0, 1.0); + return out; +} + +fn source_texel(c: vec2) -> vec4 { + let dims = vec2(textureDimensions(source_tex)); + let cc = clamp(c, vec2(0, 0), dims - vec2(1, 1)); + return textureLoad(source_tex, cc, 0); +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + // `clip_pos.xy` is the fragment centre in layer-local pixels. + let p = in.clip_pos.xy; + let disp = warp_field_bilinear(field_tex, p); + let src = p + disp; + + // Manual bilinear rather than a sampler: where `disp` is exactly + // zero the weights are exactly zero and this returns the source + // texel bit-for-bit. The resolve rewrites the entire layer every + // pen event, so an off-by-half-texel here would soften everything + // the stroke did not touch. + let q = src - vec2(0.5, 0.5); + let base = floor(q); + let frac = q - base; + let b = vec2(base); + let top = mix(source_texel(b), + source_texel(b + vec2(1, 0)), frac.x); + let bot = mix(source_texel(b + vec2(0, 1)), + source_texel(b + vec2(1, 1)), frac.x); + return mix(top, bot, frac.y); +} +"#; + +/// Resolves an accumulated warp field against a source snapshot, straight +/// onto the paint target. +/// +/// Two pipelines, one per destination format — raster layers are +/// `Rgba8Unorm`, mask layers `R8Unorm`. Per the type-owned-dispatch +/// principle the branch lives in [`WarpFieldResolve::pipeline`], not at +/// the call site, mirroring `CompositePipeline`. +pub struct WarpFieldResolve { + pipeline_rgba: wgpu::RenderPipeline, + pipeline_r8: wgpu::RenderPipeline, + bgl: wgpu::BindGroupLayout, +} + +/// Harvested by `BrushPipelines::new` alongside the other plumbing +/// pipelines — the resolve belongs to no single node, since every warp +/// terminal shares it. +pub fn warp_field_resolve_registration() -> BrushPipelineRegistration { + BrushPipelineRegistration { + id: RESOLVE_PIPELINE_ID, + build: |ctx| Box::new(WarpFieldResolve::build(ctx)), + } +} + +impl WarpFieldResolve { + fn build(ctx: &BuildContext) -> Self { + let shader = ctx + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("warp-field-resolve"), + source: wgpu::ShaderSource::Wgsl( + format!("{FIELD_HELPERS_WGSL}\n{RESOLVE_WGSL}").into(), + ), + }); + + // Both textures are fetched with `textureLoad`, so neither needs a + // sampler and the field's non-filterability is irrelevant here. + let texture_entry = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let bgl = ctx + .device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("warp-field-resolve-bgl"), + entries: &[texture_entry(0), texture_entry(1)], + }); + + let layout = ctx + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("warp-field-resolve-layout"), + bind_group_layouts: &[Some(&bgl)], + immediate_size: 0, + }); + + let make = |format: wgpu::TextureFormat, label: &str| { + ctx.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }; + + Self { + pipeline_rgba: make(wgpu::TextureFormat::Rgba8Unorm, "warp-field-resolve-rgba"), + pipeline_r8: make(wgpu::TextureFormat::R8Unorm, "warp-field-resolve-r8"), + bgl, + } + } + + fn pipeline(&self, format: wgpu::TextureFormat) -> &wgpu::RenderPipeline { + if format == wgpu::TextureFormat::R8Unorm { + &self.pipeline_r8 + } else { + &self.pipeline_rgba + } + } + + /// Sample `source` through `field` and write the result across + /// `dest`'s full extent. + /// + /// Full extent, not a damage rect: the whole point is that the output + /// is a pure function of the pre-stroke snapshot and the current + /// field, so it stays correct when the stabiliser rewinds and discards + /// dabs — a tracked rect would leave a stale warped fringe behind the + /// truncation. It also costs no more than the full-extent scratch copy + /// a colour terminal's commit already does. + #[allow(clippy::too_many_arguments)] + pub fn resolve( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + field_view: &wgpu::TextureView, + source_view: &wgpu::TextureView, + dest_view: &wgpu::TextureView, + dest_format: wgpu::TextureFormat, + dest_size: (u32, u32), + ) { + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("warp-field-resolve-bg"), + layout: &self.bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(field_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(source_view), + }, + ], + }); + + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("warp-field-resolve"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: dest_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_viewport(0.0, 0.0, dest_size.0 as f32, dest_size.1 as f32, 0.0, 1.0); + pass.set_pipeline(self.pipeline(dest_format)); + pass.set_bind_group(0, &bind_group, &[]); + pass.draw(0..3, 0..1); + } +} + +impl BrushPipelineEntry for WarpFieldResolve { + fn as_any(&self) -> &dyn Any { + self + } + fn ring(&self) -> Option<&crate::brush::pipeline::DynamicUniformRing> { + None + } + fn rings(&self) -> Vec<&crate::brush::pipeline::DynamicUniformRing> { + Vec::new() + } +} diff --git a/crates/darkly/src/catalog.rs b/crates/darkly/src/catalog.rs new file mode 100644 index 00000000..973d6352 --- /dev/null +++ b/crates/darkly/src/catalog.rs @@ -0,0 +1,437 @@ +//! The one shape every registry of typed variants projects into. +//! +//! Filters, veils, voids, blend modes, tools, layer kinds and layer filters all +//! answer the same questions — what is this variant called, what does it look +//! like, what does it do, what can you set on it — and each used to answer them +//! through its own flat `*TypeInfo` struct and its own engine query. This module +//! replaces all of them with [`Catalog`] and [`CatalogEntry`]. +//! +//! Each registration type builds its own [`CatalogEntry`] in its own file, so +//! nothing here branches on which registry it is looking at and adding a veil +//! touches nothing outside `gpu/veils/`. The enumeration of catalog-producing +//! registries is generated by `build.rs` from the module directories it scans, +//! so a registry cannot be silently left out of the projection. + +use crate::config::schema::WidgetHint; +use crate::engine::types::ParamInfo; +use crate::gpu::void::CaptureKind; + +/// One browsable entry in a registry of typed variants — the single shape the +/// UI pickers, the settings surface and the metadata export all consume. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +pub struct CatalogEntry { + #[serde(rename = "type")] + pub type_id: &'static str, + pub display_name: &'static str, + /// Iconify name, or `None` when the variant deliberately declares no icon + /// (veils render a live preview; raster layers always show a thumbnail). + pub icon: Option<&'static str>, + pub description: Option<&'static str>, + /// Grouping label within the catalog, for variants that group. + pub category: Option<&'static str>, + /// Action id this variant is bound to, for variants a hotkey can select. + pub hotkey_action: Option<&'static str>, + pub params: Vec, + /// Whether this variant declares a + /// [`PreviewAnim`](crate::gpu::preview::PreviewAnim) — the one fact behind + /// "a rendered preview of it exists". False for the registries whose entries + /// are affordances rather than images. + /// + /// It does **not** promise a *picker* preview. A blend mode declares one and + /// has a documentation asset, but is a relation between two images rather + /// than an effect over one, so its catalog exports no preview mechanism and + /// `start_preview` no-ops for it exactly as it does for an unknown type. + /// Whether a catalog can be driven live is + /// [`preview_mechanisms`](crate::catalog::preview_mechanisms)' answer, not + /// this field's. + pub supports_preview: bool, + /// How the browser captures this variant's external frames; voids only. + pub capture_kind: Option, +} + +impl CatalogEntry { + /// An entry carrying only the fields every registry has. Registries layer + /// their own metadata on with the `with_*` setters, so a registry that has + /// no icon or no parameters says so by staying silent rather than by + /// spelling out a row of `None`s. + pub fn new(type_id: &'static str, display_name: &'static str) -> Self { + CatalogEntry { + type_id, + display_name, + icon: None, + description: None, + category: None, + hotkey_action: None, + params: Vec::new(), + supports_preview: false, + capture_kind: None, + } + } + + /// Set the Iconify name, treating `""` as "declares no icon". Registries + /// that store a non-optional `&'static str` use the empty string for that, + /// and this is the one place that convention is translated. + pub fn with_icon(mut self, icon: &'static str) -> Self { + self.icon = (!icon.is_empty()).then_some(icon); + self + } + + pub fn with_description(mut self, description: &'static str) -> Self { + self.description = (!description.is_empty()).then_some(description); + self + } + + pub fn with_category(mut self, category: &'static str) -> Self { + self.category = (!category.is_empty()).then_some(category); + self + } + + pub fn with_hotkey_action(mut self, action: &'static str) -> Self { + self.hotkey_action = (!action.is_empty()).then_some(action); + self + } + + pub fn with_params(mut self, params: &'static [crate::gpu::params::ParamDef]) -> Self { + self.params = params + .iter() + .map(|d| ParamInfo::from_def(d, None)) + .collect(); + self + } + + pub fn with_supports_preview(mut self, supports_preview: bool) -> Self { + self.supports_preview = supports_preview; + self + } + + pub fn with_capture_kind(mut self, capture_kind: Option) -> Self { + self.capture_kind = capture_kind; + self + } +} + +/// A named group of entries — "filters", "veils", "settings.canvas", … +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +pub struct Catalog { + pub id: &'static str, + pub title: &'static str, + pub description: Option<&'static str>, + pub icon: Option<&'static str>, + /// Presentation order, for catalogs that declare one. Registry catalogs do + /// not; settings sections do. + pub order: Option, + pub entries: Vec, + /// Whether an entry's icon identifies it within the catalog. + /// + /// True for the registries whose entries are picker variants, where the + /// glyph is most of what the user has to tell two of them apart and a + /// duplicate is a copy-pasted `register()` that kept the donor's. Actions + /// declare it false: an action's glyph depicts the *operation*, so the same + /// operation at another scope wants the same glyph (flipping the canvas and + /// flipping a layer are both `arrows-left-right`), and an action is never + /// shown without its label. + /// + /// Says how to read the icons rather than what they are, so it stays out of + /// the exported artifact. + #[serde(skip)] + #[cfg_attr(feature = "ts-export", ts(skip))] + pub icons_identify_entries: bool, +} + +impl Catalog { + pub fn new(id: &'static str, title: &'static str, entries: Vec) -> Self { + Catalog { + id, + title, + description: None, + icon: None, + order: None, + entries, + icons_identify_entries: true, + } + } + + /// Declare that entries in this catalog may share a glyph — see + /// [`Catalog::icons_identify_entries`]. + pub fn with_shared_icons(mut self) -> Self { + self.icons_identify_entries = false; + self + } + + pub fn with_description(mut self, description: &'static str) -> Self { + self.description = (!description.is_empty()).then_some(description); + self + } + + pub fn with_icon(mut self, icon: &'static str) -> Self { + self.icon = (!icon.is_empty()).then_some(icon); + self + } + + pub fn with_order(mut self, order: i32) -> Self { + self.order = Some(order); + self + } +} + +include!(concat!(env!("OUT_DIR"), "/catalog_sources_gen.rs")); + +/// Every settings section, projected into the same shape as a registry. +/// +/// One catalog per section, one entry per section, one [`ParamInfo`] per pref — +/// so a consumer that can render a filter's parameter table can render the +/// settings surface with no second code path. Ids are prefixed `settings.`, +/// which cannot collide with a registry catalog id because none contains a dot. +/// +/// Prefs declaring [`WidgetHint::Hidden`] are excluded: panel visibility, the +/// recent-files list and friends ride the same persistence pipe as real +/// settings but are not settings, and the live UI already skips them. Applying +/// the rule here keeps it in one place rather than leaving it to each consumer. +pub fn settings_catalogs() -> Vec { + let mut out: Vec<(i32, &'static str, Catalog)> = crate::config::sections::registrations() + .iter() + .map(|section| { + let params: Vec = section + .prefs + .iter() + .filter(|p| !matches!(p.widget, WidgetHint::Hidden)) + .map(ParamInfo::from_pref) + .collect(); + let entry = CatalogEntry { + type_id: section.id, + display_name: section.display_name, + icon: section.icon, + description: section.description, + category: None, + hotkey_action: None, + params, + supports_preview: false, + capture_kind: None, + }; + let mut catalog = Catalog::new( + // `id` must be `'static`; sections are `'static` data, so lean + // on the same leak-free trick the registries do — the section's + // own id with a compile-time-known prefix, built once. + settings_catalog_id(section.id), + section.display_name, + vec![entry], + ) + .with_order(section.order); + if let Some(d) = section.description { + catalog = catalog.with_description(d); + } + if let Some(i) = section.icon { + catalog = catalog.with_icon(i); + } + (section.order, section.id, catalog) + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1))); + out.into_iter().map(|(_, _, c)| c).collect() +} + +/// `"settings."` as a `&'static str`. Sections are a fixed, compile-time +/// set discovered by `build.rs`, so the ids are interned once for the process +/// rather than rebuilt per call. +fn settings_catalog_id(section_id: &'static str) -> &'static str { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + static IDS: OnceLock>> = OnceLock::new(); + let mut map = IDS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .expect("settings id cache poisoned"); + map.entry(section_id) + .or_insert_with(|| Box::leak(format!("settings.{section_id}").into_boxed_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Nothing ships undocumented: every catalog names and describes itself, + /// and so does every entry in it. + /// + /// `icon`, `category` and `hotkey_action` are deliberately *not* asserted. + /// `None` on those is a declared value rather than a missing one — veils + /// render a live preview instead of an icon, raster layers always show a + /// thumbnail, and only tools bind a hotkey — which is the whole reason they + /// are `Option` rather than `&'static str`. Demanding them back would be a + /// rule the data does not have. Malformed icons are caught by + /// [`icons_are_wellformed_and_unique_within_a_catalog`] instead. + #[test] + fn every_catalog_entry_is_documented() { + for cat in catalogs() { + assert!(!cat.id.is_empty(), "catalog has an empty id"); + assert!(!cat.title.is_empty(), "catalog `{}` has no title", cat.id); + assert!( + cat.description.is_some_and(|d| !d.is_empty()), + "catalog `{}` has no description", + cat.id + ); + assert!( + !cat.entries.is_empty(), + "catalog `{}` has no entries", + cat.id + ); + + for e in &cat.entries { + assert!( + !e.type_id.is_empty(), + "an entry in `{}` has an empty type id", + cat.id + ); + assert!( + !e.display_name.is_empty(), + "`{}/{}` has no display name", + cat.id, + e.type_id + ); + assert!( + e.description.is_some_and(|d| !d.is_empty()), + "`{}/{}` has no description", + cat.id, + e.type_id + ); + } + } + } + + /// Generalizes the per-registry uniqueness check `gpu/filter.rs` used to + /// carry: a copy-pasted `register()` that kept the donor's glyph shows up + /// as two entries in one catalog claiming the same icon. Catalogs whose + /// glyphs are not identifying opt out of the uniqueness half — see + /// [`Catalog::icons_identify_entries`] — but not the wellformedness half. + #[test] + fn icons_are_wellformed_and_unique_within_a_catalog() { + for cat in catalogs() { + let mut seen: Vec<(&str, &str)> = Vec::new(); + for e in &cat.entries { + let Some(icon) = e.icon else { continue }; + assert!( + icon.contains(':'), + "`{}/{}` icon `{icon}` is not a `collection:name` Iconify id", + cat.id, + e.type_id + ); + assert!( + !icon.ends_with(':') && !icon.starts_with(':'), + "`{}/{}` icon `{icon}` has an empty collection or name", + cat.id, + e.type_id + ); + if !cat.icons_identify_entries { + continue; + } + if let Some((owner, _)) = seen.iter().find(|(_, i)| *i == icon) { + panic!( + "`{}/{}` and `{}/{}` both use icon `{icon}`", + cat.id, owner, cat.id, e.type_id + ); + } + seen.push((e.type_id, icon)); + } + } + } + + /// Settings project on the same footing as a registry: one catalog per + /// section, `settings.`-prefixed so it cannot collide with a registry id, + /// and carrying only prefs that are actually settings. + #[test] + fn settings_project_as_catalogs_without_hidden_prefs() { + let cats = settings_catalogs(); + assert!(!cats.is_empty(), "no settings sections found"); + + let declared: usize = crate::config::sections::registrations() + .iter() + .map(|s| s.prefs.len()) + .sum(); + let hidden: usize = crate::config::sections::registrations() + .iter() + .flat_map(|s| s.prefs.iter()) + .filter(|p| matches!(p.widget, WidgetHint::Hidden)) + .count(); + let exported: usize = cats + .iter() + .flat_map(|c| &c.entries) + .map(|e| e.params.len()) + .sum(); + assert!( + hidden > 0, + "expected some hidden prefs to exercise the filter" + ); + assert_eq!( + exported, + declared - hidden, + "settings export must carry every declared pref except the hidden ones" + ); + + for c in &cats { + assert!( + c.id.starts_with("settings."), + "`{}` is not settings-prefixed", + c.id + ); + assert!( + c.order.is_some(), + "settings catalog `{}` declares no order", + c.id + ); + assert_eq!( + c.entries.len(), + 1, + "`{}` should hold exactly one entry", + c.id + ); + for p in &c.entries[0].params { + assert_ne!(p.widget, "hidden", "`{}` exported a hidden pref", c.id); + } + } + + // Settings ids and registry ids share one namespace and must not collide. + for r in catalogs() { + assert!( + !r.id.contains('.'), + "registry catalog `{}` contains a dot", + r.id + ); + } + } + + #[test] + fn catalog_ids_and_entry_ids_are_unique_and_stable() { + let cats = catalogs(); + let mut ids: Vec<&str> = cats.iter().map(|c| c.id).collect(); + ids.sort_unstable(); + let before = ids.len(); + ids.dedup(); + assert_eq!(before, ids.len(), "two catalogs claim the same id"); + + for cat in &cats { + let mut entry_ids: Vec<&str> = cat.entries.iter().map(|e| e.type_id).collect(); + entry_ids.sort_unstable(); + let before = entry_ids.len(); + entry_ids.dedup(); + assert_eq!( + before, + entry_ids.len(), + "two entries in catalog `{}` claim the same type id", + cat.id + ); + } + + // Entry order is part of the contract — a caller that renders a catalog + // twice must get the same table both times. + for cat in &cats { + let rebuilt = catalogs(); + let same = rebuilt.iter().find(|c| c.id == cat.id).unwrap(); + let a: Vec<&str> = cat.entries.iter().map(|e| e.type_id).collect(); + let b: Vec<&str> = same.entries.iter().map(|e| e.type_id).collect(); + assert_eq!(a, b, "catalog `{}` is not order-stable", cat.id); + } + } +} diff --git a/crates/darkly/src/config/chord.rs b/crates/darkly/src/config/chord.rs new file mode 100644 index 00000000..2a1db276 --- /dev/null +++ b/crates/darkly/src/config/chord.rs @@ -0,0 +1,210 @@ +//! Binding grammar and chord rendering. +//! +//! A binding in `presets/*.yaml` is an optional `site@scope@brush:` prefix +//! followed by a chord. The prefix says *where* the binding applies; the chord +//! says which keys or mouse gesture triggers it. Rust owns both halves because +//! the metadata export ships chords already rendered, and a second +//! implementation of this table on the consumer side is exactly what the +//! artifact exists to avoid. + +/// Which modifier vocabulary a chord renders with. Documentation carries both, +/// because a static document is read on both. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Platform { + Mac, + Other, +} + +/// The parsed halves of a binding: its optional prefix parts and the chord. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ParsedBinding { + /// Binding-site name (`"layerPanel"`, `"canvas"`), or `None` for any. + pub site: Option, + /// Active-tool group (`"paint"`, `"select"`), or `None` for any tool. + pub scope: Option, + /// Brush kind (`"clone"`), or `None` for any brush. + pub brush: Option, + /// Everything after the first `:`, verbatim. + pub chord: String, +} + +/// Split a binding into its prefix parts and its chord. +/// +/// The colon is the chord separator; the part before it splits on `@` into +/// `site@scope@brush`, each optional. Anything after the *first* colon is the +/// chord verbatim, so a `@` inside a chord stays put. +/// +/// ```text +/// "Delete" → site None, scope None, brush None, chord "Delete" +/// "layerPanel:Delete" → site layerPanel chord "Delete" +/// "canvas@paint:shift+drag" → site canvas, scope paint, chord "shift+drag" +/// "@paint:KeyB" → scope paint, chord "KeyB" +/// "canvas@paint@clone:$mod+drag" → site canvas, scope paint, brush clone, chord "$mod+drag" +/// ``` +pub fn parse_binding(raw: &str) -> ParsedBinding { + let Some(colon) = raw.find(':') else { + return ParsedBinding { + chord: raw.to_string(), + ..Default::default() + }; + }; + let (prefix, rest) = raw.split_at(colon); + let chord = rest[1..].to_string(); + + let some = |s: &str| (!s.is_empty()).then(|| s.to_string()); + + match prefix.find('@') { + None => ParsedBinding { + site: some(prefix), + scope: None, + brush: None, + chord, + }, + Some(at) => { + let (site, tail) = prefix.split_at(at); + let mut parts = tail[1..].split('@'); + ParsedBinding { + site: some(site), + scope: parts.next().and_then(some), + brush: parts.next().and_then(some), + chord, + } + } + } +} + +/// Render a chord for one platform's modifier vocabulary. +/// +/// Handles both the keyboard vocabulary (`Shift`/`Alt` capitalized, key codes +/// like `KeyA` / `Comma`) and the mouse vocabulary (`shift`/`alt`/`ctrl`/`meta` +/// lowercase, verbs like `click` / `drag`). A part it does not recognize passes +/// through unchanged, which is what keeps a new key code from rendering blank. +pub fn format_chord(chord: &str, platform: Platform) -> String { + let mac = platform == Platform::Mac; + chord + .split('+') + .map(|part| match part { + "$mod" => if mac { "⌘" } else { "Ctrl" }.to_string(), + "Shift" | "shift" => if mac { "⇧" } else { "Shift" }.to_string(), + "Alt" | "alt" => if mac { "⌥" } else { "Alt" }.to_string(), + "ctrl" => if mac { "⌃" } else { "Ctrl" }.to_string(), + "meta" => if mac { "⌘" } else { "Win" }.to_string(), + "click" => "click".to_string(), + "doubleClick" => "double-click".to_string(), + "middleClick" => "middle-click".to_string(), + "drag" => "drag".to_string(), + "middleDrag" => "middle-drag".to_string(), + "rightDrag" => "right-drag".to_string(), + "Delete" => "Del".to_string(), + "Comma" => ",".to_string(), + "Period" => ".".to_string(), + "Semicolon" => ";".to_string(), + "Quote" => "'".to_string(), + "BracketLeft" => "[".to_string(), + "BracketRight" => "]".to_string(), + "Backslash" => "\\".to_string(), + "Minus" => "-".to_string(), + "Equal" => "=".to_string(), + "Slash" => "/".to_string(), + "Backquote" => "`".to_string(), + other => match other.strip_prefix("Key") { + Some(k) => k.to_string(), + None => other.to_string(), + }, + }) + .collect::>() + .join("+") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parsed( + site: Option<&str>, + scope: Option<&str>, + brush: Option<&str>, + chord: &str, + ) -> ParsedBinding { + ParsedBinding { + site: site.map(str::to_string), + scope: scope.map(str::to_string), + brush: brush.map(str::to_string), + chord: chord.to_string(), + } + } + + /// The prefix is a three-part grammar, not one opaque string. Collapsing it + /// to a `(scope, chord)` pair would report `layerThumb` — a *site* — as a + /// scope and drop `brush` entirely. + #[test] + fn parse_binding_splits_site_scope_brush() { + let cases: &[(&str, ParsedBinding)] = &[ + ("KeyI", parsed(None, None, None, "KeyI")), + ("Delete", parsed(None, None, None, "Delete")), + ( + "layerThumb:alt+click", + parsed(Some("layerThumb"), None, None, "alt+click"), + ), + ( + "layerPanel:Delete", + parsed(Some("layerPanel"), None, None, "Delete"), + ), + ( + "canvas@paint:shift+drag", + parsed(Some("canvas"), Some("paint"), None, "shift+drag"), + ), + ("@paint:KeyB", parsed(None, Some("paint"), None, "KeyB")), + ( + "canvas@paint@clone:$mod+drag", + parsed(Some("canvas"), Some("paint"), Some("clone"), "$mod+drag"), + ), + // Only the FIRST colon separates; an `@` after it belongs to the chord. + ("canvas:a@b", parsed(Some("canvas"), None, None, "a@b")), + ]; + for (raw, want) in cases { + assert_eq!(&parse_binding(raw), want, "parsing `{raw}`"); + } + } + + #[test] + fn format_chord_renders_both_platforms() { + let cases: &[(&str, &str, &str)] = &[ + ("$mod+KeyZ", "⌘+Z", "Ctrl+Z"), + ("$mod+Shift+KeyP", "⌘+⇧+P", "Ctrl+Shift+P"), + ("Alt+KeyA", "⌥+A", "Alt+A"), + ("alt+click", "⌥+click", "Alt+click"), + ("ctrl+drag", "⌃+drag", "Ctrl+drag"), + ("meta+click", "⌘+click", "Win+click"), + ("$mod+drag", "⌘+drag", "Ctrl+drag"), + ("shift+doubleClick", "⇧+double-click", "Shift+double-click"), + ("middleClick", "middle-click", "middle-click"), + ("middleDrag", "middle-drag", "middle-drag"), + ("rightDrag", "right-drag", "right-drag"), + // The twelve key codes. + ("Delete", "Del", "Del"), + ("Comma", ",", ","), + ("Period", ".", "."), + ("Semicolon", ";", ";"), + ("Quote", "'", "'"), + ("BracketLeft", "[", "["), + ("BracketRight", "]", "]"), + ("Backslash", "\\", "\\"), + ("Minus", "-", "-"), + ("Equal", "=", "="), + ("Slash", "/", "/"), + ("Backquote", "`", "`"), + // Unrecognized parts pass through rather than rendering blank. + ("Space", "Space", "Space"), + ("F5", "F5", "F5"), + ]; + for (chord, mac, other) in cases { + assert_eq!(&format_chord(chord, Platform::Mac), mac, "mac `{chord}`"); + assert_eq!( + &format_chord(chord, Platform::Other), + other, + "other `{chord}`" + ); + } + } +} diff --git a/crates/darkly/src/config/mod.rs b/crates/darkly/src/config/mod.rs index cdcb7d55..04d0c770 100644 --- a/crates/darkly/src/config/mod.rs +++ b/crates/darkly/src/config/mod.rs @@ -1,3 +1,4 @@ +pub mod chord; pub mod schema; pub mod sections; @@ -9,7 +10,7 @@ mod presets_gen { pub use presets_gen::{BASE_SETTINGS_OPTIONS, DEFAULTS_YAML, OVERLAYS}; use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; /// On-disk schema version for `user_settings.json`. Bump whenever a change /// to the schema or YAML layers cannot be auto-cleaned by @@ -74,12 +75,25 @@ impl Config { } } - /// Resolve a key down the layer stack. - fn get(&self, key: &str) -> Option<&ConfigValue> { - if let Some(v) = self.user.get(key) { - return Some(v); + /// The one walk down the layer stack: named `overlay` above `defaults`, + /// with the user layer consulted only when `include_user` is set. + /// + /// Every resolution goes through here so the layer order exists in exactly + /// one place. `get` and `base_value` differ only in whether the user layer + /// participates; the exporter differs only in naming the overlay outright + /// rather than reading it from `app.baseSettings`. + fn resolve( + &self, + overlay: Option<&str>, + key: &str, + include_user: bool, + ) -> Option<&ConfigValue> { + if include_user { + if let Some(v) = self.user.get(key) { + return Some(v); + } } - if let Some(ConfigValue::Str(name)) = self.user.get("app.baseSettings") { + if let Some(name) = overlay { if let Some(v) = self.overlays.get(name).and_then(|m| m.get(key)) { return Some(v); } @@ -87,16 +101,24 @@ impl Config { self.defaults.get(key) } + /// The overlay the user has selected, if any. + fn active_overlay(&self) -> Option<&str> { + match self.user.get("app.baseSettings") { + Some(ConfigValue::Str(name)) => Some(name.as_str()), + _ => None, + } + } + + /// Resolve a key down the layer stack. + fn get(&self, key: &str) -> Option<&ConfigValue> { + self.resolve(self.active_overlay(), key, true) + } + /// What "Reset override on this key" would reveal — the layer below /// the user layer. Drives the Settings UI's "displayed default" and /// the Reset-affordance disabled state. fn base_value(&self, key: &str) -> Option<&ConfigValue> { - if let Some(ConfigValue::Str(name)) = self.user.get("app.baseSettings") { - if let Some(v) = self.overlays.get(name).and_then(|m| m.get(key)) { - return Some(v); - } - } - self.defaults.get(key) + self.resolve(self.active_overlay(), key, false) } } @@ -322,15 +344,140 @@ pub fn kind_is_int(key: &str) -> bool { /// Return the full schema as a serializable view. Used by the WASM bridge to /// feed the Settings UI. -pub fn schema_info() -> Vec { - let mut out: Vec<_> = sections::registrations() - .iter() - .map(schema::SectionInfo::from_section) - .collect(); - out.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.id.cmp(b.id))); +/// The editor-agnostic baseline value for a key — the bottom layer alone, with +/// no user override and no editor overlay. +/// +/// Reads `defaults.yaml` directly rather than the process-global config, so a +/// caller that only wants to describe the schema (the metadata exporter, the +/// settings projection) needs no initialization and cannot be perturbed by +/// whatever the running editor has chosen. +/// One effective binding: the raw preset string, its parsed prefix, and the +/// chord rendered for both platform conventions. +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Binding { + /// The binding exactly as the preset YAML declares it. + pub raw: String, + /// Prefix parts, each absent unless the binding declares it. + pub site: Option, + pub scope: Option, + pub brush: Option, + /// The chord rendered with Apple modifier glyphs. + pub mac: String, + /// The chord rendered with the Windows/Linux modifier names. + pub other: String, +} + +impl Binding { + fn parse(raw: &str) -> Self { + let p = chord::parse_binding(raw); + Binding { + raw: raw.to_string(), + site: p.site, + scope: p.scope, + brush: p.brush, + mac: chord::format_chord(&p.chord, chord::Platform::Mac), + other: chord::format_chord(&p.chord, chord::Platform::Other), + } + } +} + +/// The action id a config key binds, or `None` when the key binds no action. +/// +/// Bindings live under `hotkeys.` / `mouseclicks.`, with one exception: the +/// canvas-navigation held modifiers (`hotkeys.nav.trigger` and friends) are +/// prefs declared by [`sections`], sharing the prefix because that is where a +/// user looks for them. Nothing dispatches an action from a pref, so asking the +/// schema is what separates the two — no rule about dots in ids. +pub fn bound_action_id(key: &str) -> Option<&str> { + use std::collections::HashSet; + use std::sync::OnceLock; + static PREF_KEYS: OnceLock> = OnceLock::new(); + let prefs = PREF_KEYS.get_or_init(|| { + sections::registrations() + .iter() + .flat_map(|s| s.prefs.iter()) + .map(|p| p.key) + .collect() + }); + let id = key + .strip_prefix("hotkeys.") + .or_else(|| key.strip_prefix("mouseclicks."))?; + (!prefs.contains(key)).then_some(id) +} + +/// Every hotkey and mouse binding a named preset resolves to, with no user +/// layer. `None` resolves the editor-agnostic baseline alone. +/// +/// Keys are action ids; every value holds at least one [`Binding`]. An action +/// the preset binds nothing to is **absent** — the map is already resolved, so +/// absence is a complete statement rather than an instruction to look in a +/// lower layer. No empty vector is ever emitted. +/// +/// Builds from the generated presets directly rather than the process-global +/// config, so a caller needs no initialization and cannot be perturbed by +/// whatever the running editor has selected. +pub fn preset_bindings(overlay: Option<&str>) -> BTreeMap> { + let defaults = parse_yaml_preset(presets_gen::DEFAULTS_YAML) + .unwrap_or_else(|e| panic!("failed to parse defaults.yaml: {e}")); + let mut overlays = HashMap::new(); + for (name, yaml) in presets_gen::OVERLAYS { + let map = parse_yaml_preset(yaml) + .unwrap_or_else(|e| panic!("failed to parse overlay {name}: {e}")); + overlays.insert((*name).to_string(), map); + } + let config = Config { + defaults, + overlays, + user: HashMap::new(), + }; + + // The union of keys the agnostic layer and this overlay declare — every key + // that could resolve to anything under this preset. Deduped: a key both + // layers declare is one key that resolves once, not two. + let mut keys: Vec<&String> = config.defaults.keys().collect(); + if let Some(name) = overlay { + if let Some(m) = config.overlays.get(name) { + keys.extend(m.keys()); + } + } + keys.sort(); + keys.dedup(); + + let mut out: BTreeMap> = BTreeMap::new(); + for key in keys { + let Some(action) = bound_action_id(key) else { + continue; + }; + let Some(ConfigValue::Str(raw)) = config.resolve(overlay, key, false) else { + continue; + }; + // `collect_string_facet` joins a YAML list with `|`, so one id can + // carry several chords. Shipping the joined string would push a + // splitting rule onto every consumer. + let bindings: Vec = raw + .split('|') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(Binding::parse) + .collect(); + if bindings.is_empty() { + continue; + } + out.entry(action.to_string()).or_default().extend(bindings); + } out } +pub fn agnostic_default(key: &str) -> Option { + thread_local! { + static DEFAULTS: HashMap = + parse_yaml_preset(presets_gen::DEFAULTS_YAML) + .unwrap_or_else(|e| panic!("failed to parse defaults.yaml: {e}")); + } + DEFAULTS.with(|d| d.get(key).cloned()) +} + #[cfg(test)] mod tests { use super::*; @@ -431,4 +578,194 @@ mod tests { // Unknown key → false (defensive). assert!(!kind_is_int("bogus.key")); } + + /// Resolution never returns a short map: every id the agnostic layer or + /// this overlay declares comes back, because both layers are walked. An + /// inheritance bug shows up here as a missing key. + #[test] + fn preset_bindings_covers_every_key_in_every_preset() { + let binding_ids = |yaml: &str| -> Vec { + parse_yaml_preset(yaml) + .unwrap() + .into_iter() + .filter_map(|(k, v)| { + // A key whose value is an empty string binds nothing. + match v { + ConfigValue::Str(s) if !s.trim().is_empty() => Some(k), + _ => None, + } + }) + .filter_map(|k| bound_action_id(&k).map(str::to_string)) + .collect() + }; + + for (name, yaml) in presets_gen::OVERLAYS { + let mut expected: Vec = binding_ids(presets_gen::DEFAULTS_YAML); + expected.extend(binding_ids(yaml)); + expected.sort(); + expected.dedup(); + + let got = preset_bindings(Some(name)); + let mut got_ids: Vec = got.keys().cloned().collect(); + got_ids.sort(); + assert_eq!( + got_ids, expected, + "preset `{name}` resolves a different id set than its layers declare" + ); + } + + let mut expected = binding_ids(presets_gen::DEFAULTS_YAML); + expected.sort(); + expected.dedup(); + let mut got_ids: Vec = preset_bindings(None).keys().cloned().collect(); + got_ids.sort(); + assert_eq!( + got_ids, expected, + "the agnostic baseline resolves a different id set" + ); + } + + /// An overlay sits *above* the baseline rather than replacing it: every id + /// the agnostic layer binds is still bound under every overlay, with the + /// overlay's chords where it overrides and the baseline's otherwise. + #[test] + fn preset_bindings_inherits_the_agnostic_layer() { + let base = preset_bindings(None); + for (name, yaml) in presets_gen::OVERLAYS { + let overlay_map = parse_yaml_preset(yaml).unwrap(); + let resolved = preset_bindings(Some(name)); + for (id, base_bindings) in &base { + let got = resolved.get(id).unwrap_or_else(|| { + panic!("preset `{name}` dropped `{id}`, which the baseline binds") + }); + let overridden = overlay_map.contains_key(&format!("hotkeys.{id}")) + || overlay_map.contains_key(&format!("mouseclicks.{id}")); + if !overridden { + let a: Vec<&String> = base_bindings.iter().map(|b| &b.raw).collect(); + let b: Vec<&String> = got.iter().map(|b| &b.raw).collect(); + assert_eq!(a, b, "preset `{name}` changed `{id}` without overriding it"); + } + } + } + } + + /// Absence means "binds nothing"; there is no empty vector to misread as + /// "explicitly unbound". No preset mechanism produces one today, and this + /// keeps the two-valued design from creeping back without one. + #[test] + fn preset_bindings_never_emits_an_empty_vec() { + let mut presets: Vec> = vec![None]; + presets.extend(presets_gen::OVERLAYS.iter().map(|(n, _)| Some(*n))); + for preset in presets { + for (id, bindings) in preset_bindings(preset) { + assert!( + !bindings.is_empty(), + "preset {preset:?} emitted an empty binding list for `{id}`" + ); + for b in &bindings { + assert!(!b.raw.is_empty(), "`{id}` carries an empty raw binding"); + assert!(!b.mac.is_empty(), "`{id}` renders empty on mac"); + assert!(!b.other.is_empty(), "`{id}` renders empty off mac"); + } + } + } + } + + /// The new public resolution API and `Config::get` must not drift: with no + /// user layer they are the same walk, and this pins that they agree. + #[test] + fn preset_bindings_matches_config_get_with_no_user_layer() { + for (name, _) in presets_gen::OVERLAYS { + reset_state(); + pick(name); + for (id, bindings) in preset_bindings(Some(name)) { + let got: Vec = bindings.iter().map(|b| b.raw.clone()).collect(); + + // An action can be bound in both facets — Krita gives + // `isolateLayer` a key *and* two mouse chords — and the + // exporter merges them, key-sorted, under the one id. Rebuild + // that from `Config::get` and require the same list. + let mut expected: Vec = Vec::new(); + for facet in ["hotkeys", "mouseclicks"] { + let raw = CONFIG.with(|c| c.borrow().get(&format!("{facet}.{id}")).cloned()); + if let Some(ConfigValue::Str(raw)) = raw { + expected.extend( + raw.split('|') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + ); + } + } + assert!( + !expected.is_empty(), + "`{id}` resolves through preset_bindings but not through Config::get" + ); + assert_eq!( + got, expected, + "`{id}` differs between the two paths under `{name}`" + ); + } + } + reset_state(); + } + + /// Prints the measured id counts per preset. Not an assertion — the counts + /// move whenever a preset gains a binding, which is why the tests above + /// assert the *rule* that produces them instead. + #[test] + #[ignore = "reporting only"] + fn report_binding_counts() { + let mut union: std::collections::BTreeSet = Default::default(); + let base = preset_bindings(None); + union.extend(base.keys().cloned()); + println!("defaults: {}", base.len()); + for (name, _) in presets_gen::OVERLAYS { + let m = preset_bindings(Some(name)); + union.extend(m.keys().cloned()); + println!("{name}: {}", m.len()); + } + println!("union: {}", union.len()); + } + + /// Every binding in every preset names something that actually exists. + /// + /// A preset can name any id; one nothing registers is a key that silently + /// does nothing — the bug class that once shipped a dead Ctrl+I. Everything + /// a chord can reach declares the id that reaches it on its catalog entry + /// (`hotkey_action`), so reading the whole of that surface covers the + /// actions, the tool selections and the filters in one comparison. The ids + /// are deliberately not derivable from a `type_id` (`colorpicker` declares + /// `colorPickerTool`), so nothing else catches a typo on either side. + /// + /// Checked over [`preset_bindings`] rather than the raw YAML so the "which + /// keys are bindings" rule has one home. + #[test] + fn every_preset_binding_names_a_registered_target() { + let catalogs = crate::catalog::catalogs(); + let registered: std::collections::BTreeSet<&'static str> = catalogs + .iter() + .flat_map(|c| c.entries.iter()) + .filter_map(|e| e.hotkey_action) + .collect(); + + let mut presets: Vec> = vec![None]; + presets.extend(presets_gen::OVERLAYS.iter().map(|(name, _)| Some(*name))); + + let mut checked = 0usize; + for preset in presets { + let label = preset.unwrap_or("defaults"); + for action in preset_bindings(preset).keys() { + assert!( + registered.contains(action.as_str()), + "preset `{label}` binds `{action}`, which nothing registers \ + (registered: {registered:?})" + ); + checked += 1; + } + } + assert!( + checked > 0, + "no bindings found in any preset — the scan is looking in the wrong place" + ); + } } diff --git a/crates/darkly/src/config/schema.rs b/crates/darkly/src/config/schema.rs index 32825c8b..310bc07e 100644 --- a/crates/darkly/src/config/schema.rs +++ b/crates/darkly/src/config/schema.rs @@ -85,90 +85,3 @@ pub enum WidgetHint { /// shouldn't show up as a "setting". Hidden, } - -// --------------------------------------------------------------------------- -// Flat serialization views for the WASM bridge. -// --------------------------------------------------------------------------- - -/// Flat serialization of a [`SchemaSection`] with prefs already projected. -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SectionInfo { - pub id: &'static str, - pub display_name: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<&'static str>, - #[serde(skip_serializing_if = "Option::is_none")] - pub icon: Option<&'static str>, - pub order: i32, - pub prefs: Vec, -} - -/// Flat view of a single [`Pref`] with kind/range/options inlined. -/// Avoids a tagged enum so the frontend can consume the JSON without -/// discriminator unwrapping. No `default` field — defaults live in -/// the YAML overlay/agnostic layers, not in the schema. -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PrefInfo { - pub key: &'static str, - pub display_name: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<&'static str>, - /// `"bool" | "int" | "float" | "str" | "enum"`. - pub kind: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - pub min: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max: Option, - /// Populated for `"enum"` kinds only: `[[value, label], ...]`. - #[serde(skip_serializing_if = "Option::is_none")] - pub options: Option, - /// `"auto" | "numberInput" | "hotkey" | "mouseBinding" | "color"`. - pub widget: &'static str, -} - -impl SectionInfo { - pub fn from_section(section: &SchemaSection) -> Self { - SectionInfo { - id: section.id, - display_name: section.display_name, - description: section.description, - icon: section.icon, - order: section.order, - prefs: section.prefs.iter().map(PrefInfo::from_pref).collect(), - } - } -} - -impl PrefInfo { - pub fn from_pref(pref: &Pref) -> Self { - let (kind, min, max, options) = match &pref.kind { - PrefKind::Bool => ("bool", None, None, None), - PrefKind::Int { min, max } => ("int", Some(*min as f64), Some(*max as f64), None), - PrefKind::Float { min, max } => ("float", Some(*min), Some(*max), None), - PrefKind::Str => ("str", None, None, None), - PrefKind::Enum { options } => ("enum", None, None, Some(serde_json::json!(options))), - }; - PrefInfo { - key: pref.key, - display_name: pref.display_name, - description: pref.description, - kind, - min, - max, - options, - widget: widget_hint_str(&pref.widget), - } - } -} - -fn widget_hint_str(hint: &WidgetHint) -> &'static str { - match hint { - WidgetHint::Auto => "auto", - WidgetHint::NumberInput => "numberInput", - WidgetHint::Hotkey => "hotkey", - WidgetHint::Color => "color", - WidgetHint::Hidden => "hidden", - } -} diff --git a/crates/darkly/src/docs_render/mod.rs b/crates/darkly/src/docs_render/mod.rs new file mode 100644 index 00000000..4e8e6292 --- /dev/null +++ b/crates/darkly/src/docs_render/mod.rs @@ -0,0 +1,664 @@ +//! Renders one animated preview per previewable registry entry, to disk. +//! +//! Sixteen blend modes have no icon anywhere, and ten veils deliberately have +//! none either because their picker renders a live thumbnail instead. For all +//! four effect catalogs the image *is* the documentation — and for most of them +//! a still is not enough, because what a control does only becomes legible when +//! it moves. This module renders that motion headlessly against a fixed subject +//! and writes it out as PNG frame sequences plus a small index. +//! +//! **This module renders nothing of its own.** A preview's motion belongs to +//! the entry that has it — `Veil::preview_at`, `Void::preview_at`, a filter +//! registration's `preview_at` — and the driver that runs it is +//! [`crate::gpu::preview`], the same one the editor's pickers go through. What +//! is left here is what only a headless documentation run needs: a fixed +//! synthetic subject instead of the user's canvas, a blocking capture sink +//! instead of an asynchronous one, PNGs on disk, and an index beside them. +//! +//! **Two leftover renderers.** A blend mode is a relation between two images +//! rather than an effect over one, so there is no `src → out` mechanism to open +//! for it; it is rendered through a real [`DarklyEngine`] document whose top +//! layer's opacity this module drives directly. A brush is a stroke driven +//! through the brush engine rather than an effect over one image, so it has no +//! mechanism either; it is rendered through the same `BrushStrokePreviewRenderer` +//! and the same framer the editor's picker goes through. Both are further +//! *callers* of the same `PreviewAnim`, not further preview systems. +//! +//! Everything in this module performs blocking GPU readbacks and is therefore +//! gated behind the `testing` feature exactly as `gpu::test_utils` is. Engine, +//! compositor and WASM-bridge code cannot name it in a production build. + +pub mod subject; + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::brush::pipeline::BrushPipelines; +use crate::brush::preview_renderer::BrushStrokePreviewRenderer; +use crate::catalog::preview_mechanisms; +use crate::engine::DarklyEngine; +use crate::gpu::context::{GpuContext, GpuDevice}; +use crate::gpu::filter::FilterPipelineRegistry; +use crate::gpu::preview::{ + drive, frame_t, swing, PreviewAnim, PreviewMechanism, PreviewRegistries, PreviewSequence, + PreviewTarget, PreviewVariant, PREVIEW_FORMAT, +}; +use crate::gpu::test_utils::{readback_texture, test_device}; +use crate::gpu::veil::VeilRegistry; +use crate::gpu::void::VoidRegistry; +use crate::layer::LayerId; +use subject::{blend_source_rgba, subject_rgba, DOCS_SUBJECT_DIM}; + +/// The preview target always resamples its source into its own preview-sized +/// texture. Feeding it the subject at twice the output edge puts that resample +/// at the 2:1 ratio its shader was written for, where the four taps land on +/// input texel centres and tile the 2 × 2 block exactly — an area average rather +/// than the softening a 1:1 pass would apply to the very edges the blur, +/// pixelate, painting and aberration previews are read by. +const SUBJECT_SCALE: u32 = 2; + +/// How far the blend-mode preview's top layer rises over the backdrop at `t`. +/// +/// The one host-layer knob in the tree, and it lives here rather than in a +/// shared vocabulary because blend modes are the only catalog that needs one and +/// the only catalog rendered through a document. It returns to zero, which is +/// why [`crate::gpu::blend_mode::PREVIEW`] declares a closing loop. +pub fn blend_opacity_at(t: f32) -> f32 { + swing(t) +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub enum DocsRenderError { + /// A catalog holds previewable entries but no renderer knows how to draw + /// them — what a new previewable registry looks like from here. + NoRenderer { + catalog: String, + type_id: String, + }, + /// An entry claims previewability but its registry hands out no recipe. + NoRecipe { + catalog: String, + type_id: String, + }, + Usage(String), + Io(std::io::Error), + Encode(image::ImageError), +} + +impl std::fmt::Display for DocsRenderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoRenderer { catalog, type_id } => write!( + f, + "`{catalog}/{type_id}` is previewable but `{catalog}` has no renderer" + ), + Self::NoRecipe { catalog, type_id } => { + write!(f, "`{catalog}/{type_id}` declares no preview recipe") + } + Self::Usage(m) => write!(f, "{m}"), + Self::Io(e) => write!(f, "{e}"), + Self::Encode(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for DocsRenderError {} + +impl From for DocsRenderError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +impl From for DocsRenderError { + fn from(e: image::ImageError) -> Self { + Self::Encode(e) + } +} + +// --------------------------------------------------------------------------- +// The index written beside the frames +// --------------------------------------------------------------------------- + +/// What a consumer cannot derive from a directory listing: how big the frames +/// are, how many files, how fast to play them, whether the last frame hands back +/// to the first without a visible jump, and which frame stands for the whole. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Asset { + pub dir: String, + /// Frame dimensions. Per-asset rather than per-manifest: an effect is + /// documented against the fixed square subject, but a brush stroke is a + /// left-to-right line framed to the picker strip's own shape. + pub width: u32, + pub height: u32, + pub frames: u32, + pub fps: u32, + #[serde(rename = "loop")] + pub loops: bool, + /// Index of the poster frame — the one a consumer shows when it is not + /// playing the sequence, and the same frame the editor's picker renders for + /// [`PreviewVariant::Still`]. `{still:03}.png` in `dir`. + pub still: u32, +} + +/// A thin index of what was written — not a second copy of the metadata export. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Manifest { + /// The same value the metadata export stamps, and the only thing that lets + /// a consumer check that a JSON artifact and an asset directory came from + /// one build. + pub version: String, + /// Catalog id → entry type id → what was written for it. + pub assets: BTreeMap>, +} + +/// One RGBA8 buffer per frame, in playback order. +pub type Frames = Vec>; + +/// One entry's rendered frames, with the playback facts its declaration +/// determines. +pub struct Rendered { + pub frames: Frames, + /// Dimensions of every frame in [`Self::frames`]. Carried alongside the + /// pixels because the renderers do not all produce the same shape. + pub width: u32, + pub height: u32, + pub fps: u32, + pub loops: bool, + pub still: u32, +} + +// --------------------------------------------------------------------------- +// Shared GPU state +// --------------------------------------------------------------------------- + +/// One device, one preview target, and the one document the blend-mode renderer +/// needs, for the whole run. +/// +/// Every `DarklyEngine` construction splices sixteen WGSL arms into the +/// composite shader and compiles it, which on a software rasterizer is real CPU +/// time — and blend modes differ from one another only by an in-place property +/// write, so one document serves the whole catalog. The offscreen catalogs need +/// no document at all. +pub struct Gpu { + gpu: Arc, + target: PreviewTarget, + /// Kept alive for the target's loaded source; the downscale has already + /// consumed it, but dropping the texture it was read from is still wrong. + subject: Option, + veils: VeilRegistry, + voids: VoidRegistry, + filters: FilterPipelineRegistry, + blend_doc: Option, + /// The brush engine's GPU pipelines and its stroke-preview scratch target. + /// Both are reusable for the whole run and both are expensive to build, for + /// the same reason `blend_doc` is kept. + brush: Option<(BrushPipelines, BrushStrokePreviewRenderer)>, +} + +/// The theme every documentation brush stroke is rendered in — a white stroke +/// on black, which is also the engine's own default (`preview_theme_fg` / +/// `preview_theme_bg`). Named here rather than read off an engine so a headless +/// run depends on nothing ambient, and stated once so the staged backdrop's +/// tones and the stroke colour cannot drift apart. +const DOCS_STROKE_FG: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +const DOCS_STROKE_BG: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; + +/// The subject with a second, differently-oriented field stacked over it — the +/// layer whose blend mode and opacity the preview drives. +struct BlendDoc { + engine: DarklyEngine, + top: LayerId, +} + +impl Default for Gpu { + fn default() -> Self { + Self::new() + } +} + +impl Gpu { + pub fn new() -> Self { + let (device, queue) = test_device(); + Gpu { + #[allow(clippy::arc_with_non_send_sync)] // see GpuDevice's own docs + gpu: Arc::new(GpuDevice { device, queue }), + target: PreviewTarget::new(), + subject: None, + veils: VeilRegistry::new(), + voids: VoidRegistry::new(), + filters: FilterPipelineRegistry::new(), + blend_doc: None, + brush: None, + } + } + + fn blend_doc(&mut self) -> &mut BlendDoc { + if self.blend_doc.is_none() { + let dim = DOCS_SUBJECT_DIM; + let mut engine = DarklyEngine::new( + GpuContext::new_headless_shared(Arc::clone(&self.gpu)), + dim, + dim, + ); + let backdrop = engine.paste_image(dim, dim, &subject_rgba(dim), 0, 0, None); + let top = engine.paste_image(dim, dim, &blend_source_rgba(dim), 0, 0, Some(backdrop)); + self.blend_doc = Some(BlendDoc { engine, top }); + } + self.blend_doc.as_mut().unwrap() + } + + /// Fill the preview target with the fixed subject — the documentation run's + /// one substitution for the editor's live canvas. Built once and reloaded + /// per entry, because a mechanism that generates its own content wants the + /// source cleared rather than loaded. + fn load_subject(&mut self, reads_source: bool) { + if !reads_source { + self.target.clear_source( + &self.gpu.device, + &self.gpu.queue, + DOCS_SUBJECT_DIM, + DOCS_SUBJECT_DIM, + ); + return; + } + let dim = DOCS_SUBJECT_DIM * SUBJECT_SCALE; + let texture = self.subject.get_or_insert_with(|| { + let texture = self.gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("docs-subject"), + size: wgpu::Extent3d { + width: dim, + height: dim, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: PREVIEW_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.gpu.queue.write_texture( + texture.as_image_copy(), + &subject_rgba(dim), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(dim * 4), + rows_per_image: Some(dim), + }, + wgpu::Extent3d { + width: dim, + height: dim, + depth_or_array_layers: 1, + }, + ); + texture + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.target + .load_source(&self.gpu.device, &self.gpu.queue, &view, dim, dim); + } +} + +// --------------------------------------------------------------------------- +// The two consumers +// --------------------------------------------------------------------------- + +impl Gpu { + /// Render one entry through the shared driver with the blocking sink. + /// + /// The whole of what this module contributes: a subject, a `readback_texture` + /// instead of a `ReadbackScheduler`, and no per-tick budget — the binary + /// wants every frame now. + fn render_offscreen( + &mut self, + mech: &'static dyn PreviewMechanism, + catalog: &str, + type_id: &str, + ) -> Result { + let no_recipe = || DocsRenderError::NoRecipe { + catalog: catalog.to_string(), + type_id: type_id.to_string(), + }; + let anim = mech.resolve(type_id).ok_or_else(no_recipe)?.anim; + self.load_subject(mech.reads_source()); + + let device = Arc::clone(&self.gpu); + let (w, h) = self.target.size(); + let mut frames = Vec::with_capacity(anim.frames as usize); + { + let Gpu { + target, + veils, + voids, + filters, + .. + } = self; + // The binary's counterpart of `Compositor::preview_registries`, + // destructured here rather than behind a method so `target` stays + // borrowable alongside it. + let regs = PreviewRegistries { + veils, + voids, + filters, + }; + // The whole sequence: a documentation asset is every frame, and the + // poster is recorded as an index into it rather than written twice. + let mut seq = PreviewSequence::open(mech, regs, type_id, PreviewVariant::Animated) + .ok_or_else(no_recipe)?; + drive( + &mut seq, + &device.device, + &device.queue, + target, + |encoder, output, _, _| { + device.queue.submit([encoder.finish()]); + frames.push(readback_texture( + &device.device, + &device.queue, + output, + PREVIEW_FORMAT, + w, + h, + )); + }, + ); + } + Ok(Rendered { + frames, + width: w, + height: h, + fps: anim.fps, + loops: anim.loops, + still: anim.still_frame(), + }) + } + + /// Render one blend mode through a real document, driving the top layer's + /// opacity — the one thing a consumer without a document cannot do, which is + /// why this catalog has no offscreen mechanism. + fn render_blend_mode(&mut self, type_id: &str) -> Result { + let anim: PreviewAnim = crate::gpu::blend_mode::registry() + .preview(type_id) + .ok_or_else(|| DocsRenderError::NoRecipe { + catalog: crate::gpu::blend_mode::CATALOG_ID.to_string(), + type_id: type_id.to_string(), + })?; + let doc = self.blend_doc(); + doc.engine.set_blend_mode(doc.top, type_id); + + let mut frames = Vec::with_capacity(anim.frames as usize); + for i in 0..anim.frames { + let opacity = blend_opacity_at(frame_t(i, anim.frames)); + doc.engine.set_opacity(doc.top, opacity); + frames.push(doc.engine.test_readback_canvas()); + } + // The document is reused across every mode, and a mode only ever writes + // the frames it renders — so leaving the last frame's opacity behind + // would leak into the next entry's first frame. + doc.engine.set_opacity(doc.top, 1.0); + Ok(Rendered { + frames, + width: DOCS_SUBJECT_DIM, + height: DOCS_SUBJECT_DIM, + fps: anim.fps, + loops: anim.loops, + still: anim.still_frame(), + }) + } + + /// Render one brush's preview stroke — the same synthetic S-curve, through + /// the same stroke engine and the same framer the editor's picker uses, so + /// the documentation and the picker show one image of a brush rather than + /// two. + /// + /// A brush is a stroke driven through the brush engine rather than an effect + /// over one image, so like a blend mode it has no `src → out` mechanism to + /// open — it is a second caller of the same `PreviewAnim`, not a second + /// preview system. + fn render_brush_stroke(&mut self, type_id: &str) -> Result { + let no_recipe = || DocsRenderError::NoRecipe { + catalog: crate::brush::builtin_brushes::CATALOG_ID.to_string(), + type_id: type_id.to_string(), + }; + let anim = crate::brush::builtin_brushes::preview(type_id).ok_or_else(no_recipe)?; + + // Brushes are keyed by file stem in the catalog and by name in the + // library, and `docs()` is what pairs the two. + let position = crate::brush::builtin_brushes::docs() + .iter() + .position(|(stem, _)| *stem == type_id) + .ok_or_else(no_recipe)?; + let mut graph = crate::brush::builtin_brushes::all() + .swap_remove(position) + .metadata + .graph; + + // The same two steps `request_stroke_preview_readback` takes, in the + // same order, so the artifact and the picker render the same brush. + graph.apply_preview_overrides(); + let backdrop = crate::brush::graph_capabilities(&graph).preview_backdrop; + + let (rw, rh) = crate::engine::brush_library::BRUSH_STROKE_RENDER_SIZE; + let inset = + rw.min(rh) as f32 * crate::engine::brush_library::BRUSH_STROKE_PATH_INSET_FRACTION; + let path = + crate::brush::preview_renderer::synthesize_stroke_path(rw as f32, rh as f32, 30, inset); + + let (device, queue) = (&self.gpu.device, &self.gpu.queue); + let (pipelines, renderer) = self.brush.get_or_insert_with(|| { + ( + BrushPipelines::new( + device, + queue, + &crate::gpu::selection::selection_mask_bgl(device), + ), + BrushStrokePreviewRenderer::new(), + ) + }); + let texture = renderer + .render_stroke( + device, + queue, + pipelines, + &graph, + &path, + DOCS_STROKE_FG, + DOCS_STROKE_BG, + backdrop, + rw, + rh, + None, + ) + .ok_or_else(no_recipe)?; + let pixels = readback_texture(device, queue, texture, PREVIEW_FORMAT, rw, rh); + + let (tw, th) = crate::engine::brush_library::BRUSH_THUMBNAIL_SIZE; + let framed = crate::engine::rendering::frame_stroke_thumbnail( + &pixels, + rw, + rh, + tw, + th, + backdrop, + DOCS_STROKE_FG, + DOCS_STROKE_BG, + ); + Ok(Rendered { + frames: vec![framed], + width: tw, + height: th, + fps: anim.fps, + loops: anim.loops, + still: anim.still_frame(), + }) + } +} + +/// Render one entry's whole sequence, plus the playback facts its animation +/// determines. The binary and the tests both come through here, so neither +/// re-implements the dispatch. +/// +/// A catalog with an offscreen mechanism goes through the shared driver; the +/// two catalogs without go through a document and through the brush engine +/// respectively. A catalog with none of the three is +/// [`DocsRenderError::NoRenderer`] — what a new previewable registry that has +/// not declared a mechanism looks like from here. +pub fn render_entry( + gpu: &mut Gpu, + catalog: &str, + type_id: &str, +) -> Result { + if let Some((_, mech)) = preview_mechanisms() + .into_iter() + .find(|(id, _)| *id == catalog) + { + return gpu.render_offscreen(mech, catalog, type_id); + } + if catalog == crate::gpu::blend_mode::CATALOG_ID { + return gpu.render_blend_mode(type_id); + } + if catalog == crate::brush::builtin_brushes::CATALOG_ID { + return gpu.render_brush_stroke(type_id); + } + Err(DocsRenderError::NoRenderer { + catalog: catalog.to_string(), + type_id: type_id.to_string(), + }) +} + +/// The source the offscreen path handed the effect, read back. +/// +/// Test-only, and gated for the same reason `PreviewTarget::source_texture` is: +/// nothing in a run reads the source back, and `AGENTS.md` §No Blocking GPU +/// Readbacks keeps readback surface behind the gate. A value-pinned assertion +/// about what a filter *did* has to compare against what it was *given* — the +/// 2:1 area average of the subject, not the subject itself. +#[cfg(any(test, feature = "testing"))] +pub fn test_source_pixels(gpu: &mut Gpu) -> Vec { + gpu.load_subject(true); + let (w, h) = gpu.target.size(); + readback_texture( + &gpu.gpu.device, + &gpu.gpu.queue, + gpu.target.source_texture(), + PREVIEW_FORMAT, + w, + h, + ) +} + +/// Write one entry's frames as zero-padded PNGs, creating `dir`. +fn write_frames(dir: &Path, frames: &[Vec], w: u32, h: u32) -> Result<(), DocsRenderError> { + use image::ImageEncoder; + std::fs::create_dir_all(dir)?; + for (i, pixels) in frames.iter().enumerate() { + let mut out = Vec::new(); + image::codecs::png::PngEncoder::new(std::io::Cursor::new(&mut out)).write_image( + pixels, + w, + h, + image::ExtendedColorType::Rgba8, + )?; + std::fs::write(dir.join(format!("{i:03}.png")), out)?; + } + Ok(()) +} + +/// Render every previewable catalog entry into `out` and write the index +/// beside them. +/// +/// Directory names are the catalog and entry ids themselves — no id literal +/// appears in the layout — so the asset directory and the metadata artifact +/// cannot disagree about what a thing is called. +pub fn render_all(out: &Path) -> Result { + let mut gpu = Gpu::new(); + let mut assets: BTreeMap> = BTreeMap::new(); + + for catalog in crate::catalog::catalogs() { + for entry in catalog.entries.iter().filter(|e| e.supports_preview) { + let rendered = render_entry(&mut gpu, catalog.id, entry.type_id)?; + let rel = PathBuf::from(catalog.id).join(entry.type_id); + write_frames( + &out.join(&rel), + &rendered.frames, + rendered.width, + rendered.height, + )?; + assets.entry(catalog.id.to_string()).or_default().insert( + entry.type_id.to_string(), + Asset { + dir: rel.to_string_lossy().replace('\\', "/"), + width: rendered.width, + height: rendered.height, + frames: rendered.frames.len() as u32, + fps: rendered.fps, + loops: rendered.loops, + still: rendered.still, + }, + ); + } + } + + let manifest = Manifest { + version: crate::VERSION.to_string(), + assets, + }; + std::fs::create_dir_all(out)?; + std::fs::write( + out.join("assets.json"), + serde_json::to_string_pretty(&manifest).expect("the manifest is plain data"), + )?; + Ok(manifest) +} + +// --------------------------------------------------------------------------- +// Arguments +// --------------------------------------------------------------------------- + +pub const USAGE: &str = "\ +render_docs — render an animated preview for every previewable registry entry + +USAGE: + render_docs --out + +OPTIONS: + --out Directory to write frame sequences and assets.json into + --help Print this message +"; + +pub struct Args { + /// `None` when `--help` was asked for and there is no work to do. + pub out: Option, +} + +/// Parse the command line. This lives here rather than in the binary because +/// coverage tooling runs test targets and never executes a `[[bin]]` — anything +/// left inside `fn main` is untestable by construction. +pub fn parse_args(argv: impl Iterator) -> Result { + let mut out = None; + let mut argv = argv.peekable(); + while let Some(arg) = argv.next() { + match arg.as_str() { + "--help" | "-h" => return Ok(Args { out: None }), + "--out" => { + out = Some(PathBuf::from(argv.next().ok_or_else(|| { + DocsRenderError::Usage("--out needs a directory".into()) + })?)) + } + other => { + return Err(DocsRenderError::Usage(format!( + "unrecognized argument `{other}`" + ))) + } + } + } + Ok(Args { + out: Some(out.ok_or_else(|| DocsRenderError::Usage("--out is required".into()))?), + }) +} diff --git a/crates/darkly/src/docs_render/subject.rs b/crates/darkly/src/docs_render/subject.rs new file mode 100644 index 00000000..ced82452 --- /dev/null +++ b/crates/darkly/src/docs_render/subject.rs @@ -0,0 +1,206 @@ +//! The fixed image every documentation asset is rendered against. +//! +//! The editor's own pickers sample whatever is on the user's canvas, which is +//! exactly the right answer there and exactly the wrong one for documentation: +//! two assets are only comparable if they depict the same thing. So the subject +//! is generated rather than shipped — no binary in the repository, no licensing +//! question on a published crate, and no screenshot step that cannot run +//! headlessly. +//! +//! Both fields are described in normalized coordinates and sampled at pixel +//! centres, so each one is a single continuous image evaluated at whatever +//! resolution is asked for. That is what makes a `2 · dim` render a genuine +//! supersample of the `dim` one rather than a different picture. + +use crate::gpu::preview::{field_rgba, PREVIEW_MAX_DIM}; + +/// Edge length of every rendered documentation frame. +/// +/// This is [`PREVIEW_MAX_DIM`] rather than a number of its own: the offscreen +/// veil and void renderers are hard-wired to fit their output into that box, so +/// matching it is what makes every asset the same size regardless of which +/// mechanism produced it. +pub const DOCS_SUBJECT_DIM: u32 = PREVIEW_MAX_DIM; + +/// A solid shape laid over the smooth field, in normalized coordinates. +enum Shape { + /// `[x0, y0, x1, y1]`, half-open. + Rect([f32; 4], [f32; 3]), + /// Centre and radius. + Disc([f32; 2], f32, [f32; 3]), +} + +impl Shape { + fn covers(&self, u: f32, v: f32) -> Option<[f32; 3]> { + match self { + Shape::Rect([x0, y0, x1, y1], c) => { + (u >= *x0 && u < *x1 && v >= *y0 && v < *y1).then_some(*c) + } + Shape::Disc([cx, cy], r, c) => { + let (dx, dy) = (u - cx, v - cy); + (dx * dx + dy * dy < r * r).then_some(*c) + } + } + } +} + +/// Hard-edged solids over the smooth field: saturated primaries for the effects +/// that displace or resample colour channels, and a near-black / near-white pair +/// giving the tone controls something to clip against. Their edges are what the +/// blur, pixelate, painting and aberration previews are read by. +const SHAPES: &[Shape] = &[ + Shape::Disc([0.5, 0.30], 0.16, [0.16, 0.47, 0.92]), + Shape::Rect([0.06, 0.62, 0.30, 0.86], [0.90, 0.12, 0.16]), + Shape::Rect([0.36, 0.62, 0.60, 0.86], [0.03, 0.03, 0.04]), + Shape::Rect([0.66, 0.62, 0.94, 0.86], [0.97, 0.97, 0.95]), +]; + +/// Fully-saturated colour at `hue` degrees, at value `v`. +fn hue_ramp(hue: f32, v: f32) -> [f32; 3] { + let h = (hue / 60.0).rem_euclid(6.0); + let f = h - h.floor(); + let (p, q, t) = (0.0, 1.0 - f, f); + let rgb = match h as u32 { + 0 => [1.0, t, p], + 1 => [q, 1.0, p], + 2 => [p, 1.0, t], + 3 => [p, q, 1.0], + 4 => [t, p, 1.0], + _ => [1.0, p, q], + }; + [rgb[0] * v, rgb[1] * v, rgb[2] * v] +} + +/// The subject's fields are square and fully opaque; everything else about the +/// rasterization is [`field_rgba`]'s. +fn pack(dim: u32, field: impl Fn(f32, f32) -> [f32; 3]) -> Vec { + field_rgba(dim, dim, |u, v| { + let c = field(u, v); + [c[0], c[1], c[2], 1.0] + }) +} + +/// The documentation subject at `dim × dim`, RGBA8 and fully opaque. +/// +/// A horizontal sweep through the hue wheel crossed with a vertical ramp from +/// black to full value — colour and the whole tonal range, which is what the +/// hue, desaturation, curves, levels and brightness previews are read against — +/// overlaid with [`SHAPES`] for the effects whose subject is an edge. +/// +/// Opacity is not a variable here: `test_readback_canvas` reads the composite +/// cache, where premultiplied and straight alpha coincide only for opaque +/// content. +pub fn subject_rgba(dim: u32) -> Vec { + pack(dim, |u, v| { + SHAPES + .iter() + .find_map(|s| s.covers(u, v)) + .unwrap_or_else(|| hue_ramp(u * 360.0, v)) + }) +} + +/// The upper layer of a blend-mode preview at `dim × dim`, RGBA8 and fully +/// opaque. +/// +/// A diagonal ramp between two non-symmetric mid-tones, held away from the 0 +/// and 1 boundaries so every mode's formula is exercised on its interior rather +/// than on an edge case — the same reasoning behind the fixed colour pair in +/// `tests/blend_modes.rs`. Its axis runs across the subject's, so no two modes +/// collapse onto the same output. +pub fn blend_source_rgba(dim: u32) -> Vec { + const NEAR: [f32; 3] = [0.85, 0.34, 0.14]; + const FAR: [f32; 3] = [0.18, 0.44, 0.80]; + pack(dim, |u, v| { + let d = (u + v) * 0.5; + std::array::from_fn(|i| NEAR[i] + (FAR[i] - NEAR[i]) * d) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gpu::preview::pixel_centre; + + /// Both fields are pure functions of position — no RNG, no clock, no I/O. + /// A seed or a timestamp leaking in would make every asset unreproducible + /// and every frame-to-frame comparison meaningless. + #[test] + fn docs_subject_is_deterministic() { + assert_eq!(subject_rgba(64), subject_rgba(64)); + assert_eq!(blend_source_rgba(64), blend_source_rgba(64)); + } + + /// Every pixel is opaque and both buffers cover the whole canvas — the + /// precondition the composite readback depends on. + #[test] + fn subject_covers_the_canvas_opaquely() { + for buf in [subject_rgba(64), blend_source_rgba(64)] { + assert_eq!(buf.len(), 64 * 64 * 4); + assert!( + buf.chunks_exact(4).all(|p| p[3] == 255), + "a pixel is not opaque" + ); + } + } + + /// Each 2 × 2 block of the doubled render averages to the corresponding + /// pixel of the single render, wherever the field is smooth. + /// + /// This pins the normalized-coordinate property the veil path relies on: the + /// veil preview renderer always resamples its source, and it is fed the + /// subject at 2× precisely so that resample is an exact box average of the + /// *same* field. If the generator ever sampled in integer pixel space the + /// two renders would drift apart by a fraction of a pixel and the veil + /// assets would silently start depicting a slightly different image. + /// + /// A shape boundary is the one place a point sample and an area average are + /// *meant* to differ — one lands inside the solid, the other is part covered. + /// Asked of the real [`SHAPES`] rather than a second copy of their geometry. + fn straddles_a_shape(x: u32, y: u32, dim: u32) -> bool { + let covered = |x: u32, y: u32, d: u32| { + let (u, v) = pixel_centre(x, y, d, d); + SHAPES.iter().position(|s| s.covers(u, v).is_some()) + }; + let here = covered(x, y, dim); + (0..4).any(|i| covered(x * 2 + (i & 1), y * 2 + (i >> 1), dim * 2) != here) + } + + #[test] + fn subject_at_2x_area_averages_to_the_1x_field() { + let dim = DOCS_SUBJECT_DIM; + let one = subject_rgba(dim); + let two = subject_rgba(dim * 2); + assert_eq!(two.len(), one.len() * 4); + + let at = |buf: &[u8], d: u32, x: u32, y: u32, c: usize| { + buf[((y * d + x) * 4) as usize + c] as i32 + }; + + let mut compared = 0usize; + for y in 0..dim { + for x in 0..dim { + if straddles_a_shape(x, y, dim) { + continue; + } + for c in 0..3 { + let block: i32 = (0..4) + .map(|i| at(&two, dim * 2, x * 2 + (i & 1), y * 2 + (i >> 1), c)) + .sum(); + let avg = (block as f32 / 4.0).round() as i32; + let point = at(&one, dim, x, y, c); + assert!( + (avg - point).abs() <= 1, + "at ({x},{y}) channel {c}: 2× block averages {avg}, 1× samples {point}" + ); + compared += 1; + } + } + } + // The skip rule covers shape outlines, which are a thin minority. + assert!( + compared > (dim * dim * 3) as usize * 9 / 10, + "only {compared} of {} samples were away from a shape edge", + dim * dim * 3 + ); + } +} diff --git a/crates/darkly/src/document/filter.rs b/crates/darkly/src/document/filter.rs index 9d11f758..873b291e 100644 --- a/crates/darkly/src/document/filter.rs +++ b/crates/darkly/src/document/filter.rs @@ -28,6 +28,10 @@ use crate::layer::{LayerId, NodeCommon, PixelBuffer}; pub struct FilterEntityRegistration { pub type_id: &'static str, pub display_name: &'static str, + /// Iconify name shown on the filter's row under its host layer. + pub icon: &'static str, + /// One-sentence summary of what attaching this filter does. + pub description: &'static str, /// Produce the manifest body + any pixel-blob refs. Infallible by /// construction; see the analogous note on /// [`crate::document::layer_kind::LayerKindRegistration::serialize`]. @@ -42,6 +46,33 @@ pub struct FilterEntityRegistration { pub remap_ids: fn(&mut Filter, &IdMap), } +/// Id of the catalog this registry projects into. Distinct from the `filters` +/// catalog of `crate::gpu::filter`, which registers colour adjustments rather +/// than the mask and selection modifiers attached to a host layer. +pub const CATALOG_ID: &str = "layerFilters"; + +impl FilterEntityRegistration { + pub fn catalog_entry(&self) -> crate::catalog::CatalogEntry { + crate::catalog::CatalogEntry::new(self.type_id, self.display_name) + .with_icon(self.icon) + .with_description(self.description) + } +} + +/// The layer-filter catalog — every registered kind, sorted by `type_id`. +pub fn catalog() -> crate::catalog::Catalog { + crate::catalog::Catalog::new( + CATALOG_ID, + "Layer Filters", + registry() + .all() + .into_iter() + .map(FilterEntityRegistration::catalog_entry) + .collect(), + ) + .with_description("Typed effects attached to a single host layer or group.") +} + /// Auto-discovered filter registry — owns the per-kind registration records /// and hands out `&'static FilterEntityRegistration` references for the dispatch /// surface (`Filter::kind`) and the UI. diff --git a/crates/darkly/src/document/filters/mask.rs b/crates/darkly/src/document/filters/mask.rs index f82a806d..c4f93f7c 100644 --- a/crates/darkly/src/document/filters/mask.rs +++ b/crates/darkly/src/document/filters/mask.rs @@ -87,6 +87,8 @@ pub fn register() -> FilterEntityRegistration { FilterEntityRegistration { type_id: TYPE_ID, display_name: "Mask", + icon: "fa6-solid:mask", + description: "A greyscale channel that hides or reveals its host layer per pixel.", serialize, deserialize, remap_ids, diff --git a/crates/darkly/src/document/filters/selection.rs b/crates/darkly/src/document/filters/selection.rs index 868e01b2..e737f449 100644 --- a/crates/darkly/src/document/filters/selection.rs +++ b/crates/darkly/src/document/filters/selection.rs @@ -91,6 +91,8 @@ pub fn register() -> FilterEntityRegistration { FilterEntityRegistration { type_id: TYPE_ID, display_name: "Selection", + icon: "fa6-solid:vector-square", + description: "The active marching-ants region that confines edits to part of the canvas.", serialize, deserialize, remap_ids, diff --git a/crates/darkly/src/document/layer_kind.rs b/crates/darkly/src/document/layer_kind.rs index b08719df..c715e360 100644 --- a/crates/darkly/src/document/layer_kind.rs +++ b/crates/darkly/src/document/layer_kind.rs @@ -55,6 +55,10 @@ pub struct LayerKindRegistration { pub type_id: &'static str, pub display_name: &'static str, + /// One-sentence summary of what this kind holds — the layer panel's + /// tooltip and the reference manual's row for it. + pub description: &'static str, + /// May this kind host a mask modifier? Consumed by the layer panel to /// gate "Add mask" without branching on `type_id` — a new kind opts in /// (or out) here, in its own file, and the UI follows automatically. @@ -99,6 +103,33 @@ pub struct LayerKindRegistration { pub remap_ids: fn(&mut LayerNode, &IdMap), } +/// Id of the catalog this registry projects into. +pub const CATALOG_ID: &str = "layerKinds"; + +impl LayerKindRegistration { + pub fn catalog_entry(&self) -> crate::catalog::CatalogEntry { + // `icon` is empty for kinds that always render a live thumbnail + // instead; `with_icon` turns that into a declared absence. + crate::catalog::CatalogEntry::new(self.type_id, self.display_name) + .with_icon(self.icon) + .with_description(self.description) + } +} + +/// The layer-kind catalog — every registered kind, sorted by `type_id`. +pub fn catalog() -> crate::catalog::Catalog { + crate::catalog::Catalog::new( + CATALOG_ID, + "Layer Kinds", + registry() + .all() + .into_iter() + .map(LayerKindRegistration::catalog_entry) + .collect(), + ) + .with_description("What a node in the layer tree is made of.") +} + pub struct LayerKindRegistry { /// Owned storage — stable addresses while the registry lives (forever). entries: Vec, diff --git a/crates/darkly/src/document/layer_kinds/filter.rs b/crates/darkly/src/document/layer_kinds/filter.rs index ea2684b4..ad11b2c1 100644 --- a/crates/darkly/src/document/layer_kinds/filter.rs +++ b/crates/darkly/src/document/layer_kinds/filter.rs @@ -44,6 +44,7 @@ pub fn register() -> LayerKindRegistration { LayerKindRegistration { type_id: TYPE_ID, display_name: "Filter Layer", + description: "A color adjustment applied to everything composited beneath it.", can_have_mask: true, can_rename: true, has_thumbnail: false, diff --git a/crates/darkly/src/document/layer_kinds/group.rs b/crates/darkly/src/document/layer_kinds/group.rs index 2afe5f8f..84b361d5 100644 --- a/crates/darkly/src/document/layer_kinds/group.rs +++ b/crates/darkly/src/document/layer_kinds/group.rs @@ -33,6 +33,7 @@ pub fn register() -> LayerKindRegistration { LayerKindRegistration { type_id: TYPE_ID, display_name: "Group", + description: "A folder of layers composited together and treated as one.", can_have_mask: true, can_rename: true, has_thumbnail: false, diff --git a/crates/darkly/src/document/layer_kinds/raster.rs b/crates/darkly/src/document/layer_kinds/raster.rs index a456622a..b11aa982 100644 --- a/crates/darkly/src/document/layer_kinds/raster.rs +++ b/crates/darkly/src/document/layer_kinds/raster.rs @@ -50,6 +50,7 @@ pub fn register() -> LayerKindRegistration { LayerKindRegistration { type_id: TYPE_ID, display_name: "Raster Layer", + description: "A grid of pixels — what a brush stroke paints into.", can_have_mask: true, can_rename: true, has_thumbnail: true, diff --git a/crates/darkly/src/document/layer_kinds/vector.rs b/crates/darkly/src/document/layer_kinds/vector.rs index 0680812b..f964c3f7 100644 --- a/crates/darkly/src/document/layer_kinds/vector.rs +++ b/crates/darkly/src/document/layer_kinds/vector.rs @@ -44,6 +44,7 @@ pub fn register() -> LayerKindRegistration { LayerKindRegistration { type_id: TYPE_ID, display_name: "Vector Layer", + description: "Resolution-independent shapes and text, rasterized at draw time.", can_have_mask: true, can_rename: true, has_thumbnail: false, diff --git a/crates/darkly/src/document/layer_kinds/void.rs b/crates/darkly/src/document/layer_kinds/void.rs index 248d6a49..146f372d 100644 --- a/crates/darkly/src/document/layer_kinds/void.rs +++ b/crates/darkly/src/document/layer_kinds/void.rs @@ -65,6 +65,7 @@ pub fn register() -> LayerKindRegistration { LayerKindRegistration { type_id: TYPE_ID, display_name: "Void Layer", + description: "Pixels generated on demand by a source rather than stored.", can_have_mask: true, can_rename: true, has_thumbnail: false, diff --git a/crates/darkly/src/engine/brush_graph.rs b/crates/darkly/src/engine/brush_graph.rs index a20d2db5..fa501a01 100644 --- a/crates/darkly/src/engine/brush_graph.rs +++ b/crates/darkly/src/engine/brush_graph.rs @@ -9,6 +9,7 @@ use super::{DarklyEngine, ReadbackContext}; use crate::brush::input_value::InputValue; use crate::brush::state::BrushState; use crate::brush::wire::BrushWireType; +use crate::gpu::preview::PreviewBackdrop; use crate::nodegraph::Graph; use crate::nodegraph::{NodeId, PortDir, PortRef, UnitType}; @@ -40,7 +41,7 @@ enum ChangeKind { /// - `PortDef::preview_value` — caller-side /// `Graph::apply_preview_overrides` replaces the scrubbed value /// with a preview-mode constant before rendering (used by - /// `paint.size`, `watercolor.size`, …). + /// `brush_settings.size`, `blur.strength`). /// - `PortDef::preview_irrelevant_scrub` — the preview pipeline /// structurally ignores the port (used by `pen_input.stabilize`, /// which the synthetic-stroke preview's hard-wired `PassThrough` @@ -61,7 +62,11 @@ impl DarklyEngine { #[handler] pub fn brush_node_types(&self) -> Vec> { let registry = crate::brush::registry(); - registry.types().map(|r| r.node.clone()).collect() + registry + .types() + .into_iter() + .map(|r| r.node.clone()) + .collect() } /// Capabilities the active brush graph derives from its nodes' @@ -514,13 +519,15 @@ impl DarklyEngine { return cached.unwrap_or_default(); } - self.request_stroke_preview_readback(self.active_brush_graph(), |width, height| { - ReadbackContext::BrushStrokePreview { + self.request_stroke_preview_readback( + self.active_brush_graph(), + |width, height, backdrop| ReadbackContext::BrushStrokePreview { width, height, + backdrop, graph_version: current_graph_version, - } - }); + }, + ); self.last_rendered_stroke_preview_version = current_graph_version; cached.unwrap_or_default() @@ -669,6 +676,7 @@ impl DarklyEngine { height: u32, fg: [f32; 4], bg: [f32; 4], + backdrop: PreviewBackdrop, base_size_override: Option, context: ReadbackContext, ) { @@ -680,6 +688,7 @@ impl DarklyEngine { path, fg, bg, + backdrop, width, height, base_size_override, @@ -722,10 +731,14 @@ impl DarklyEngine { /// show brush *identity*, not the momentary scrub value the user happened /// to have. Per-node knowledge of what to neutralize lives on the port /// registrations — this pipeline never introspects node types. + /// The backdrop travels with the request rather than being read off the + /// engine when the readback lands: two brushes can have bakes in flight at + /// once, and the framer must measure each against the field its own stroke + /// was drawn over. pub(crate) fn request_stroke_preview_readback( &mut self, mut graph: Graph, - make_context: impl FnOnce(u32, u32) -> ReadbackContext, + make_context: impl FnOnce(u32, u32, PreviewBackdrop) -> ReadbackContext, ) { graph.apply_preview_overrides(); let (rw, rh) = super::brush_library::BRUSH_STROKE_RENDER_SIZE; @@ -734,6 +747,7 @@ impl DarklyEngine { crate::brush::preview_renderer::synthesize_stroke_path(rw as f32, rh as f32, 30, inset); let fg = self.preview_theme_fg; let bg = self.preview_theme_bg; + let backdrop = crate::brush::graph_capabilities(&graph).preview_backdrop; self.render_preview_and_request_readback( &graph, &path, @@ -741,8 +755,9 @@ impl DarklyEngine { rh, fg, bg, + backdrop, None, - make_context(rw, rh), + make_context(rw, rh, backdrop), ); } @@ -754,6 +769,11 @@ impl DarklyEngine { /// differ only in the graph and the [`ReadbackContext`] variant. The dab /// thumbnail represents brush identity (shape, texture, dynamics), so /// user-facing scrubs that vary across instances shouldn't bias it. + /// + /// Always [`PreviewBackdrop::Flat`]: a stationary full-pressure sample has + /// no motion for a displacement to reveal, so a field under it would show + /// the field with a barely perturbed centre. The four brushes that would + /// want one show their declared glyph in this slot instead. pub(crate) fn request_dab_preview_readback( &mut self, mut graph: Graph, @@ -771,6 +791,7 @@ impl DarklyEngine { rh, fg, bg, + PreviewBackdrop::Flat, Some(super::brush_library::DAB_PREVIEW_BASE_SIZE), make_context(rw, rh), ); @@ -1168,6 +1189,67 @@ impl DarklyEngine { Ok(self.active_graph_json()) } + /// Override an input port's slider bounds on one node instance. + /// + /// `display_min`/`display_max` arrive in **display space**, the same + /// contract [`Self::brush_set_exposed_port`] uses for its value — so the + /// caller hands back the numbers it was shown in + /// [`ExposedValue::Scalar`]'s `min`/`max` and needs no unit logic of its + /// own. Storage is raw port space, matching the brush yaml. + /// + /// Bounds are UI-only, so nothing recompiles and the stored port value is + /// untouched — but the override is authored brush state that survives + /// save/load, so this bumps the topology version like the other + /// graph-authoring handlers. Both the brush bar and the node editor read + /// the instance `PortDef::min`/`max`, so one call re-ranges the control in + /// every view. + #[handler(returns = graph)] + pub fn brush_graph_set_port_range( + &mut self, + node_id: &str, + port_name: &str, + display_min: f32, + display_max: f32, + ) -> Result { + let nid = NodeId(node_id.to_string()); + let unit_type = self.exposed_port_unit_type(&nid, port_name); + self.tool_session + .write() + .get_mut::() + .expect(NO_BRUSH_STATE) + .graph + .set_port_range( + &nid, + port_name, + unit_type.from_display(display_min), + unit_type.from_display(display_max), + ) + .map_err(|e| format!("{e}"))?; + self.bump_brush_topology_version(); + Ok(self.active_graph_json()) + } + + /// The display unit an input port's numbers are expressed in on the + /// wire, preferring the registration's declaration over the instance + /// copy — the same precedence [`Self::brush_exposed_ports`] applies when + /// it converts values out. + fn exposed_port_unit_type(&self, node_id: &NodeId, port_name: &str) -> UnitType { + let tool = self.tool_session.read(); + let brush = tool.get::().expect(NO_BRUSH_STATE); + let Some(node) = brush.graph.nodes().get(node_id) else { + return UnitType::default(); + }; + crate::brush::registry() + .get(&node.type_id) + .and_then(|r| { + r.ports + .iter() + .find(|p| p.name == port_name && p.dir == PortDir::Input) + }) + .map(|p| p.unit_type) + .unwrap_or_default() + } + /// Remove a brush-bar entry. Idempotent (missing entries aren't an /// error). Bumps the topology version so the frontend clears the /// active preset name. diff --git a/crates/darkly/src/engine/brush_library.rs b/crates/darkly/src/engine/brush_library.rs index c147dfe7..caf5d8a3 100644 --- a/crates/darkly/src/engine/brush_library.rs +++ b/crates/darkly/src/engine/brush_library.rs @@ -8,7 +8,7 @@ use crate::brush::library::BrushInfo; /// Dimensions used for baked brush thumbnails. Matches the live editor /// preview so brushes look identical in the picker grid. -pub(crate) const BRUSH_THUMBNAIL_SIZE: (u32, u32) = (320, 120); +pub const BRUSH_THUMBNAIL_SIZE: (u32, u32) = (320, 120); /// Render canvas for stroke previews. Generously oversized — not derived /// from any per-brush geometry. `apply_preview_overrides` neutralizes the @@ -98,13 +98,15 @@ impl DarklyEngine { // size-invariant — the picker grid should show brush identity, not a // snapshot of whatever scrub value the user happened to have when // saving. - self.request_stroke_preview_readback(self.active_brush_graph(), |width, height| { - ReadbackContext::BrushThumbnailForSave { + self.request_stroke_preview_readback( + self.active_brush_graph(), + |width, height, backdrop| ReadbackContext::BrushThumbnailForSave { name: name.to_string(), width, height, - } - }); + backdrop, + }, + ); Ok(()) } @@ -133,13 +135,15 @@ impl DarklyEngine { let Some(brush) = self.brush_library.get(name).cloned() else { return Vec::new(); }; - self.request_stroke_preview_readback(brush.metadata.graph.clone(), |width, height| { - ReadbackContext::BrushThumbnailForSave { + self.request_stroke_preview_readback( + brush.metadata.graph.clone(), + |width, height, backdrop| ReadbackContext::BrushThumbnailForSave { name: name.to_string(), width, height, - } - }); + backdrop, + }, + ); Vec::new() } diff --git a/crates/darkly/src/engine/filters/apply.rs b/crates/darkly/src/engine/filters/apply.rs index 5189d91d..8ebe43fe 100644 --- a/crates/darkly/src/engine/filters/apply.rs +++ b/crates/darkly/src/engine/filters/apply.rs @@ -15,7 +15,6 @@ use super::super::rendering::commit_undo_region; use super::super::{DarklyEngine, FilterPreview, PendingFilter}; use crate::coord::{CanvasRect, WindowRect}; use crate::engine::protocol::{params_from_json, RawParams}; -use crate::engine::types::{ParamInfo, VeilTypeInfo}; use crate::gpu::params::ParamValue; use crate::layer::LayerId; use crate::undo::GpuRegionAction; @@ -35,31 +34,6 @@ pub(crate) enum FilterRegion { #[handlers] impl DarklyEngine { - /// All registered filter types (id + display name + param schema), as the - /// same `VeilTypeInfo` shape veils/voids use. Parameter-free filters (invert) - /// carry an empty `params`; parametric ones (curves) carry their schema. - /// Drives both the frontend's dynamic Colors-menu action registration and - /// the filter-layer properties panel. - #[handler] - pub fn filter_types(&self) -> Vec { - let registry = self.compositor.filter_pipeline_registry(); - registry - .types() - .into_iter() - .map(|(type_id, display_name, icon, description)| VeilTypeInfo { - type_id, - display_name, - icon, - description, - params: registry - .params(type_id) - .iter() - .map(|d| ParamInfo::from_def(d, None)) - .collect(), - }) - .collect() - } - /// Wire entry for `apply_filter` — coerces `params` against the filter /// type's schema (defaults fill any omitted values), then /// [`Self::apply_filter_typed`]. Parameter-free filters (invert) carry an diff --git a/crates/darkly/src/engine/mod.rs b/crates/darkly/src/engine/mod.rs index 8166c39a..ea1f97ec 100644 --- a/crates/darkly/src/engine/mod.rs +++ b/crates/darkly/src/engine/mod.rs @@ -1,6 +1,6 @@ mod bake_common; mod brush_graph; -mod brush_library; +pub(crate) mod brush_library; mod canvas_resize; mod canvas_transform; mod clipboard; @@ -17,6 +17,7 @@ mod layers; mod load; mod merge; mod painting; +pub mod preview; pub mod process_recording; pub mod protocol; pub mod rendering; @@ -28,14 +29,14 @@ mod veils; mod voids; pub use brush_graph::{ExposedPortInfo, ExposedValue}; +pub use brush_library::BRUSH_THUMBNAIL_SIZE; pub use export::ExportImageResult; pub use load::LoadDocument; pub use process_recording::{ProcessRecorder, RecordedFrame}; pub use rendering::{PickSource, DEFAULT_THUMB_SIZE}; pub use save::{SaveError, SaveJob, SavePurpose, SaveReadbackKind}; pub use types::{ - BlendModeTypeInfo, ClipboardExport, EngineState, LayerInfo, LayerKindTypeInfo, ModifierInfo, - ModifierTypeInfo, ParamInfo, StrokeOp, ToolTypeInfo, VeilInfo, VeilTypeInfo, VoidTypeInfo, + ClipboardExport, EngineState, LayerInfo, ModifierInfo, ParamInfo, StrokeOp, VeilInfo, }; pub use perf::{BrushPerfDelta, FrameRenderPhases}; @@ -58,13 +59,12 @@ use crate::gpu::context::GpuContext; use crate::gpu::diff_rect::DiffRectPass; use crate::gpu::overlay::OverlayPrimitive; use crate::gpu::paint_target::PaintPipelines; +use crate::gpu::preview::{PreviewBackdrop, PreviewTarget}; use crate::gpu::readback::ReadbackScheduler; use crate::gpu::region_store::{EntryPixels, RegionScratch}; use crate::gpu::selection::SelectionPipelines; use crate::gpu::transform::FloatingContent; -use crate::gpu::veil_preview::VeilPreviewRenderer; use crate::gpu::view::{ViewParams, ViewTransform}; -use crate::gpu::void_preview::VoidPreviewRenderer; use crate::layer::LayerId; use crate::undo::UndoStack; use std::collections::HashMap; @@ -222,6 +222,11 @@ pub(crate) enum ReadbackContext { BrushStrokePreview { width: u32, height: u32, + /// The field the stroke was rendered over. The framer finds the stroke + /// by looking for pixels the backdrop did not put there, so it has to + /// travel with the request — the engine's theme may have moved on, and + /// another brush's bake may be in flight alongside this one. + backdrop: PreviewBackdrop, /// Graph version at the time the render was issued — used to skip /// caching stale results if another render has superseded this one. graph_version: u64, @@ -234,6 +239,8 @@ pub(crate) enum ReadbackContext { name: String, width: u32, height: u32, + /// See [`ReadbackContext::BrushStrokePreview::backdrop`]. + backdrop: PreviewBackdrop, }, /// Async readback of a single-dab preview rendered from a library /// brush's graph. Completion PNG-encodes the pixels and installs the @@ -295,15 +302,15 @@ pub(crate) enum ReadbackContext { UndoRegionReady { cell: std::rc::Rc>, }, - /// Async readback of one picker preview frame, rendered offscreen - /// (`gpu::veil_preview` for veils over the current canvas, `gpu::void_preview` - /// for voids from scratch). Completion drops the raw RGBA bytes into - /// `previews[(kind, type_id)].frames[frame_idx]`; the frontend drains all + /// Async readback of one picker preview frame, rendered offscreen through + /// [`crate::gpu::preview`]. Completion drops the raw RGBA bytes into + /// `previews[(catalog, type_id)].frames[frame_idx]`; the frontend drains all /// `total` frames once they land and plays them as a loop. Each frame is the /// job's aspect-fit `width × height` RGBA. PreviewFrame { - kind: PreviewKind, + catalog: &'static str, type_id: &'static str, + variant: crate::gpu::preview::PreviewVariant, frame_idx: u32, total: u32, }, @@ -318,22 +325,16 @@ pub(crate) enum ReadbackContext { }, } -/// Which kind of effect a picker preview is generating. Keys the shared -/// `previews` map alongside the effect's `'static` type id, so a veil and a void -/// that happen to share a type-id string never collide. -#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] -pub enum PreviewKind { - Veil, - Void, -} - -/// One picker preview generation: the chosen preview dimensions plus the -/// per-frame RGBA slots, each filled when its async readback lands. `width` / -/// `height` are carried so `poll_preview` and the WASM bridge report the real -/// (aspect-fit) thumbnail size, which varies with the document's shape. +/// One picker preview generation: the chosen preview dimensions, the entry's +/// own playback rate, and the per-frame RGBA slots, each filled when its async +/// readback lands. `width` / `height` are carried so `poll_preview` and the WASM +/// bridge report the real (aspect-fit) thumbnail size, which varies with the +/// document's shape; `fps` because the entry's `PreviewAnim` owns that fact and +/// the wire response must not answer with a second one. pub(crate) struct PreviewJob { pub width: u32, pub height: u32, + pub fps: u32, pub frames: Vec>>, } @@ -526,21 +527,29 @@ pub struct DarklyEngine { pub(crate) preview_theme_fg: [f32; 4], pub(crate) preview_theme_bg: [f32; 4], - // --- Picker previews (veil + void) --- - /// Offscreen renderer for the veil picker's looping thumbnail previews. - /// Reused across veils; holds its own preview-sized scratch textures and - /// is fully independent of the live veil chain and document. - pub(crate) veil_preview_renderer: VeilPreviewRenderer, - /// Offscreen renderer for the void picker's looping thumbnail previews. - /// Reused across voids; holds its own preview-sized output texture and is - /// fully independent of the live layer stack and document. - pub(crate) void_preview_renderer: VoidPreviewRenderer, - /// In-flight + completed preview jobs, keyed by `(kind, &'static str type + // --- Picker previews --- + /// Scratch textures every picker preview is rendered through, whatever the + /// catalog. Reused across entries and fully independent of the live veil + /// chain, layer stack and document. + pub(crate) preview_target: PreviewTarget, + /// Previews requested but not yet started, in arrival order. + pub(crate) preview_queue: std::collections::VecDeque, + /// Whether the target's loaded subject predates the current burst of + /// requests. Set when a request arrives with nothing in flight, so one + /// `render_offscreen` serves a whole picker's worth of cards. + pub(crate) preview_source_dirty: bool, + /// Whether that subject is the live composite rather than a cleared texture + /// — what the last mechanism to open asked for. + pub(crate) preview_source_is_composite: bool, + /// The one preview being generated right now — see + /// [`preview::PREVIEW_FRAMES_PER_TICK`] for why generation is serialized. + pub(crate) preview_active: Option, + /// In-flight + completed preview jobs, keyed by `(catalog, &'static str type /// id)`. Frame slots fill in asynchronously as `ReadbackContext::PreviewFrame` /// readbacks land; `poll_preview` hands back the frames once every slot is - /// `Some`. Overwritten on each `start_*_preview` so the picker always - /// reflects the current document (no cross-open caching). - pub(crate) previews: HashMap<(PreviewKind, &'static str), PreviewJob>, + /// `Some` and removes the job, so the next open regenerates against the + /// canvas as it then stands. + pub(crate) previews: HashMap, // --- Brush Library --- pub(crate) brush_library: BrushLibrary, @@ -742,8 +751,11 @@ impl DarklyEngine { // `set_preview_theme()` as soon as the UI loads. preview_theme_fg: [1.0, 1.0, 1.0, 1.0], preview_theme_bg: [0.0, 0.0, 0.0, 1.0], - veil_preview_renderer: VeilPreviewRenderer::new(), - void_preview_renderer: VoidPreviewRenderer::new(), + preview_target: PreviewTarget::new(), + preview_queue: std::collections::VecDeque::new(), + preview_source_dirty: true, + preview_source_is_composite: false, + preview_active: None, previews: HashMap::new(), brush_library: { let mut lib = BrushLibrary::new(); @@ -812,69 +824,6 @@ impl DarklyEngine { } } -// --------------------------------------------------------------------------- -// Shared picker-preview plumbing -// --------------------------------------------------------------------------- - -impl DarklyEngine { - /// Encode one preview frame and submit its async readback. The effect- - /// specific render — a veil's ping-pong pass or a void's generate pass — - /// lives in the `encode` closure; everything else (the command encode, the - /// readback request keyed by `(kind, type_id, frame_idx, total)`, and the - /// scheduler submission) is shared between the veil and void preview paths. - /// - /// An associated function rather than a `&mut self` method so the caller can - /// hand it disjoint borrows: `gpu` / `readbacks` here, while `encode` and - /// `output` borrow the per-effect preview renderer. - #[allow(clippy::too_many_arguments)] - pub(crate) fn encode_preview_frame( - gpu: &GpuContext, - readbacks: &mut ReadbackScheduler, - kind: PreviewKind, - type_id: &'static str, - frame_idx: u32, - total: u32, - output: &wgpu::Texture, - format: wgpu::TextureFormat, - rect: crate::coord::LayerRect, - encode: impl FnOnce(&mut wgpu::CommandEncoder), - ) { - gpu.encode("preview-frame", |encoder| { - encode(encoder); - let request = - crate::gpu::readback::request_readback(&gpu.device, encoder, output, format, rect); - readbacks.submit( - request, - ReadbackContext::PreviewFrame { - kind, - type_id, - frame_idx, - total, - }, - ); - }); - } - - /// Return the completed preview for `(kind, type_id)` as - /// `(width, height, frames)` once every frame has landed, else `None`. Each - /// frame is `width × height` tightly-packed RGBA8. Shared by both pickers. - pub fn poll_preview( - &self, - kind: PreviewKind, - type_id: &str, - ) -> Option<(u32, u32, Vec>)> { - let (_, job) = self - .previews - .iter() - .find(|((k, t), _)| *k == kind && *t == type_id)?; - if job.frames.is_empty() || job.frames.iter().any(Option::is_none) { - return None; - } - let frames = job.frames.iter().map(|f| f.clone().unwrap()).collect(); - Some((job.width, job.height, frames)) - } -} - // --------------------------------------------------------------------------- // Test helpers (public so integration tests can use them) // --------------------------------------------------------------------------- @@ -1240,7 +1189,8 @@ impl DarklyEngine { let inset = rw.min(rh) as f32 * brush_library::BRUSH_STROKE_PATH_INSET_FRACTION; let path = crate::brush::preview_renderer::synthesize_stroke_path(rw as f32, rh as f32, 30, inset); - self.test_render_preview_canvas(&graph, &path, rw, rh, None) + let backdrop = crate::brush::graph_capabilities(&graph).preview_backdrop; + self.test_render_preview_canvas(&graph, &path, backdrop, rw, rh, None) } /// Blocking readback of the raw dab-preview **render canvas** for the @@ -1256,6 +1206,7 @@ impl DarklyEngine { self.test_render_preview_canvas( &graph, &path, + crate::gpu::preview::PreviewBackdrop::Flat, rw, rh, Some(brush_library::DAB_PREVIEW_BASE_SIZE), @@ -1270,6 +1221,7 @@ impl DarklyEngine { &mut self, graph: &crate::nodegraph::Graph, path: &[crate::brush::paint_info::PaintInformation], + backdrop: crate::gpu::preview::PreviewBackdrop, rw: u32, rh: u32, base_size_override: Option, @@ -1286,6 +1238,7 @@ impl DarklyEngine { path, fg, bg, + backdrop, rw, rh, base_size_override, @@ -1367,6 +1320,12 @@ impl DarklyEngine { /// Uses `device.poll(Wait)` to ensure mapping callbacks fire, then /// dispatches every completed readback through the shared handler — /// same semantics as a real frame's `poll_pending`. + /// + /// Gated with the rest of the blocking-readback surface: `device.poll(Wait)` + /// deadlocks on WebGPU, where the browser event loop is the only thing that + /// resolves buffer mappings, so production and WASM builds must not be able + /// to name this at all. + #[cfg(any(test, feature = "testing"))] pub fn test_flush_readbacks(&mut self) { let _ = self.gpu.device.poll(wgpu::PollType::Wait { submission_index: None, @@ -1403,12 +1362,7 @@ mod tests { #[test] fn param_info_serializes_flat() { - let def = ParamDef::Float { - name: "speed", - min: 0.0, - max: 10.0, - default: 1.0, - }; + let def = ParamDef::float("speed", 0.0, 10.0, 1.0); let info = ParamInfo::from_def(&def, Some(&ParamValue::Float(2.5))); let json = serde_json::to_value(&info).unwrap(); assert_eq!(json["kind"], "float"); @@ -1421,10 +1375,7 @@ mod tests { #[test] fn param_info_bool_omits_min_max() { - let def = ParamDef::Bool { - name: "soft", - default: true, - }; + let def = ParamDef::boolean("soft", true); let info = ParamInfo::from_def(&def, None); let json = serde_json::to_value(&info).unwrap(); assert_eq!(json["kind"], "bool"); @@ -1442,20 +1393,9 @@ mod tests { visible: true, index: 0, params: vec![ + ParamInfo::from_def(&ParamDef::int("scale", 1, 32, 2), Some(&ParamValue::Int(4))), ParamInfo::from_def( - &ParamDef::Int { - name: "scale", - min: 1, - max: 32, - default: 2, - }, - Some(&ParamValue::Int(4)), - ), - ParamInfo::from_def( - &ParamDef::Bool { - name: "soft", - default: true, - }, + &ParamDef::boolean("soft", true), Some(&ParamValue::Bool(false)), ), ], diff --git a/crates/darkly/src/engine/painting.rs b/crates/darkly/src/engine/painting.rs index 844432e8..ec139bc6 100644 --- a/crates/darkly/src/engine/painting.rs +++ b/crates/darkly/src/engine/painting.rs @@ -946,6 +946,7 @@ impl DarklyEngine { self.active_base_size(), stabilizer, clone_source_anchor, + StrokeEngine::random_seed(), )); // Merged clone freezes the root composite, so make sure it's @@ -970,11 +971,19 @@ impl DarklyEngine { // canvas this means dabs landing on off-canvas pixels are // saved/restored correctly on undo. let layer_extent = layer_tex.layer_extent(); + // The terminal decides what its scratch holds — colour for + // most brushes, a displacement field for liquify. + let scratch_format = self + .brush_stroke_engine + .as_ref() + .map(|e| e.scratch_format()) + .unwrap_or(crate::brush::node::COLOR_SCRATCH_FORMAT); let mut stroke_buffer = StrokeBuffer::new( &self.gpu.device, layer_extent.width, layer_extent.height, &self.brush_pipelines, + scratch_format, ); let paint_target = GpuPaintTarget::from_node(layer_tex, self.doc.canvas_rect()); self.gpu.encode("stroke-buffer-init", |encoder| { diff --git a/crates/darkly/src/engine/preview.rs b/crates/darkly/src/engine/preview.rs new file mode 100644 index 00000000..d3eb5718 --- /dev/null +++ b/crates/darkly/src/engine/preview.rs @@ -0,0 +1,311 @@ +//! Picker previews: the editor's consumer of [`crate::gpu::preview`]. +//! +//! One entry point per verb — enqueue, step, take — over every previewable +//! catalog. Which catalog a request names is looked up in the generated +//! `preview_mechanisms()` table, so a new previewable catalog is reachable here +//! without this file being edited. +//! +//! **Generation is paced, not batched.** `start_preview` only enqueues; +//! [`DarklyEngine::pump_previews`] runs one sequence at a time and encodes at +//! most [`PREVIEW_FRAMES_PER_TICK`] frames per engine tick. Every frame in +//! flight is an unpooled `MAP_READ` staging buffer, so bounding the tick +//! bounds the memory — opening a picker with seventeen animated cards would +//! otherwise put the whole sequence's staging buffers in flight at once. +//! +//! Capture is asynchronous throughout (`AGENTS.md` §No Blocking GPU Readbacks): +//! each frame's readback is appended to the *same* submission that encoded it, +//! so it captures that frame before the next overwrites the output texture. + +use super::DarklyEngine; +use super::PreviewJob; +use super::ReadbackContext; +use crate::catalog::preview_mechanisms; +use crate::coord::LayerRect; +use crate::gpu::preview::{PreviewMechanism, PreviewSequence, PreviewVariant, PREVIEW_FORMAT}; + +/// What keys a preview job: which catalog, which entry, and which of the entry's +/// two previews. A card's still and its animation are separate generations of +/// the same entry and coexist, so hovering never discards the frame already on +/// screen. +pub(crate) type PreviewKey = (&'static str, &'static str, PreviewVariant); + +/// Frames encoded per engine tick, across all pending previews. Bounds both +/// in-flight readback memory (this many `MAP_READ` staging buffers) and the GPU +/// work one tick can add. Ten cards of 48 frames drain in 60 ticks — about a +/// second at 60 Hz — and each card completes in order, so a picker fills +/// top-down rather than everything appearing at once. +pub const PREVIEW_FRAMES_PER_TICK: u32 = 8; + +/// The preview being generated right now. The sequence itself is not stored: +/// it borrows a registry off the compositor, so it is re-opened each tick and +/// seeked to `cursor` — free, because `preview_at` is absolute and a resumed +/// sequence reaches the state an uninterrupted one would have. +pub(crate) struct ActivePreview { + pub key: PreviewKey, + pub cursor: u32, +} + +/// Look a catalog id up in the generated table. +fn mechanism(catalog: &str) -> Option<(&'static str, &'static dyn PreviewMechanism)> { + preview_mechanisms() + .into_iter() + .find(|(id, _)| *id == catalog) +} + +impl DarklyEngine { + /// Queue one of the two previews of `catalog`/`type_id` — the effect applied + /// to the **current canvas** for the kinds that read one, generated from + /// scratch for the kinds that don't. + /// + /// A picker asks for [`PreviewVariant::Still`] per card and for + /// [`PreviewVariant::Animated`] only when the pointer arrives, so opening a + /// picker costs one frame per card rather than a full sequence each. + /// + /// Enqueues only; frames are produced by [`Self::pump_previews`] and + /// retrieved with [`Self::poll_preview`]. An unknown catalog or type, or one + /// declaring no preview, is a silent no-op — the request carries an + /// arbitrary wire string, and there is nothing to render. + /// + /// Fully isolated from the live document: the effect instance is built fresh + /// against the preview target's own textures, so the user's veil chain, + /// layer stack and compositor surface are never touched. + pub fn start_preview(&mut self, catalog: &str, type_id: &str, variant: PreviewVariant) { + let Some((catalog, mech)) = mechanism(catalog) else { + return; + }; + let Some(entry) = mech.resolve(type_id) else { + return; + }; + let key = (catalog, entry.type_id, variant); + + // Already done, already running, or already queued. Re-opening the + // picker after a poll *does* regenerate: the completed job is taken + // rather than cloned, so the canvas it reflects is always the current + // one. + let queued = self.preview_queue.iter().any(|k| *k == key); + let active = self.preview_active.as_ref().is_some_and(|a| a.key == key); + if self.previews.contains_key(&key) || queued || active { + return; + } + // A burst of requests — a picker opening — shares one composite. The + // flag is what makes `render_offscreen` cost once per burst rather than + // once per card. + if self.preview_queue.is_empty() && self.preview_active.is_none() { + self.preview_source_dirty = true; + } + self.preview_queue.push_back(key); + } + + /// Advance preview generation by at most [`PREVIEW_FRAMES_PER_TICK`] frames. + /// Called once per rendered frame beside the readback drain. + pub(crate) fn pump_previews(&mut self) { + let mut budget = PREVIEW_FRAMES_PER_TICK; + while budget > 0 { + if self.preview_active.is_none() && !self.open_next() { + return; + } + let Some(active) = self.preview_active.as_ref() else { + return; + }; + let (key, cursor) = (active.key, active.cursor); + let Some((_, mech)) = mechanism(key.0) else { + self.preview_active = None; + return; + }; + + match self.encode_frames(mech, key, cursor, budget) { + // The entry resolved but its mechanism could not open it. Drop + // the job rather than leave one nothing can ever complete: the + // frontend polls for 180 frames before giving up, so a job that + // can never fill is a silent hang. + None => { + self.previews.remove(&key); + self.preview_active = None; + return; + } + Some((encoded, done)) => { + budget -= encoded; + if let Some(a) = self.preview_active.as_mut() { + a.cursor = cursor + encoded; + } + if done { + self.preview_active = None; + } + if encoded == 0 { + return; + } + } + } + } + } + + /// Encode up to `budget` frames of `type_id` starting at `cursor`, each + /// with its readback appended to the encoding submission. Answers the frames + /// encoded and whether the sequence finished, or `None` if it could not be + /// opened. + fn encode_frames( + &mut self, + mech: &'static dyn PreviewMechanism, + key: PreviewKey, + cursor: u32, + budget: u32, + ) -> Option<(u32, bool)> { + let (catalog, type_id, variant) = key; + // Disjoint fields: the sequence borrows the compositor's registries + // while the capture closure borrows the GPU context and the readback + // scheduler. + let Self { + compositor, + gpu, + readbacks, + preview_target, + .. + } = self; + let mut seq = + PreviewSequence::open(mech, compositor.preview_registries(), type_id, variant)?; + seq.seek(cursor); + + let mut encoded = 0; + while encoded < budget { + let stepped = seq.step( + &gpu.device, + &gpu.queue, + preview_target, + |mut encoder, output, frame_idx, total| { + let rect = LayerRect::from_xywh(0, 0, output.width(), output.height()); + let request = crate::gpu::readback::request_readback( + &gpu.device, + &mut encoder, + output, + PREVIEW_FORMAT, + rect, + ); + gpu.queue.submit([encoder.finish()]); + readbacks.submit( + request, + ReadbackContext::PreviewFrame { + catalog, + type_id, + variant, + frame_idx, + total, + }, + ); + }, + ); + if !stepped { + break; + } + encoded += 1; + } + Some((encoded, seq.is_done())) + } + + /// Open the next queued preview, refreshing the target's subject if this is + /// the first of a burst. `false` when the queue is empty or the entry + /// evaporated. + /// + /// The composite does not change between the cards of one picker batch, so + /// one full-canvas `render_offscreen` serves all of them — which matters + /// most for the stills, where the batch is every card at once. + fn open_next(&mut self) -> bool { + let Some(key) = self.preview_queue.pop_front() else { + return false; + }; + let (catalog, type_id, variant) = key; + let Some((_, mech)) = mechanism(catalog) else { + return false; + }; + let Some(entry) = mech.resolve(type_id) else { + return false; + }; + + self.load_subject(mech.reads_source()); + + let (pw, ph) = self.preview_target.size(); + let frames = match variant { + PreviewVariant::Still => 1, + PreviewVariant::Animated => entry.anim.frames.max(1), + }; + self.previews.insert( + key, + PreviewJob { + width: pw, + height: ph, + fps: entry.anim.fps, + frames: vec![None; frames as usize], + }, + ); + self.preview_active = Some(ActivePreview { key, cursor: 0 }); + true + } + + /// Put the subject the next entry needs into the target: the live composite + /// for a mechanism that reads one, a cleared texture for one that generates + /// its own content. + /// + /// Reloads only when the target does not already hold what is wanted — + /// which for a burst of same-kind requests is once, and for a burst that + /// mixes kinds is once per switch. + fn load_subject(&mut self, reads_source: bool) { + if !self.preview_source_dirty && self.preview_source_is_composite == reads_source { + return; + } + if reads_source { + // Refresh the composite so the preview reflects the current + // document, even with no surface present yet (mirrors + // `start_export`). + self.compositor + .render_offscreen(&self.gpu.device, &self.gpu.queue, &mut self.doc); + } + let (w, h) = ( + self.compositor.canvas_width(), + self.compositor.canvas_height(), + ); + let Self { + compositor, + gpu, + preview_target, + .. + } = self; + if reads_source { + let source = compositor.composited_texture(); + let view = source.create_view(&wgpu::TextureViewDescriptor::default()); + preview_target.load_source(&gpu.device, &gpu.queue, &view, w, h); + } else { + preview_target.clear_source(&gpu.device, &gpu.queue, w, h); + } + self.preview_source_is_composite = reads_source; + self.preview_source_dirty = false; + } + + /// Take the completed preview for `catalog`/`type_id`/`variant` as + /// `(width, height, fps, frames)` once every frame has landed, else `None`. + /// Each frame is `width × height` tightly-packed RGBA8. + /// + /// Takes rather than clones: the frontend copies each frame into an + /// `ImageData` of its own, so retaining them here would keep every picker + /// session's frames alive for the life of the engine. Re-opening the picker + /// regenerates, which is correct anyway — the canvas has moved on. + pub fn poll_preview( + &mut self, + catalog: &str, + type_id: &str, + variant: PreviewVariant, + ) -> Option<(u32, u32, u32, Vec>)> { + let key = *self + .previews + .keys() + .find(|(c, t, v)| *c == catalog && *t == type_id && *v == variant)?; + if self.previews[&key].frames.iter().any(Option::is_none) { + return None; + } + let job = self.previews.remove(&key)?; + let frames = job + .frames + .into_iter() + .map(|f| f.expect("all filled")) + .collect(); + Some((job.width, job.height, job.fps, frames)) + } +} diff --git a/crates/darkly/src/engine/protocol/handlers/preview.rs b/crates/darkly/src/engine/protocol/handlers/preview.rs index 899a1789..88c9b0bf 100644 --- a/crates/darkly/src/engine/protocol/handlers/preview.rs +++ b/crates/darkly/src/engine/protocol/handlers/preview.rs @@ -1,59 +1,48 @@ -//! Picker previews (veil + void): one generic start/poll request pair. +//! Picker previews: one generic start/poll request pair over every previewable +//! catalog and both preview variants. //! -//! Both effect kinds render small looping thumbnails read back asynchronously; -//! once [`PreviewKind`] exists the start/poll handlers are identical except for -//! which engine entry point `start` dispatches to, so a single pair (keyed on a -//! `kind` field) serves both pickers rather than two byte-identical copies. +//! The `catalog` field carries a catalog id — the same `"veils"` / `"voids"` / +//! `"filters"` vocabulary `catalogs()` publishes and the frontend's pickers +//! already hold. There is no translation table here and nothing to add when a +//! catalog becomes previewable: the engine looks the id up in the generated +//! mechanism table, and an id it does not find is a no-op. use serde::Deserialize; use serde_json::json; use crate::engine::protocol::{decode, RequestRegistration, Response}; -use crate::engine::PreviewKind; +use crate::gpu::preview::PreviewVariant; -/// `{ kind, type }` — which picker (`veil`/`void`) and which effect type id. +/// `{ catalog, type, variant }` — which catalog, which entry's type id, and +/// which of its two previews. #[derive(Deserialize)] #[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] pub struct PreviewReq { - pub kind: String, + pub catalog: String, #[serde(rename = "type")] pub type_id: String, -} - -/// Resolve the wire `kind` string to a [`PreviewKind`]. Unknown kinds return -/// `None` so the handler can no-op rather than panic on malformed input. -fn parse_kind(kind: &str) -> Option { - match kind { - "veil" => Some(PreviewKind::Veil), - "void" => Some(PreviewKind::Void), - _ => None, - } + pub variant: PreviewVariant, } pub fn registrations() -> Vec { vec![ RequestRegistration::new("start_preview", |engine, payload, _b| { let r: PreviewReq = decode(payload)?; - match parse_kind(&r.kind) { - Some(PreviewKind::Veil) => engine.start_veil_preview(&r.type_id), - Some(PreviewKind::Void) => engine.start_void_preview(&r.type_id), - None => {} - } + engine.start_preview(&r.catalog, &r.type_id, r.variant); Ok(Response::empty()) }) .post() .req::(), RequestRegistration::new("poll_preview", |engine, payload, _b| { let r: PreviewReq = decode(payload)?; - let Some(kind) = parse_kind(&r.kind) else { - return Ok(Response::json(serde_json::Value::Null)); - }; - let Some((width, height, frames)) = engine.poll_preview(kind, &r.type_id) else { + let Some((width, height, fps, frames)) = engine.poll_preview(&r.catalog, &r.type_id, r.variant) + else { return Ok(Response::json(serde_json::Value::Null)); }; // Frames are concatenated into the single bytes side-channel; // the JS edge slices them back out using width*height*4 stride. - let fps = crate::gpu::preview::PREVIEW_FPS; + // `fps` comes from the entry's own `PreviewAnim` — the one + // authority on how fast its preview plays. let frame_count = frames.len(); let mut bytes = Vec::new(); for f in &frames { diff --git a/crates/darkly/src/engine/rendering.rs b/crates/darkly/src/engine/rendering.rs index 1c19e506..94f9ca5d 100644 --- a/crates/darkly/src/engine/rendering.rs +++ b/crates/darkly/src/engine/rendering.rs @@ -7,6 +7,7 @@ use crate::coord::{CanvasPoint, CanvasRect, LayerRect}; use crate::gpu::atlas::CanvasFrame; use crate::gpu::compositor::Compositor; use crate::gpu::context::GpuContext; +use crate::gpu::preview::PreviewBackdrop; use crate::gpu::readback::{self, ReadbackScheduler}; use crate::gpu::region_store::{EntryPixels, RegionScratch, Snapshot, UndoRegionEntry}; use crate::gpu::view::{ViewParams, ViewTransform}; @@ -445,6 +446,7 @@ impl DarklyEngine { ReadbackContext::BrushStrokePreview { width, height, + backdrop, graph_version, } => { // Drop stale results — if the graph has changed since @@ -458,6 +460,8 @@ impl DarklyEngine { height, tw, th, + backdrop, + self.preview_theme_fg, self.preview_theme_bg, ); let png_bytes = encode_rgba_as_png(&framed, tw, th); @@ -470,10 +474,19 @@ impl DarklyEngine { name, width, height, + backdrop, } => { let (tw, th) = super::brush_library::BRUSH_THUMBNAIL_SIZE; - let framed = - frame_stroke_thumbnail(&pixels, width, height, tw, th, self.preview_theme_bg); + let framed = frame_stroke_thumbnail( + &pixels, + width, + height, + tw, + th, + backdrop, + self.preview_theme_fg, + self.preview_theme_bg, + ); let png_bytes = encode_rgba_as_png(&framed, tw, th); if !png_bytes.is_empty() { self.brush_library.set_thumbnail(&name, png_bytes); @@ -506,8 +519,9 @@ impl DarklyEngine { } } ReadbackContext::PreviewFrame { - kind, + catalog, type_id, + variant, frame_idx, total, } => { @@ -515,7 +529,7 @@ impl DarklyEngine { // putImageData. Guard against a stale generation (frame count // mismatch) so a superseded request can't write into a freshly // sized buffer. - if let Some(job) = self.previews.get_mut(&(kind, type_id)) { + if let Some(job) = self.previews.get_mut(&(catalog, type_id, variant)) { if job.frames.len() == total as usize { if let Some(slot) = job.frames.get_mut(frame_idx as usize) { *slot = Some(pixels); @@ -656,6 +670,11 @@ impl DarklyEngine { // before the headless early-return so tests exercise the same path. self.tick_process_recording(time_secs); + // Picker previews advance a bounded slice per tick, beside the readback + // drain that lands their frames. Before the headless early-return for + // the same reason the recorder is. + self.pump_previews(); + let t_thumb = web_time::Instant::now(); // Auto-queue thumbnail readbacks for layers whose pixels were // modified since the last frame. Must run *before* the headless @@ -1234,16 +1253,20 @@ const CURSOR_PREVIEW_MAX_BOOST: f32 = 8.0; /// This is the single CPU implementation of "bounding box of the changed /// pixels" — the same min/max reduction the GPU [`BboxReduction`] runs, on /// the CPU side of an already-read-back buffer. The two thumbnail framers -/// pass a "differs from the theme bg" predicate; the cursor-preview coverage +/// pass a "differs from the backdrop" predicate; the cursor-preview coverage /// scan passes an alpha-threshold predicate. Keeping the scan in one place /// means a border/empty-region convention can't drift between the callers. /// +/// The predicate is given the pixel's position as well as its value, because a +/// stroke staged over a field is only "changed" relative to what that field put +/// at that position. +/// /// [`BboxReduction`]: crate::gpu::bbox::BboxReduction pub(crate) fn changed_pixels_bbox( pixels: &[u8], width: u32, height: u32, - interesting: impl Fn([u8; 4]) -> bool, + interesting: impl Fn(u32, u32, [u8; 4]) -> bool, ) -> Option<[u32; 4]> { let mut min_x = width; let mut min_y = height; @@ -1253,7 +1276,11 @@ pub(crate) fn changed_pixels_bbox( for y in 0..height { for x in 0..width { let i = ((y * width + x) * 4) as usize; - if interesting([pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]) { + if interesting( + x, + y, + [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]], + ) { min_x = min_x.min(x); min_y = min_y.min(y); max_x = max_x.max(x); @@ -1269,6 +1296,10 @@ pub(crate) fn changed_pixels_bbox( /// solid `bg` on any RGB channel by more than `tol`? The tolerance /// accommodates the GPU's premultiplied-alpha rounding and any /// color-management drift while still catching a pale stroke against the bg. +fn quantize_rgb(c: [f32; 4]) -> [u8; 3] { + std::array::from_fn(|i| (c[i].clamp(0.0, 1.0) * 255.0).round() as u8) +} + fn differs_from_bg(px: [u8; 4], bg: [u8; 3], tol: i32) -> bool { (px[0] as i32 - bg[0] as i32).abs() > tol || (px[1] as i32 - bg[1] as i32).abs() > tol @@ -1300,13 +1331,9 @@ pub fn frame_dab_thumbnail(pixels: &[u8], width: u32, height: u32, bg: [f32; 4]) ); return Vec::new(); } - let bg_u8 = [ - (bg[0].clamp(0.0, 1.0) * 255.0).round() as u8, - (bg[1].clamp(0.0, 1.0) * 255.0).round() as u8, - (bg[2].clamp(0.0, 1.0) * 255.0).round() as u8, - ]; + let bg_u8 = quantize_rgb(bg); const TOLERANCE: i32 = 12; - let bbox = changed_pixels_bbox(pixels, width, height, |px| { + let bbox = changed_pixels_bbox(pixels, width, height, |_, _, px| { differs_from_bg(px, bg_u8, TOLERANCE) }); @@ -1382,7 +1409,7 @@ pub(crate) fn cursor_preview_scale_from_mask(pixels: &[u8], width: u32, height: // "no content" pixel value 0 exactly). const TOLERANCE: u8 = 12; let Some([min_x, min_y, max_x, max_y]) = - changed_pixels_bbox(pixels, width, height, |px| px[3] > TOLERANCE) + changed_pixels_bbox(pixels, width, height, |_, _, px| px[3] > TOLERANCE) else { return 1.0; }; @@ -1419,7 +1446,8 @@ pub(crate) fn cursor_preview_scale_from_mask(pixels: &[u8], width: u32, height: /// Frame a rendered stroke into the cache aspect ratio and resize. /// /// Same shape as `frame_dab_thumbnail` but for the S-curve preview: -/// 1. Scan for non-bg pixels and compute their bounding box. +/// 1. Scan for pixels the backdrop did not put there and compute their +/// bounding box. /// 2. Expand the bbox to match the target aspect ratio so the stroke /// isn't squashed by the resize. /// 3. Inflate by a 10% margin on each axis, then re-center on the @@ -1429,12 +1457,20 @@ pub(crate) fn cursor_preview_scale_from_mask(pixels: &[u8], width: u32, height: /// Brush size doesn't enter into any of this — bigger dabs paint a /// bigger bbox, smaller dabs paint a smaller bbox, the framer fits /// either to the target. The preview path is the same for every brush. -fn frame_stroke_thumbnail( +/// +/// "Non-bg" is measured against `backdrop` rather than against a colour, +/// because a stroke staged over a field was not drawn over one colour. A +/// [`PreviewBackdrop::Flat`] backdrop answers `bg` at every position, so the +/// general form degenerates to the flat comparison for the ten brushes that +/// stage nothing, and no call site branches on which backdrop it got. +pub(crate) fn frame_stroke_thumbnail( pixels: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32, + backdrop: PreviewBackdrop, + fg: [f32; 4], bg: [f32; 4], ) -> Vec { let expected = (src_w * src_h * 4) as usize; @@ -1445,16 +1481,12 @@ fn frame_stroke_thumbnail( ); return Vec::new(); } - let bg_u8 = [ - (bg[0].clamp(0.0, 1.0) * 255.0).round() as u8, - (bg[1].clamp(0.0, 1.0) * 255.0).round() as u8, - (bg[2].clamp(0.0, 1.0) * 255.0).round() as u8, - ]; // Same tolerance shape as frame_dab_thumbnail — accommodates // premultiplied-alpha rounding on the GPU side. const TOLERANCE: i32 = 12; - let bbox = changed_pixels_bbox(pixels, src_w, src_h, |px| { - differs_from_bg(px, bg_u8, TOLERANCE) + let bbox = changed_pixels_bbox(pixels, src_w, src_h, |x, y, px| { + let (u, v) = crate::gpu::preview::pixel_centre(x, y, src_w, src_h); + differs_from_bg(px, quantize_rgb(backdrop.sample(u, v, fg, bg)), TOLERANCE) }); let Some(src) = image::RgbaImage::from_raw(src_w, src_h, pixels.to_vec()) else { @@ -1489,10 +1521,12 @@ fn frame_stroke_thumbnail( let crop_y = cy.saturating_sub(crop_h / 2).min(src_h - crop_h); image::imageops::crop_imm(&src, crop_x, crop_y, crop_w, crop_h).to_image() } else { - // Empty render — return a flat field of bg at the target size. - // Skip the resize entirely; constructing it directly is cheaper - // and avoids the resize filter introducing rounding. + // Nothing was drawn — return a flat field of bg at the target size, + // whatever was staged under it. Skip the resize entirely; constructing + // it directly is cheaper and avoids the resize filter introducing + // rounding. let mut buf = Vec::with_capacity((dst_w * dst_h * 4) as usize); + let bg_u8 = quantize_rgb(bg); let bg_a = (bg[3].clamp(0.0, 1.0) * 255.0).round() as u8; for _ in 0..(dst_w * dst_h) { buf.extend_from_slice(&[bg_u8[0], bg_u8[1], bg_u8[2], bg_a]); @@ -1572,6 +1606,11 @@ fn generate_mask_thumbnail_from_pixels( mod tests { use super::*; + /// Theme foreground for the framing tests. Only ever reaches + /// [`PreviewBackdrop::Flat::sample`], which ignores it — the framing cases + /// below are about the crop, not about the staging. + const FG: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; + /// Build a `src_w * src_h` RGBA buffer filled with `bg`, then paint a /// solid rectangle of `fg` at `(x0..x1, y0..y1)`. fn fill_with_rect( @@ -1604,7 +1643,8 @@ mod tests { let bg = [0.05, 0.05, 0.05, 1.0]; let bg_u8 = [13u8, 13, 13, 255]; let pixels = fill_with_rect(640, 240, bg_u8, bg_u8, 0, 0, 0, 0); - let framed = frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, bg); + let framed = + frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, PreviewBackdrop::Flat, FG, bg); assert_eq!(framed.len(), (320 * 120 * 4) as usize); // Every pixel matches bg. for chunk in framed.chunks_exact(4) { @@ -1629,7 +1669,8 @@ mod tests { 325, 125, ); - let framed = frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, bg); + let framed = + frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, PreviewBackdrop::Flat, FG, bg); assert_eq!(framed.len(), (320 * 120 * 4) as usize); // Center 80x40 region should be predominantly bright. let mut bright = 0; @@ -1663,7 +1704,8 @@ mod tests { 640, 130, ); - let framed = frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, bg); + let framed = + frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, PreviewBackdrop::Flat, FG, bg); let bright = framed.chunks_exact(4).filter(|p| p[0] > 128).count(); assert!( bright > 100, @@ -1687,7 +1729,8 @@ mod tests { 120, 30, ); - let framed = frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, bg); + let framed = + frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, PreviewBackdrop::Flat, FG, bg); let bright = framed.chunks_exact(4).filter(|p| p[0] > 128).count(); assert!( bright > 200, diff --git a/crates/darkly/src/engine/types.rs b/crates/darkly/src/engine/types.rs index fe9a1ddb..904d8724 100644 --- a/crates/darkly/src/engine/types.rs +++ b/crates/darkly/src/engine/types.rs @@ -1,6 +1,7 @@ //! FFI/serialization types — serde-serializable for any WASM bridge. -use crate::gpu::params::{ParamDef, ParamValue}; +use crate::gpu::params::{ParamDef, ParamKind, ParamValue}; +use crate::units::UnitType; /// Cached, synchronously-consumable snapshot of engine state that the frontend /// mirrors. Returned by `render` each frame (a downhill projection of the one @@ -185,104 +186,42 @@ pub struct ModifierInfo { pub editable: bool, } +/// Per-instance view of a veil in the chain. `type` is the registry `type_id`; +/// resolve to a display label via `veil_types()` — never duplicate it here. #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] -pub struct VeilTypeInfo { - #[serde(rename = "type")] - pub type_id: &'static str, - pub display_name: &'static str, - /// Iconify name shown for this type. Filters carry a per-variant icon so - /// each reads distinctly in the Colors menu and the Add Filter Layer picker; - /// veils leave it empty (their UI renders a live preview, not an icon). - pub icon: &'static str, - /// One-sentence summary from the registration — picker tooltips, and (for - /// filters) folded into the Colors-menu action description where the - /// command palette's search indexes it. - pub description: &'static str, - pub params: Vec, -} - -/// Registry view of a void type for the "Add Void" picker. Mirrors -/// [`VeilTypeInfo`] but additionally carries `supportsPreview` (whether to -/// render a live thumbnail at all) and the browser `captureKind`. The void's -/// iconify `icon` is always present — the picker's fallback when there's no -/// rendered preview. -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] -pub struct VoidTypeInfo { - #[serde(rename = "type")] - pub type_id: &'static str, - pub display_name: &'static str, - pub params: Vec, - pub icon: &'static str, - pub supports_preview: bool, - /// How the browser captures this void's external frames (`"camera"` / - /// `"display"`), or absent for procedural voids. The frontend builds a - /// `voidType → CaptureKind` map from this to pick `getUserMedia` vs - /// `getDisplayMedia` and to drive the generic MediaStream lifecycle. - #[serde(skip_serializing_if = "Option::is_none")] - pub capture_kind: Option, -} - -/// Flat serialization-friendly view of a tool's registration metadata. -/// Mirrors `VeilTypeInfo` so the UI consumes both in the same shape. -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] -pub struct ToolTypeInfo { +pub struct VeilInfo { #[serde(rename = "type")] - pub type_id: &'static str, - pub display_name: &'static str, + pub type_id: String, + pub visible: bool, + pub index: usize, pub params: Vec, } -/// Flat view of a registered blend mode for the layer-properties dropdown. -/// `category` drives the `` grouping (Darken / Lighten / etc.). -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] -pub struct BlendModeTypeInfo { - #[serde(rename = "type")] - pub type_id: &'static str, - pub display_name: &'static str, - pub category: &'static str, -} - -/// Registry view of a modifier kind — the UI uses this to render the -/// "Add modifier" menu and to look up display labels for `ModifierInfo.kind`. -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] -pub struct ModifierTypeInfo { - #[serde(rename = "type")] - pub type_id: &'static str, - pub display_name: &'static str, -} - -/// Registry view of a layer kind — used by the layer panel to render labels -/// like "Raster Layer" / "Group" for the layer's own `type` discriminator. +/// Range and default rendered for reading — each number converted into its +/// display unit and suffixed. Carried alongside the raw numbers so a consumer +/// that only wants to *show* the schema needs no unit table of its own. #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] -pub struct LayerKindTypeInfo { - #[serde(rename = "type")] - pub type_id: &'static str, - pub display_name: &'static str, +pub struct ParamDisplay { + pub min: Option, + pub max: Option, + pub default: Option, + /// The unit suffix alone, for a column header. Empty for unitless values. + pub unit: &'static str, } -/// Per-instance view of a veil in the chain. `type` is the registry `type_id`; -/// resolve to a display label via `veil_types()` — never duplicate it here. -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] -pub struct VeilInfo { - #[serde(rename = "type")] - pub type_id: String, - pub visible: bool, - pub index: usize, - pub params: Vec, +/// Format a display-space number without trailing zeros — `180.0` reads +/// `"180"`, `0.25` stays `"0.25"`. +fn fmt_display(value: f32, unit: UnitType) -> String { + let v = unit.to_display(value); + let mut s = format!("{v:.3}"); + if s.contains('.') { + s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + } + format!("{s}{}", unit.suffix()) } /// Flat serialization-friendly view of a parameter definition + current value. @@ -292,6 +231,16 @@ pub struct VeilInfo { pub struct ParamInfo { pub kind: &'static str, pub name: &'static str, + /// Display label. `None` → the UI title-cases `name`. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<&'static str>, + /// How to render this parameter's editor. One closed set, which both + /// `ParamKind` and the settings schema's `WidgetHint` map into: + /// `"auto"`, `"numberInput"`, `"icon"`, `"hotkey"`, `"color"`, `"hidden"`. + pub widget: &'static str, + pub unit: UnitType, #[serde(skip_serializing_if = "Option::is_none")] pub min: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -304,158 +253,166 @@ pub struct ParamInfo { #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "ts-export", ts(type = "JsonValue | null"))] pub options: Option, + pub display: ParamDisplay, } impl ParamInfo { pub fn from_def(def: &ParamDef, value: Option<&ParamValue>) -> Self { - match def { - ParamDef::Float { - name, - min, - max, - default, - } => ParamInfo { - kind: "float", - name, - min: Some(*min as f64), - max: Some(*max as f64), - default: ParamValue::Float(*default), - value: value.cloned(), - options: None, - }, - ParamDef::Int { - name, - min, - max, - default, - } => ParamInfo { - kind: "int", - name, - min: Some(*min as f64), - max: Some(*max as f64), - default: ParamValue::Int(*default), - value: value.cloned(), - options: None, - }, - ParamDef::Bool { name, default } => ParamInfo { - kind: "bool", - name, - min: None, - max: None, - default: ParamValue::Bool(*default), - value: value.cloned(), - options: None, - }, - ParamDef::String { name, default } => ParamInfo { - kind: "string", - name, - min: None, - max: None, - default: ParamValue::String(default.to_string()), - value: value.cloned(), - options: None, - }, - ParamDef::Curve { name, default } => ParamInfo { - kind: "curve", - name, - min: None, - max: None, - default: ParamValue::Curve(default.to_vec()), - value: value.cloned(), - options: None, - }, - ParamDef::Levels { name, default } => ParamInfo { - kind: "levels", - name, - min: None, - max: None, - default: ParamValue::Levels(*default), - value: value.cloned(), - options: None, - }, - ParamDef::Enum { - name, - options, - default, - } => ParamInfo { - kind: "enum", - name, - min: None, - max: None, - default: ParamValue::Int(*default), - value: value.cloned(), - options: Some(serde_json::json!(options)), - }, - ParamDef::FloatInput { - name, - min, - max, - default, - } => ParamInfo { - kind: "floatInput", - name, - min: Some(*min as f64), - max: Some(*max as f64), - default: ParamValue::Float(*default), - value: value.cloned(), - options: None, - }, - ParamDef::Icon { - name, - options, - default, - } => ParamInfo { - kind: "icon", - name, - min: None, - max: None, - default: ParamValue::String(default.to_string()), - value: value.cloned(), - options: Some(serde_json::json!(options)), - }, - ParamDef::Color { name, default } => ParamInfo { - kind: "color", - name, - min: None, - max: None, - default: ParamValue::Color(*default), - value: value.cloned(), - options: None, - }, - ParamDef::Vec2 { name, max, default } => ParamInfo { - kind: "vec2", - name, - min: None, - // For a vec2 the flat `max` field carries the magnitude clamp - // (the offset pad's edge radius). - max: Some(*max as f64), - default: ParamValue::Vec2(*default), - value: value.cloned(), - options: None, - }, - ParamDef::List { - name, - item, - max_len, - .. - } => ParamInfo { - kind: "list", - name, - min: None, + // `name`, `label`, `description` and `unit` are the same for every + // kind, so only the value-shaped part is projected per variant. + let (kind, widget, min, max, options) = match &def.kind { + ParamKind::Float { min, max, .. } => { + ("float", "auto", Some(*min as f64), Some(*max as f64), None) + } + ParamKind::Int { min, max, .. } => { + ("int", "auto", Some(*min as f64), Some(*max as f64), None) + } + ParamKind::Bool { .. } => ("bool", "auto", None, None, None), + ParamKind::String { .. } => ("string", "auto", None, None, None), + ParamKind::Curve { .. } => ("curve", "auto", None, None, None), + ParamKind::Levels { .. } => ("levels", "auto", None, None, None), + ParamKind::Enum { options, .. } => { + ("enum", "auto", None, None, Some(serde_json::json!(options))) + } + ParamKind::FloatInput { min, max, .. } => ( + "floatInput", + "numberInput", + Some(*min as f64), + Some(*max as f64), + None, + ), + ParamKind::Icon { options, .. } => { + ("icon", "icon", None, None, Some(serde_json::json!(options))) + } + ParamKind::Color { .. } => ("color", "auto", None, None, None), + // For a vec2 the flat `max` field carries the magnitude clamp + // (the offset pad's edge radius). + ParamKind::Vec2 { max, .. } => ("vec2", "auto", None, Some(*max as f64), None), + ParamKind::List { item, max_len, .. } => ( + "list", + "auto", + None, // For a list the flat `max` field carries the entry cap so the // editor disables "Add" at the limit without an effect-specific // constant. - max: Some(*max_len as f64), - default: def.default_value(), - value: value.cloned(), + Some(*max_len as f64), // The item schema rides the same kind-discriminated `options` // channel Enum/Icon use — here a `Vec` of the item // defs so the list editor can render each entry's fields. - options: Some(serde_json::json!(item + Some(serde_json::json!(item .iter() .map(|d| ParamInfo::from_def(d, None)) .collect::>())), + ), + }; + + let default = def.default_value(); + // Only a scalar range renders — a curve or a list has no single number + // to show, and `Vec2`'s `max` is a magnitude rather than a bound. + let scalar_default = match &default { + ParamValue::Float(f) => Some(*f), + ParamValue::Int(i) => Some(*i as f32), + _ => None, + }; + let renders_range = matches!( + def.kind, + ParamKind::Float { .. } | ParamKind::Int { .. } | ParamKind::FloatInput { .. } + ); + let display = ParamDisplay { + min: renders_range.then(|| fmt_display(min.unwrap_or(0.0) as f32, def.unit)), + max: renders_range.then(|| fmt_display(max.unwrap_or(0.0) as f32, def.unit)), + default: scalar_default.map(|d| fmt_display(d, def.unit)), + unit: def.unit.suffix(), + }; + + ParamInfo { + kind, + name: def.name, + label: def.label, + description: def.description, + widget, + unit: def.unit, + min, + max, + default, + value: value.cloned(), + options, + display, + } + } + + /// Project a declared preference into the same shape an effect parameter + /// takes. `Pref` and `ParamDef` are isomorphic once both carry a label, a + /// description and a widget hint, so the settings surface and the effect + /// panels consume one type rather than two near-identical ones. + /// + /// `name` is the pref's dot-path key, and `default` comes from the + /// editor-agnostic defaults layer — the schema declares type and range, not + /// values, so the value has to be read from where it actually lives. + pub fn from_pref(pref: &crate::config::schema::Pref) -> Self { + use crate::config::schema::{PrefKind, WidgetHint}; + use crate::config::ConfigValue; + + let (kind, min, max, options) = match &pref.kind { + PrefKind::Bool => ("bool", None, None, None), + PrefKind::Int { min, max } => ("int", Some(*min as f64), Some(*max as f64), None), + PrefKind::Float { min, max } => ("float", Some(*min), Some(*max), None), + PrefKind::Str => ("str", None, None, None), + PrefKind::Enum { options } => ("enum", None, None, Some(serde_json::json!(options))), + }; + + // Both producers map into one closed widget set; see `ParamInfo.widget`. + let widget = match pref.widget { + WidgetHint::Auto => "auto", + WidgetHint::NumberInput => "numberInput", + WidgetHint::Hotkey => "hotkey", + WidgetHint::Color => "color", + WidgetHint::Hidden => "hidden", + }; + + let default = match crate::config::agnostic_default(pref.key) { + Some(ConfigValue::Bool(b)) => ParamValue::Bool(b), + Some(ConfigValue::Int(i)) => ParamValue::Int(i as i32), + Some(ConfigValue::Float(f)) => ParamValue::Float(f as f32), + Some(ConfigValue::Str(s)) => ParamValue::String(s), + // A pref the agnostic layer does not set is one every editor + // overlay is expected to supply; fall back to the kind's zero so + // the shape stays total. + None => match &pref.kind { + PrefKind::Bool => ParamValue::Bool(false), + PrefKind::Int { min, .. } => ParamValue::Int(*min as i32), + PrefKind::Float { min, .. } => ParamValue::Float(*min as f32), + PrefKind::Str | PrefKind::Enum { .. } => ParamValue::String(String::new()), + }, + }; + + let scalar_default = match &default { + ParamValue::Float(f) => Some(*f), + ParamValue::Int(i) => Some(*i as f32), + _ => None, + }; + let renders_range = matches!(pref.kind, PrefKind::Int { .. } | PrefKind::Float { .. }); + // Prefs declare no unit; their ranges are already in display space. + let unit = UnitType::Raw; + + ParamInfo { + kind, + name: pref.key, + label: Some(pref.display_name), + description: pref.description, + widget, + unit, + min, + max, + display: ParamDisplay { + min: renders_range.then(|| fmt_display(min.unwrap_or(0.0) as f32, unit)), + max: renders_range.then(|| fmt_display(max.unwrap_or(0.0) as f32, unit)), + default: scalar_default.map(|d| fmt_display(d, unit)), + unit: unit.suffix(), }, + default, + value: None, + options, } } } diff --git a/crates/darkly/src/engine/veils.rs b/crates/darkly/src/engine/veils.rs index e8a1dfae..2a4efc6b 100644 --- a/crates/darkly/src/engine/veils.rs +++ b/crates/darkly/src/engine/veils.rs @@ -2,19 +2,11 @@ use darkly_macros::handlers; -use super::types::{ - node_to_layer_info, BlendModeTypeInfo, LayerInfo, LayerKindTypeInfo, ModifierTypeInfo, - ParamInfo, ToolTypeInfo, VeilInfo, VeilTypeInfo, -}; +use super::types::{node_to_layer_info, LayerInfo, ParamInfo, VeilInfo}; use super::DarklyEngine; -use super::PreviewJob; -use super::PreviewKind; -use super::ReadbackContext; -use crate::coord::LayerRect; +use crate::catalog::Catalog; use crate::engine::protocol::{params_from_json, RawParams}; use crate::gpu::params::{ParamDef, ParamValue}; -use crate::gpu::preview::ANIMATED_FRAMES; -use crate::gpu::veil::Veil; #[handlers] impl DarklyEngine { @@ -84,147 +76,6 @@ impl DarklyEngine { chain.update_veil(&self.gpu.device, &self.gpu.queue, index, new_veil); } - // --- Picker previews --- - - /// Begin generating the looping thumbnail preview for `type_id` — the veil - /// applied to the **current canvas**, captured as a sequence of frames. - /// Regenerates on every call (a no-op only while a generation is already in - /// flight) so the picker always reflects the live document. Frames land - /// asynchronously; retrieve them with - /// [`poll_veil_preview`](Self::poll_veil_preview). - /// - /// Fully isolated from the live veil chain: it downscales a snapshot of the - /// composite into the preview renderer's own textures and runs a fresh veil - /// instance over it, so the user's active veils, compositor surface, and - /// document are never mutated. - pub fn start_veil_preview(&mut self, type_id: &str) { - // Resolve to the registry's 'static id — it keys both the preview job - // and the readback context. - let Some(static_id) = self - .compositor - .veil_chain() - .registry() - .static_type_id(type_id) - else { - return; - }; - - // Don't queue a duplicate generation while one is in flight. (We do not - // skip when frames already exist — each open re-renders the live - // canvas, which may have changed since last time.) - if self.readbacks.any(|c| { - matches!( - c, - ReadbackContext::PreviewFrame { kind: PreviewKind::Veil, type_id: t, .. } - if *t == static_id - ) - }) { - return; - } - - // Refresh the composite so the preview reflects the current document, - // even with no surface present yet (mirrors `start_export`). - self.compositor - .render_offscreen(&self.gpu.device, &self.gpu.queue, &mut self.doc); - let canvas_w = self.compositor.canvas_width(); - let canvas_h = self.compositor.canvas_height(); - let format = self.compositor.veil_chain().accum_format(); - - // Downscale the live composite into the preview input texture. Holds an - // immutable borrow of `compositor` (via the texture view) alongside the - // mutable renderer borrow — disjoint fields, so they don't alias. - { - let source = self.compositor.composited_texture(); - let source_view = source.create_view(&wgpu::TextureViewDescriptor::default()); - self.veil_preview_renderer.load_source( - &self.gpu.device, - &self.gpu.queue, - &source_view, - canvas_w, - canvas_h, - format, - ); - } - - let defaults: Vec = self - .compositor - .veil_chain() - .registry() - .param_defs(static_id) - .iter() - .map(|d| d.default_value()) - .collect(); - - // Build the veil + cache over the loaded composite. Borrow-split the - // disjoint fields (registry on the compositor, the GPU context, the - // renderer) so they don't alias `self`. - let (mut veil, cache) = { - let Self { - compositor, - gpu, - veil_preview_renderer, - .. - } = self; - let registry = compositor.veil_chain_mut().registry_mut(); - veil_preview_renderer.build_veil( - &gpu.device, - &gpu.queue, - registry, - static_id, - &defaults, - format, - ) - }; - - let (pw, ph) = self.veil_preview_renderer.preview_size(); - let total = if veil.needs_animation() { - ANIMATED_FRAMES - } else { - 1 - }; - let dt = self.veil_preview_renderer.frame_dt(); - self.previews.insert( - (PreviewKind::Veil, static_id), - PreviewJob { - width: pw, - height: ph, - frames: vec![None; total as usize], - }, - ); - - let rect = LayerRect::from_xywh(0, 0, pw, ph); - for frame_idx in 0..total { - // Frame 0 captures the initial state; advance animated veils between - // frames so the loop shows motion. Each frame renders into the same - // output texture and copies to its own staging buffer in the same - // submission, so the readback captures that frame before the next - // overwrites it. - if frame_idx > 0 && veil.needs_animation() { - veil.update_time(&self.gpu.queue, &cache, dt); - } - let veil_ref: &dyn Veil = veil.as_ref(); - let Self { - gpu, - readbacks, - veil_preview_renderer, - .. - } = self; - let output = veil_preview_renderer.output_texture(); - Self::encode_preview_frame( - gpu, - readbacks, - PreviewKind::Veil, - static_id, - frame_idx, - total, - output, - format, - rect, - |encoder| veil_preview_renderer.encode_frame(encoder, veil_ref, &cache), - ); - } - } - // --- Queries --- #[handler] @@ -269,87 +120,18 @@ impl DarklyEngine { list } - /// Return all registered veil types with their parameter definitions. + /// Every registry, projected into the one browsable shape the UI pickers, + /// the settings surface and the metadata export all consume. Delegates to + /// the GPU-free free function so an exporter can build the same data + /// without an engine; the handler exists so `ts_rs` emits `Catalog` into + /// the frontend's typed client. #[handler] - pub fn veil_types(&self) -> Vec { - self.compositor - .veil_chain() - .registry() - .types() - .into_iter() - .map(|(type_id, display_name, description, defs)| VeilTypeInfo { - type_id, - display_name, - // Veils render a live preview in their picker, so no icon. - icon: "", - description, - params: defs.iter().map(|d| ParamInfo::from_def(d, None)).collect(), - }) - .collect() + pub fn catalogs(&self) -> Vec { + crate::catalog::catalogs() } /// Get the parameter definitions for a veil type. pub fn veil_param_defs(&self, type_id: &str) -> &'static [ParamDef] { self.compositor.veil_chain().registry().param_defs(type_id) } - - /// Return all registered tool types with display name and parameter definitions. - /// Backs the WASM bridge so the UI can render tool names without hardcoding them. - #[handler] - pub fn tool_types(&self) -> Vec { - crate::tool::registry() - .types() - .into_iter() - .map(|(type_id, display_name, defs)| ToolTypeInfo { - type_id, - display_name, - params: defs.iter().map(|d| ParamInfo::from_def(d, None)).collect(), - }) - .collect() - } - - /// Return all registered blend modes in GPU-value order, with display name - /// and category. Backs the WASM bridge so the UI populates the blend-mode - /// dropdown from the registry instead of a hardcoded table. - #[handler] - pub fn blend_mode_types(&self) -> Vec { - crate::gpu::blend_mode::registry() - .all() - .into_iter() - .map(|reg| BlendModeTypeInfo { - type_id: reg.type_id, - display_name: reg.display_name, - category: reg.category, - }) - .collect() - } - - /// Return all registered filter kinds. UI uses this to resolve - /// `ModifierInfo.kind` to a display label and to populate the - /// "Add filter" menu. - #[handler] - pub fn modifier_types(&self) -> Vec { - crate::document::filter::registry() - .all() - .into_iter() - .map(|reg| ModifierTypeInfo { - type_id: reg.type_id, - display_name: reg.display_name, - }) - .collect() - } - - /// Return all registered layer kinds. UI uses this to resolve a layer's - /// `type` discriminator to a display label (e.g. "Raster Layer", "Group"). - #[handler] - pub fn layer_kind_types(&self) -> Vec { - crate::document::layer_kind::registry() - .all() - .into_iter() - .map(|reg| LayerKindTypeInfo { - type_id: reg.type_id, - display_name: reg.display_name, - }) - .collect() - } } diff --git a/crates/darkly/src/engine/voids.rs b/crates/darkly/src/engine/voids.rs index 96872985..c30d5d30 100644 --- a/crates/darkly/src/engine/voids.rs +++ b/crates/darkly/src/engine/voids.rs @@ -1,48 +1,14 @@ -//! Void (procedural-content layer) queries and picker previews. +//! Void (procedural-content layer) queries. use darkly_macros::handlers; -use super::types::{ParamInfo, VoidTypeInfo}; use super::DarklyEngine; -use super::PreviewJob; -use super::PreviewKind; -use super::ReadbackContext; -use crate::coord::LayerRect; use crate::gpu::params::ParamDef; -use crate::gpu::preview::{ANIMATED_FRAMES, PREVIEW_DT}; -use crate::gpu::void::Void; - -/// Voids render into a straight RGBA8 destination (matching the layer-texture -/// atlas format), which is also the format the per-frame readback expects. -const VOID_PREVIEW_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; #[handlers] impl DarklyEngine { // --- Queries --- - /// Return all registered void types with their parameter definitions, icon, - /// and whether each supports a rendered picker preview. - #[handler] - pub fn void_types(&self) -> Vec { - self.compositor - .void_registry() - .types() - .into_iter() - .map(|reg| VoidTypeInfo { - type_id: reg.type_id, - display_name: reg.display_name, - params: reg - .params - .iter() - .map(|d| ParamInfo::from_def(d, None)) - .collect(), - icon: reg.icon, - supports_preview: reg.supports_preview, - capture_kind: reg.capture_kind, - }) - .collect() - } - /// Get the parameter definitions for a void type. pub fn void_param_defs(&self, type_id: &str) -> &'static [ParamDef] { self.compositor.void_registry().param_defs(type_id) @@ -74,112 +40,4 @@ impl DarklyEngine { _ => None, } } - - // --- Picker previews --- - - /// Begin generating the looping thumbnail preview for void `type_id` — the - /// void rendered from scratch at the current canvas's aspect-fit preview - /// size, captured as a sequence of frames. Regenerates on every call (a - /// no-op only while a generation is already in flight). Frames land - /// asynchronously; retrieve them with [`poll_preview`](Self::poll_preview). - /// - /// Unlike the veil path there's no source composite to refresh — the void - /// generates its own content — so this just builds the void, encodes its - /// frames, and reads them back. Fully isolated from the live layer stack: a - /// fresh void instance renders into the preview renderer's own texture. - pub fn start_void_preview(&mut self, type_id: &str) { - let Some(static_id) = self.compositor.void_registry().static_type_id(type_id) else { - return; - }; - - // Don't queue a duplicate generation while one is in flight. - if self.readbacks.any(|c| { - matches!( - c, - ReadbackContext::PreviewFrame { kind: PreviewKind::Void, type_id: t, .. } - if *t == static_id - ) - }) { - return; - } - - let canvas_w = self.compositor.canvas_width(); - let canvas_h = self.compositor.canvas_height(); - let format = VOID_PREVIEW_FORMAT; - - let defaults: Vec<_> = self - .compositor - .void_registry() - .param_defs(static_id) - .iter() - .map(|d| d.default_value()) - .collect(); - - // Build the void + cache over the preview output texture. Borrow-split - // the disjoint fields (registry on the compositor, the GPU context, the - // renderer) so they don't alias `self`. - let (mut void, cache) = { - let Self { - compositor, - gpu, - void_preview_renderer, - .. - } = self; - let registry = compositor.void_registry_mut(); - void_preview_renderer.build_void( - &gpu.device, - &gpu.queue, - registry, - static_id, - &defaults, - canvas_w, - canvas_h, - format, - ) - }; - - let (pw, ph) = self.void_preview_renderer.preview_size(); - let total = if void.needs_animation() { - ANIMATED_FRAMES - } else { - 1 - }; - self.previews.insert( - (PreviewKind::Void, static_id), - PreviewJob { - width: pw, - height: ph, - frames: vec![None; total as usize], - }, - ); - - let rect = LayerRect::from_xywh(0, 0, pw, ph); - for frame_idx in 0..total { - // Advance animated voids between frames so the loop shows motion; - // static voids (the common case — noise) render a single frame. - if frame_idx > 0 && void.needs_animation() { - void.update_time(&self.gpu.queue, &cache, PREVIEW_DT); - } - let void_ref: &dyn Void = void.as_ref(); - let Self { - gpu, - readbacks, - void_preview_renderer, - .. - } = self; - let output = void_preview_renderer.output_texture(); - Self::encode_preview_frame( - gpu, - readbacks, - PreviewKind::Void, - static_id, - frame_idx, - total, - output, - format, - rect, - |encoder| void_preview_renderer.encode_frame(encoder, void_ref, &cache), - ); - } - } } diff --git a/crates/darkly/src/format/tests.rs b/crates/darkly/src/format/tests.rs index 1f848e88..88894643 100644 --- a/crates/darkly/src/format/tests.rs +++ b/crates/darkly/src/format/tests.rs @@ -68,7 +68,7 @@ fn round_trip_every_veil() { let types: Vec<(&'static str, &'static [ParamDef])> = registry .types() .into_iter() - .map(|(id, _name, _description, params)| (id, params)) + .map(|reg| (reg.type_id, reg.params)) .collect(); assert!( !types.is_empty(), @@ -201,7 +201,11 @@ fn round_trip_every_stabilizer() { #[test] fn round_trip_every_brush_node() { let registry = registry(); - let types: Vec<&str> = registry.types().map(|reg| reg.type_id).collect(); + let types: Vec<&str> = registry + .types() + .into_iter() + .map(|reg| reg.type_id) + .collect(); assert!( !types.is_empty(), "brush node registry must contain at least one node type" @@ -456,7 +460,7 @@ fn populate_kitchen_sink(engine: &mut DarklyEngine) { .registry() .types() .into_iter() - .map(|(id, _name, _description, params)| (id, params)) + .map(|reg| (reg.type_id, reg.params)) .collect(); for (type_id, schema) in veil_types { let defaults = defaults_of(schema); @@ -488,7 +492,7 @@ fn populate_kitchen_sink(engine: &mut DarklyEngine) { .filter_pipeline_registry() .types() .into_iter() - .map(|(id, _name, _icon, _description)| id.to_string()) + .map(|reg| reg.type_id.to_string()) .collect(); for pipeline in filter_types { engine.add_filter_layer(&pipeline, Vec::new(), None); @@ -1281,9 +1285,9 @@ fn legacy_type_id_migration() { // registry — confirms the registry interface is the dispatch // surface the migration will plug into. let registry = VeilRegistry::new(); - for (type_id, _name, _description, _params) in registry.types() { + for reg in registry.types() { assert!( - registry.has(type_id), + registry.has(reg.type_id), "legacy migration scaffold: registry must resolve every registered \ type_id back through itself — drift suggests the dispatch surface \ a future migration would plug into has changed" diff --git a/crates/darkly/src/gpu/black_and_white.rs b/crates/darkly/src/gpu/black_and_white.rs index 290d7755..b8cc9f3e 100644 --- a/crates/darkly/src/gpu/black_and_white.rs +++ b/crates/darkly/src/gpu/black_and_white.rs @@ -13,6 +13,7 @@ //! Mode 6 is a custom weighted mix, and an optional hue tint colors the gray. use crate::gpu::params::{ParamDef, ParamValue}; +use crate::gpu::preview::{swing, PreviewAnim}; pub const TYPE_ID: &str = "black_and_white"; pub const DISPLAY_NAME: &str = "Black and White"; @@ -24,9 +25,9 @@ formulas or custom channel weights, with an optional color tint."; /// the tint applies in every mode. A `static` rather than a `const` so both /// registrations hold the same address — pinned by the identity test below. pub static PARAMS: &[ParamDef] = &[ - ParamDef::Enum { - name: "mode", - options: &[ + ParamDef::enumeration( + "mode", + &[ "Lightness", "Luminosity (BT.709)", "Luminosity (BT.601)", @@ -35,40 +36,63 @@ pub static PARAMS: &[ParamDef] = &[ "Max", "Custom Weights", ], - default: 0, - }, - ParamDef::Float { - name: "red_weight", - min: 0.0, - max: 1.0, - default: 0.299, - }, - ParamDef::Float { - name: "green_weight", - min: 0.0, - max: 1.0, - default: 0.587, - }, - ParamDef::Float { - name: "blue_weight", - min: 0.0, - max: 1.0, - default: 0.114, - }, - ParamDef::Float { - name: "tint_hue", - min: 0.0, - max: 360.0, - default: 0.0, - }, - ParamDef::Float { - name: "tint_strength", - min: 0.0, - max: 1.0, - default: 0.0, - }, + 0, + ) + .with_label("Mode") + .with_description("How color is weighed when collapsing it to grey."), + ParamDef::float("red_weight", 0.0, 1.0, 0.299) + .with_label("Red Weight") + .with_description("How much the red channel contributes, in Custom Weights mode."), + ParamDef::float("green_weight", 0.0, 1.0, 0.587) + .with_label("Green Weight") + .with_description("How much the green channel contributes, in Custom Weights mode."), + ParamDef::float("blue_weight", 0.0, 1.0, 0.114) + .with_label("Blue Weight") + .with_description("How much the blue channel contributes, in Custom Weights mode."), + ParamDef::float("tint_hue", 0.0, 360.0, 0.0) + .with_label("Tint Hue") + .with_description("Which color the finished grey is toned toward."), + ParamDef::float("tint_strength", 0.0, 1.0, 0.0) + .with_label("Tint Strength") + .with_description("How strongly the tint color shows through the grey."), ]; +/// One preview for both surfaces, beside the schema they share. A `static` for +/// the same reason `PARAMS` is one — both registrations hold the same address, +/// which is what makes the sharing structural rather than two copies that +/// happen to agree today. +/// +/// The still is taken at rest rather than at the sweep's peak, which is the +/// opposite of what most entries want and is the whole reason `still_at` is +/// per-entry. Everywhere else the sweep animates *the* control the effect is +/// named for, so the peak is the effect at its most legible. Here the effect is +/// already fully applied at rest — the grey is the point — and the sweep +/// animates the *tint*, a secondary control. A still taken at the peak would +/// show a saturated colour wash, which is the one thing a black-and-white +/// preview must not look like. +pub static PREVIEW: PreviewAnim = PreviewAnim::LOOPING.with_still_at(0.0); + +/// What that preview shows at `t`: the grey toned through the full colour wheel +/// while the tint strengthens and fades, so a single pass shows both the +/// desaturation and what the tint controls do to it. +/// +/// The hue runs *monotonically* through the wheel rather than swinging out and +/// back, because the wheel is circular: a swinging hue would spend its peak +/// strength at 360°, which is 0°, which is red — so the one frame that stands +/// for the whole effect would be a full-strength red wash. Running the hue +/// forward puts the peak at 180° instead, and 360° ≡ 0° means the sequence still +/// closes on the colour it opened with. +/// +/// The filter reads this off its registration and the veil calls it from +/// [`Veil::preview_at`](crate::gpu::veil::Veil::preview_at) — the two surfaces +/// share the motion the same way they share the schema. +pub fn preview_params(t: f32) -> Vec { + let mut params: Vec = PARAMS.iter().map(ParamDef::default_value).collect(); + params[4] = ParamValue::Float(360.0 * t); + params[5] = ParamValue::Float(swing(t)); + params +} + /// The shared WGSL transform (`BwParams` / `bw_gray` / `bw_transform`), /// prepended to each surface's wrapper shader at pipeline build time. pub const SHADER_LIB: &str = include_str!("../../shaders/lib/black_and_white.wgsl"); diff --git a/crates/darkly/src/gpu/blend_mode.rs b/crates/darkly/src/gpu/blend_mode.rs index 0357a941..ae987967 100644 --- a/crates/darkly/src/gpu/blend_mode.rs +++ b/crates/darkly/src/gpu/blend_mode.rs @@ -16,6 +16,8 @@ use std::collections::HashMap; use std::sync::OnceLock; +use super::preview::PreviewAnim; + /// Static metadata for one blend mode. Every layer/group holds a /// `&'static BlendModeRegistration` directly; the GPU value is read straight /// from `gpu_value`, no enum cast, no extra lookup. @@ -26,6 +28,9 @@ use std::sync::OnceLock; pub struct BlendModeRegistration { pub type_id: &'static str, pub display_name: &'static str, + /// One-sentence summary of what this mode does to the colours beneath it — + /// the dropdown's tooltip and the reference manual's row for it. + pub description: &'static str, /// Visual grouping label for the UI dropdown ("Darken", "Lighten", etc.). pub category: &'static str, /// Integer the composite shader switches on. The shader's blend dispatch @@ -40,6 +45,53 @@ pub struct BlendModeRegistration { pub wgsl_math: &'static str, } +/// Id of the catalog this registry projects into. +pub const CATALOG_ID: &str = "blendModes"; + +/// How a blend mode's preview plays back: the blended layer rising over an +/// unchanged backdrop and receding. Modes take no parameters and the motion is +/// the same for every one of them, so it belongs to the catalog rather than to +/// any single registration — a seventeenth mode is still one file of five +/// fields and inherits this for free. It returns to zero, so the loop closes. +/// +/// A mode is not an effect over an image but a relation between two, so there +/// is no `src → out` mechanism to write and no `preview_at` to override: the +/// documentation renderer drives the *host layer's* opacity, which is the one +/// thing only a consumer holding a document can do. A mode that ever wants +/// different motion is a `preview` field on [`BlendModeRegistration`] and a +/// fallback to this in [`BlendModeRegistry::preview`] — a change local to this +/// file and the one mode that wants the override. +pub static PREVIEW: PreviewAnim = PreviewAnim::LOOPING; + +impl BlendModeRegistration { + pub fn catalog_entry(&self) -> crate::catalog::CatalogEntry { + // Blend modes have no icons anywhere — the dropdown is text, grouped by + // `category`. + crate::catalog::CatalogEntry::new(self.type_id, self.display_name) + .with_description(self.description) + .with_category(self.category) + // Modes carry no `preview` field of their own — the recipe lives on + // the catalog — so previewability is the same question put to the + // same authority, resolved through the registry rather than a field. + .with_supports_preview(registry().preview(self.type_id).is_some()) + } +} + +/// The blend-mode catalog, in GPU-value order — the conventional +/// Photoshop / Krita ordering the dropdown lists, not alphabetic. +pub fn catalog() -> crate::catalog::Catalog { + crate::catalog::Catalog::new( + CATALOG_ID, + "Blend Modes", + registry() + .all() + .into_iter() + .map(BlendModeRegistration::catalog_entry) + .collect(), + ) + .with_description("How a layer's color combines with the composite beneath it.") +} + pub struct BlendModeRegistry { /// Owned storage for every registered mode. Stable addresses while the /// registry lives (and it lives forever — see [`registry`]), so `&'static` @@ -95,6 +147,12 @@ impl BlendModeRegistry { pub fn all(&'static self) -> Vec<&'static BlendModeRegistration> { self.ordered.iter().map(|&i| &self.entries[i]).collect() } + + /// How long a mode's preview runs. Every registered mode inherits + /// [`PREVIEW`]; an unknown `type_id` gets `None`. + pub fn preview(&'static self, type_id: &str) -> Option { + self.get(type_id).map(|_| PREVIEW) + } } /// Lazily-initialized process-wide blend-mode registry. diff --git a/crates/darkly/src/gpu/blend_modes/color.rs b/crates/darkly/src/gpu/blend_modes/color.rs index 35400d8e..bb83f99c 100644 --- a/crates/darkly/src/gpu/blend_modes/color.rs +++ b/crates/darkly/src/gpu/blend_modes/color.rs @@ -4,6 +4,8 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "color", display_name: "Color", + description: + "Takes the hue and saturation of this layer and the brightness of what is beneath.", category: "Component", gpu_value: 14, wgsl_math: "Cs = pd_set_lum(fg.rgb, pd_lum(bg.rgb));", diff --git a/crates/darkly/src/gpu/blend_modes/color_burn.rs b/crates/darkly/src/gpu/blend_modes/color_burn.rs index af1cf496..8d240262 100644 --- a/crates/darkly/src/gpu/blend_modes/color_burn.rs +++ b/crates/darkly/src/gpu/blend_modes/color_burn.rs @@ -4,6 +4,7 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "color_burn", display_name: "Color Burn", + description: "Darkens the base by increasing its contrast toward the blend color.", category: "Darken", gpu_value: 3, // pd_color_burn: Krita KoCompositeOpFunctions.h:329–361. diff --git a/crates/darkly/src/gpu/blend_modes/color_dodge.rs b/crates/darkly/src/gpu/blend_modes/color_dodge.rs index 05399324..78a6e6c2 100644 --- a/crates/darkly/src/gpu/blend_modes/color_dodge.rs +++ b/crates/darkly/src/gpu/blend_modes/color_dodge.rs @@ -4,6 +4,7 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "color_dodge", display_name: "Color Dodge", + description: "Lightens the base by decreasing its contrast toward the blend color.", category: "Lighten", gpu_value: 6, // pd_color_dodge: Krita KoCompositeOpFunctions.h:376–403. diff --git a/crates/darkly/src/gpu/blend_modes/darken.rs b/crates/darkly/src/gpu/blend_modes/darken.rs index 26ea8ea1..cc574585 100644 --- a/crates/darkly/src/gpu/blend_modes/darken.rs +++ b/crates/darkly/src/gpu/blend_modes/darken.rs @@ -4,6 +4,7 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "darken", display_name: "Darken", + description: "Keeps whichever of the two colors is darker, channel by channel.", category: "Darken", gpu_value: 1, wgsl_math: "Cs = min(fg.rgb, bg.rgb);", diff --git a/crates/darkly/src/gpu/blend_modes/difference.rs b/crates/darkly/src/gpu/blend_modes/difference.rs index f8ee1669..12b4be1d 100644 --- a/crates/darkly/src/gpu/blend_modes/difference.rs +++ b/crates/darkly/src/gpu/blend_modes/difference.rs @@ -4,6 +4,8 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "difference", display_name: "Difference", + description: + "Subtracts the darker color from the lighter one, inverting where they differ.", category: "Inversion", gpu_value: 11, wgsl_math: "Cs = abs(fg.rgb - bg.rgb);", diff --git a/crates/darkly/src/gpu/blend_modes/hard_light.rs b/crates/darkly/src/gpu/blend_modes/hard_light.rs index 057664dc..76eb3cde 100644 --- a/crates/darkly/src/gpu/blend_modes/hard_light.rs +++ b/crates/darkly/src/gpu/blend_modes/hard_light.rs @@ -4,6 +4,7 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "hard_light", display_name: "Hard Light", + description: "Overlay with the layers swapped — the blend color decides the contrast.", category: "Contrast", gpu_value: 10, wgsl_math: "\ diff --git a/crates/darkly/src/gpu/blend_modes/hue.rs b/crates/darkly/src/gpu/blend_modes/hue.rs index 32255bf7..2c2b9c07 100644 --- a/crates/darkly/src/gpu/blend_modes/hue.rs +++ b/crates/darkly/src/gpu/blend_modes/hue.rs @@ -4,6 +4,8 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "hue", display_name: "Hue", + description: + "Takes the hue of this layer and the saturation and brightness of what is beneath.", category: "Component", gpu_value: 12, // PDF 11.3.5.3 / W3C Compositing-1, Krita's HSY model. diff --git a/crates/darkly/src/gpu/blend_modes/lighten.rs b/crates/darkly/src/gpu/blend_modes/lighten.rs index 50eccc92..88eb1e0b 100644 --- a/crates/darkly/src/gpu/blend_modes/lighten.rs +++ b/crates/darkly/src/gpu/blend_modes/lighten.rs @@ -4,6 +4,7 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "lighten", display_name: "Lighten", + description: "Keeps whichever of the two colors is lighter, channel by channel.", category: "Lighten", gpu_value: 4, wgsl_math: "Cs = max(fg.rgb, bg.rgb);", diff --git a/crates/darkly/src/gpu/blend_modes/linear_dodge.rs b/crates/darkly/src/gpu/blend_modes/linear_dodge.rs index f0e946b9..12363bd5 100644 --- a/crates/darkly/src/gpu/blend_modes/linear_dodge.rs +++ b/crates/darkly/src/gpu/blend_modes/linear_dodge.rs @@ -4,6 +4,7 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "linear_dodge", display_name: "Linear Dodge (Add)", + description: "Adds the two colors together, blowing out to white quickly.", category: "Lighten", gpu_value: 7, wgsl_math: "Cs = clamp(fg.rgb + bg.rgb, vec3f(0.0), vec3f(1.0));", diff --git a/crates/darkly/src/gpu/blend_modes/luminosity.rs b/crates/darkly/src/gpu/blend_modes/luminosity.rs index 4223e52f..f120a08a 100644 --- a/crates/darkly/src/gpu/blend_modes/luminosity.rs +++ b/crates/darkly/src/gpu/blend_modes/luminosity.rs @@ -4,6 +4,8 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "luminosity", display_name: "Luminosity", + description: + "Takes the brightness of this layer and the hue and saturation of what is beneath.", category: "Component", gpu_value: 15, wgsl_math: "Cs = pd_set_lum(bg.rgb, pd_lum(fg.rgb));", diff --git a/crates/darkly/src/gpu/blend_modes/multiply.rs b/crates/darkly/src/gpu/blend_modes/multiply.rs index 327f9a72..4cbfd17e 100644 --- a/crates/darkly/src/gpu/blend_modes/multiply.rs +++ b/crates/darkly/src/gpu/blend_modes/multiply.rs @@ -4,6 +4,8 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "multiply", display_name: "Multiply", + description: + "Multiplies the two colors, darkening everywhere and keeping white transparent.", category: "Darken", gpu_value: 2, wgsl_math: "Cs = fg.rgb * bg.rgb;", diff --git a/crates/darkly/src/gpu/blend_modes/normal.rs b/crates/darkly/src/gpu/blend_modes/normal.rs index 9626ada3..47a50a45 100644 --- a/crates/darkly/src/gpu/blend_modes/normal.rs +++ b/crates/darkly/src/gpu/blend_modes/normal.rs @@ -4,6 +4,7 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "normal", display_name: "Normal", + description: "Replaces the colors beneath it, scaled by opacity.", category: "Normal", gpu_value: 0, wgsl_math: "Cs = fg.rgb;", diff --git a/crates/darkly/src/gpu/blend_modes/overlay.rs b/crates/darkly/src/gpu/blend_modes/overlay.rs index 48ca6f4f..3d32fd9b 100644 --- a/crates/darkly/src/gpu/blend_modes/overlay.rs +++ b/crates/darkly/src/gpu/blend_modes/overlay.rs @@ -4,6 +4,8 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "overlay", display_name: "Overlay", + description: + "Multiplies dark areas and screens light ones, boosting contrast around mid grey.", category: "Contrast", gpu_value: 8, wgsl_math: "\ diff --git a/crates/darkly/src/gpu/blend_modes/saturation.rs b/crates/darkly/src/gpu/blend_modes/saturation.rs index 986fc688..1f0a8b5b 100644 --- a/crates/darkly/src/gpu/blend_modes/saturation.rs +++ b/crates/darkly/src/gpu/blend_modes/saturation.rs @@ -4,6 +4,8 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "saturation", display_name: "Saturation", + description: + "Takes the saturation of this layer and the hue and brightness of what is beneath.", category: "Component", gpu_value: 13, wgsl_math: "Cs = pd_set_lum(pd_set_sat(bg.rgb, pd_sat(fg.rgb)), pd_lum(bg.rgb));", diff --git a/crates/darkly/src/gpu/blend_modes/screen.rs b/crates/darkly/src/gpu/blend_modes/screen.rs index aa93c7eb..1d357eb1 100644 --- a/crates/darkly/src/gpu/blend_modes/screen.rs +++ b/crates/darkly/src/gpu/blend_modes/screen.rs @@ -4,6 +4,7 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "screen", display_name: "Screen", + description: "Inverts, multiplies and inverts back, lightening everywhere and keeping black transparent.", category: "Lighten", gpu_value: 5, wgsl_math: "Cs = fg.rgb + bg.rgb - fg.rgb * bg.rgb;", diff --git a/crates/darkly/src/gpu/blend_modes/soft_light.rs b/crates/darkly/src/gpu/blend_modes/soft_light.rs index 3caa5bf7..8f2102d7 100644 --- a/crates/darkly/src/gpu/blend_modes/soft_light.rs +++ b/crates/darkly/src/gpu/blend_modes/soft_light.rs @@ -4,6 +4,7 @@ pub fn register() -> BlendModeRegistration { BlendModeRegistration { type_id: "soft_light", display_name: "Soft Light", + description: "A gentler Overlay, shading rather than harshly boosting contrast.", category: "Contrast", gpu_value: 9, // pd_soft_light: Photoshop variant, Krita KoCompositeOpFunctions.h:513–529. diff --git a/crates/darkly/src/gpu/compositor.rs b/crates/darkly/src/gpu/compositor.rs index 4e5daa85..c8271451 100644 --- a/crates/darkly/src/gpu/compositor.rs +++ b/crates/darkly/src/gpu/compositor.rs @@ -2575,7 +2575,7 @@ impl Compositor { device: &wgpu::Device, queue: &wgpu::Queue, layer_id: LayerId, - void: Box, + mut void: Box, ) { if self.layer_cache.contains_key(&layer_id) { return; @@ -3144,6 +3144,21 @@ impl Compositor { &mut self.veil_chain } + /// The registries a preview mechanism may need, borrow-split in one place + /// so a caller does not have to reach for three `&mut self` accessors that + /// cannot coexist. + /// + /// The compositor's own registries rather than a second set owned by the + /// preview subsystem: a preview then shares the live pipeline cache and + /// compiles no shader twice. + pub fn preview_registries(&mut self) -> crate::gpu::preview::PreviewRegistries<'_> { + crate::gpu::preview::PreviewRegistries { + veils: self.veil_chain.registry_mut(), + voids: &mut self.void_registry, + filters: &mut self.filter_pipeline_registry, + } + } + /// Read-only access to the tool overlay. Callers do their own dispatch; /// the compositor stops being a switchboard. pub fn tool_overlay(&self) -> &ToolOverlay { diff --git a/crates/darkly/src/gpu/effect.rs b/crates/darkly/src/gpu/effect.rs index 37dd1038..043c31e9 100644 --- a/crates/darkly/src/gpu/effect.rs +++ b/crates/darkly/src/gpu/effect.rs @@ -44,6 +44,57 @@ impl EffectCache { aux_pipelines: Vec::new(), } } + + /// Rewrite the uniform buffer at `index` from freshly-packed bytes, or do + /// nothing when this cache holds no such buffer. + /// + /// An effect's parameter state reaches the GPU through exactly two callers + /// — the effect's own `create_cache` and whatever rewrites it afterwards — + /// and the two must agree on a layout `bytemuck` will not check for them. + /// Routing both through one method is what keeps the packing in one place. + pub fn write_uniform(&self, queue: &wgpu::Queue, index: usize, bytes: &[u8]) { + if let Some(buf) = self.uniform_bufs.get(index) { + queue.write_buffer(buf, 0, bytes); + } + } +} + +/// A fragment-visible bind-group entry kind. Fullscreen post-process pipelines +/// (every veil, plus the blit/downscale filters) are built from a short ordered +/// list of these; the builder assigns each its list position as its binding +/// index, matching the `@group(0) @binding(i)` numbering the shaders use. +#[derive(Clone, Copy)] +pub enum Binding { + /// Filterable 2D float texture (hardware-sampled). Input and aux textures. + Texture, + /// Filtering sampler. + Sampler, + /// Uniform buffer, no dynamic offset. + Uniform, +} + +impl Binding { + fn layout_entry(self, binding: u32) -> wgpu::BindGroupLayoutEntry { + let ty = match self { + Binding::Texture => wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + Binding::Sampler => wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + Binding::Uniform => wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + }; + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty, + count: None, + } + } } /// Build a render pipeline from a passthrough blit shader. @@ -53,10 +104,11 @@ pub fn create_blit_pipeline( format: wgpu::TextureFormat, label: &str, ) -> EffectPipeline { - create_filter_pipeline( + create_effect_pipeline( device, format, label, + &[Binding::Texture, Binding::Sampler], include_str!("../../shaders/blit.wgsl"), "fs_blit", ) @@ -72,44 +124,36 @@ pub fn create_downscale_pipeline( format: wgpu::TextureFormat, label: &str, ) -> EffectPipeline { - create_filter_pipeline( + create_effect_pipeline( device, format, label, + &[Binding::Texture, Binding::Sampler], include_str!("../../shaders/downscale.wgsl"), "fs_downscale", ) } -/// Shared pipeline builder for texture+sampler shaders that share the -/// blit bind-group layout (binding 0 = texture, binding 1 = sampler). -fn create_filter_pipeline( +/// Build a fullscreen-triangle post-process pipeline: `vs_main` + +/// `fragment_entry`, one color target of `format`, no blend/depth/stencil. The +/// bind-group layout is `bindings` in order, numbered 0..n. The single home for +/// every veil's pipeline construction and the blit/downscale filters. +pub fn create_effect_pipeline( device: &wgpu::Device, format: wgpu::TextureFormat, label: &str, + bindings: &[Binding], shader_source: &str, fragment_entry: &str, ) -> EffectPipeline { + let entries: Vec<_> = bindings + .iter() + .enumerate() + .map(|(i, b)| b.layout_entry(i as u32)) + .collect(); let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some(&format!("{label}-bgl")), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], + entries: &entries, }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { diff --git a/crates/darkly/src/gpu/filter.rs b/crates/darkly/src/gpu/filter.rs index 10784f19..36415a36 100644 --- a/crates/darkly/src/gpu/filter.rs +++ b/crates/darkly/src/gpu/filter.rs @@ -24,6 +24,11 @@ use std::sync::Arc; use super::effect::EffectCache; use super::params::{ParamDef, ParamValue}; +use super::preview::{ + PreviewAnim, PreviewEntry, PreviewMechanism, PreviewRegistries, PreviewSession, PreviewTarget, + PREVIEW_FORMAT, +}; +use crate::catalog::{Catalog, CatalogEntry}; /// A filter's GPU realization: a render pipeline plus optional param-derived /// resources built into an [`EffectCache`]. One instance is shared (Arc'd) @@ -75,21 +80,69 @@ pub struct FilterPipelineRegistration { /// Colors-menu action description, where the command palette's substring /// search indexes it — include the terms users would search for. pub description: &'static str, + /// Id of the action that applies this filter to the active layer. Bindings + /// in `presets/*.yaml` name this string; declaring it here rather than + /// deriving it from `type_id` is what gives those bindings a compile-time + /// target, the same way `ToolRegistration` does for tool selection. + pub hotkey_action: &'static str, pub params: &'static [ParamDef], + /// How long this filter's preview runs, or `None` for a filter with nothing + /// worth showing. Declaring an animation is what makes a filter previewable + /// — the two facts are one. + pub preview: Option, + /// The parameter values this filter's preview shows at `t ∈ [0, 1]`, in + /// `params` order. + /// + /// A function on the registration rather than a method on the effect, + /// because a [`FilterEffect`] is shared across every filter layer of its + /// type and holds no parameters of its own — they reach it through + /// [`ensure`](FilterEffect::ensure), which is what this feeds. `None` — the + /// default — is a still at the schema defaults, which is the honest answer + /// for a filter with no parameters to sweep. + pub preview_at: Option Vec>, pub create_pipeline: fn(&wgpu::Device) -> Arc, } +/// Id of the catalog this registry projects into. Distinct from the +/// `layerFilters` catalog of `crate::document::filter`, which registers mask +/// and selection modifiers rather than colour adjustments. +pub const CATALOG_ID: &str = "filters"; + +impl FilterPipelineRegistration { + pub fn catalog_entry(&self) -> CatalogEntry { + CatalogEntry::new(self.type_id, self.display_name) + .with_icon(self.icon) + .with_description(self.description) + .with_hotkey_action(self.hotkey_action) + .with_params(self.params) + .with_supports_preview(self.preview.is_some()) + } +} + +/// The filter catalog — every registered filter, sorted by `type_id`. +pub fn catalog() -> Catalog { + Catalog::new( + CATALOG_ID, + "Filters", + FilterPipelineRegistry::new() + .types() + .into_iter() + .map(FilterPipelineRegistration::catalog_entry) + .collect(), + ) + .with_description("Color adjustments applied to everything beneath them in the layer tree.") +} + /// Auto-discovered filter registry with lazy effect caching. pub struct FilterPipelineRegistry { entries: HashMap<&'static str, RegistryEntry>, } struct RegistryEntry { - display_name: &'static str, - icon: &'static str, - description: &'static str, - params: &'static [ParamDef], - create_pipeline: fn(&wgpu::Device) -> Arc, + /// The full registration this entry was built from. All metadata accessors + /// read straight off this, so a new `FilterPipelineRegistration` field is + /// exposed without widening any tuple or touching the registry. + reg: FilterPipelineRegistration, cached_pipeline: Option>, } @@ -106,11 +159,7 @@ impl FilterPipelineRegistry { entries.insert( reg.type_id, RegistryEntry { - display_name: reg.display_name, - icon: reg.icon, - description: reg.description, - params: reg.params, - create_pipeline: reg.create_pipeline, + reg, cached_pipeline: None, }, ); @@ -118,15 +167,13 @@ impl FilterPipelineRegistry { FilterPipelineRegistry { entries } } - /// All registered filter type IDs with their display names, icons, and - /// descriptions, sorted by id for a stable menu order. - pub fn types(&self) -> Vec<(&'static str, &'static str, &'static str, &'static str)> { - let mut types: Vec<_> = self - .entries - .iter() - .map(|(&id, e)| (id, e.display_name, e.icon, e.description)) - .collect(); - types.sort_by_key(|(id, _, _, _)| *id); + /// Return every registered filter's full [`FilterPipelineRegistration`], + /// sorted by `type_id` for a stable menu order. Callers read whatever + /// fields they need off the registration — a new field is free here. + pub fn types(&self) -> Vec<&FilterPipelineRegistration> { + let mut types: Vec<&FilterPipelineRegistration> = + self.entries.values().map(|e| &e.reg).collect(); + types.sort_by_key(|reg| reg.type_id); types } @@ -134,7 +181,10 @@ impl FilterPipelineRegistry { /// type (or a parameter-free filter). Drives both the `filter_types()` /// protocol emission and JSON→`ParamValue` conversion when a layer is added. pub fn params(&self, type_id: &str) -> &'static [ParamDef] { - self.entries.get(type_id).map(|e| e.params).unwrap_or(&[]) + self.entries + .get(type_id) + .map(|e| e.reg.params) + .unwrap_or(&[]) } /// True when this registry knows the given `type_id`. @@ -142,19 +192,51 @@ impl FilterPipelineRegistry { self.entries.contains_key(type_id) } + /// How long a filter type's preview runs. `None` for an unknown type or + /// one that declares no preview. + pub fn preview(&self, type_id: &str) -> Option { + self.entries.get(type_id)?.reg.preview + } + + /// The parameter values a filter type's preview shows at `t`, in schema + /// order. Falls back to the schema defaults for a filter that declares no + /// sweep, so a caller never has to ask whether one exists. + pub fn preview_params(&self, type_id: &str, t: f32) -> Vec { + let Some(entry) = self.entries.get(type_id) else { + return Vec::new(); + }; + match entry.reg.preview_at { + Some(at) => at(t), + None => entry + .reg + .params + .iter() + .map(ParamDef::default_value) + .collect(), + } + } + + /// Resolve a runtime `&str` type id to the registry's `&'static str` key, + /// or `None` if the type is unknown. Callers keying long-lived state by + /// type id (the preview cache + its readback context) use this to obtain a + /// `'static` id without leaking. Mirrors `VeilRegistry::static_type_id`. + pub fn static_type_id(&self, type_id: &str) -> Option<&'static str> { + self.entries.get_key_value(type_id).map(|(k, _)| *k) + } + /// Human-friendly display name for a filter type, falling back to the /// empty string when the type is unknown. pub fn display_name(&self, type_id: &str) -> &'static str { self.entries .get(type_id) - .map(|e| e.display_name) + .map(|e| e.reg.display_name) .unwrap_or("") } /// Iconify name for a filter type, falling back to the empty string when /// the type is unknown (callers substitute the generic layer-kind icon). pub fn icon(&self, type_id: &str) -> &'static str { - self.entries.get(type_id).map(|e| e.icon).unwrap_or("") + self.entries.get(type_id).map(|e| e.reg.icon).unwrap_or("") } /// Get or create the shared effect for a filter type. Returns `None` @@ -169,36 +251,106 @@ impl FilterPipelineRegistry { Some( entry .cached_pipeline - .get_or_insert_with(|| (entry.create_pipeline)(device)) + .get_or_insert_with(|| (entry.reg.create_pipeline)(device)) .clone(), ) } } -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashSet; - - /// Every filter declares a well-formed, non-empty Iconify name, and no two - /// filters share one — so each reads distinctly in the Colors menu and the - /// Add Filter Layer picker. Guards against copy-pasting a `register()` and - /// forgetting to change the icon. - #[test] - fn every_filter_has_a_unique_icon() { +// --------------------------------------------------------------------------- +// Preview mechanism +// --------------------------------------------------------------------------- + +/// This catalog's answer to [`PreviewMechanism`]. Exported by name so +/// `build.rs` finds it while scanning this module's source and emits a +/// `preview_mechanisms()` row for `filters`. +pub fn preview_mechanism() -> &'static dyn PreviewMechanism { + &FilterMechanism +} + +struct FilterMechanism; + +impl PreviewMechanism for FilterMechanism { + fn resolve(&self, type_id: &str) -> Option { let registry = FilterPipelineRegistry::new(); - let mut seen = HashSet::new(); - for (type_id, _display, icon, _description) in registry.types() { - assert!(!icon.is_empty(), "filter '{type_id}' has no icon"); - assert!( - icon.contains(':'), - "filter '{type_id}' icon '{icon}' is not a `prefix:name` Iconify id" - ); - assert!( - seen.insert(icon), - "filter '{type_id}' reuses icon '{icon}' — icons must be unique per filter" - ); + Some(PreviewEntry { + type_id: registry.static_type_id(type_id)?, + anim: registry.preview(type_id)?, + }) + } + + fn reads_source(&self) -> bool { + true + } + + fn open<'a>( + &self, + regs: PreviewRegistries<'a>, + type_id: &str, + ) -> Option> { + let type_id = regs.filters.static_type_id(type_id)?; + Some(Box::new(FilterSession { + registry: regs.filters, + type_id, + effect: None, + cache: EffectCache::empty(), + })) + } +} + +/// One open filter preview. +/// +/// Unlike a veil or a void there is no per-instance object to drive: a +/// [`FilterEffect`] is shared across every filter layer of its type and holds +/// no parameters, so each frame's values are computed on the registration and +/// pushed through [`ensure`](FilterEffect::ensure) into this session's own +/// cache. That is the same contract the compositor uses per layer. +struct FilterSession<'a> { + registry: &'a mut FilterPipelineRegistry, + type_id: &'static str, + effect: Option>, + cache: EffectCache, +} + +impl<'a> PreviewSession for FilterSession<'a> { + fn set_t( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + _target: &PreviewTarget, + t: f32, + ) { + if self.effect.is_none() { + self.effect = self.registry.pipeline(self.type_id, device); } - assert!(!seen.is_empty(), "no filters registered"); + let Some(effect) = self.effect.as_ref() else { + return; + }; + let params = self.registry.preview_params(self.type_id, t); + effect.ensure(device, queue, ¶ms, &mut self.cache); + } + + fn encode( + &mut self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + target: &PreviewTarget, + ) { + let Some(effect) = self.effect.as_ref() else { + return; + }; + effect.render( + device, + encoder, + target.source_view(), + None, + target.output_view(), + PREVIEW_FORMAT, + &self.cache, + ); } } + +// Icon well-formedness and per-catalog uniqueness are asserted generically for +// every registry by `icons_are_wellformed_and_unique_within_a_catalog` in +// `crate::catalog`. diff --git a/crates/darkly/src/gpu/filters/black_and_white.rs b/crates/darkly/src/gpu/filters/black_and_white.rs index f61a9e9a..d60d2370 100644 --- a/crates/darkly/src/gpu/filters/black_and_white.rs +++ b/crates/darkly/src/gpu/filters/black_and_white.rs @@ -57,15 +57,14 @@ fn create_pipeline(device: &wgpu::Device) -> Arc { pub fn register() -> FilterPipelineRegistration { FilterPipelineRegistration { - // A string literal rather than `bw::TYPE_ID`: the frontend's - // preset_hotkey_ids test derives filter action ids by scanning this - // directory's sources for `type_id: "…"`. Equality with the shared - // const is pinned by `gpu::black_and_white`'s identity test. - type_id: "black_and_white", + type_id: bw::TYPE_ID, display_name: bw::DISPLAY_NAME, description: bw::DESCRIPTION, + hotkey_action: "filterBlack_and_white", icon: "fa6-solid:droplet-slash", params: bw::PARAMS, + preview: Some(bw::PREVIEW), + preview_at: Some(bw::preview_params), create_pipeline, } } diff --git a/crates/darkly/src/gpu/filters/brightness_contrast.rs b/crates/darkly/src/gpu/filters/brightness_contrast.rs index 44f67dd2..78c610fd 100644 --- a/crates/darkly/src/gpu/filters/brightness_contrast.rs +++ b/crates/darkly/src/gpu/filters/brightness_contrast.rs @@ -18,20 +18,15 @@ use crate::gpu::effect::EffectCache; use crate::gpu::filter::{FilterEffect, FilterPipelineRegistration}; use crate::gpu::param_filter::{ParamFilter, SrcSampling}; use crate::gpu::params::{ParamDef, ParamValue}; +use crate::gpu::preview::{swing_signed, PreviewAnim}; pub const PARAMS: &[ParamDef] = &[ - ParamDef::Float { - name: "brightness", - min: -100.0, - max: 100.0, - default: 0.0, - }, - ParamDef::Float { - name: "contrast", - min: -100.0, - max: 100.0, - default: 0.0, - }, + ParamDef::float("brightness", -100.0, 100.0, 0.0) + .with_label("Brightness") + .with_description("Lifts or lowers every tone by the same amount."), + ParamDef::float("contrast", -100.0, 100.0, 0.0) + .with_label("Contrast") + .with_description("Spreads tones away from mid grey, or gathers them toward it."), ]; fn float_param(params: &[ParamValue], idx: usize) -> f32 { @@ -99,13 +94,32 @@ fn create_pipeline(device: &wgpu::Device) -> Arc { )) } +/// Both sliders swing up, down and back, concurrently — so the preview shows +/// the two controls interacting rather than one at a time. Contrast leads with +/// a wider positive swing because it reads more slowly than brightness at the +/// same magnitude, and a narrower negative one because flattening reads faster +/// than steepening. +fn preview_params(t: f32) -> Vec { + let s = swing_signed(t); + let mut params: Vec = PARAMS.iter().map(ParamDef::default_value).collect(); + params[0] = ParamValue::Float(40.0 * s); // brightness + params[1] = ParamValue::Float(60.0 * s.max(0.0) + 40.0 * s.min(0.0)); // contrast + params +} + pub fn register() -> FilterPipelineRegistration { FilterPipelineRegistration { type_id: "brightness_contrast", display_name: "Brightness/Contrast", icon: "fa6-solid:sun", description: "The classic two-slider brightness and contrast adjustment.", + hotkey_action: "filterBrightness_contrast", params: PARAMS, + // A signed sweep rests in the middle, so the default still would be the + // frame that looks like no effect at all. The quarter point is its + // positive extreme. + preview: Some(PreviewAnim::LOOPING.with_still_at(0.25)), + preview_at: Some(preview_params), create_pipeline, } } diff --git a/crates/darkly/src/gpu/filters/chromatic_aberration.rs b/crates/darkly/src/gpu/filters/chromatic_aberration.rs index 4f86c7f2..3b3b012e 100644 --- a/crates/darkly/src/gpu/filters/chromatic_aberration.rs +++ b/crates/darkly/src/gpu/filters/chromatic_aberration.rs @@ -19,6 +19,7 @@ //! Unlike the other parametric filters this one reads its source with //! [`SrcSampling::Bilinear`] — the ghost/blur taps land on fractional offsets. +use crate::units::UnitType; use std::collections::BTreeMap; use std::sync::Arc; @@ -28,6 +29,7 @@ use crate::gpu::effect::EffectCache; use crate::gpu::filter::{FilterEffect, FilterPipelineRegistration}; use crate::gpu::param_filter::{ParamFilter, SrcSampling}; use crate::gpu::params::{ConstParamValue, ParamDef, ParamValue}; +use crate::gpu::preview::{swing, PreviewAnim}; /// Uniform-array size (and the schema's entry cap). The UI disables "Add" at the /// limit; [`pack_uniform`] still clamps defensively. @@ -35,28 +37,22 @@ pub const MAX_ABERRATIONS: usize = 16; /// Schema for a single aberration entry. const ABERRATION_ITEM: &[ParamDef] = &[ - ParamDef::Vec2 { - name: "offset", - max: 64.0, - default: [0.0, 0.0], - }, - ParamDef::Float { - name: "scale", - min: 0.9, - max: 1.1, - default: 1.0, - }, - ParamDef::Color { - name: "color", - default: [1.0, 1.0, 1.0], - }, - ParamDef::Float { - name: "blur", - min: 0.0, - // Max blur kept modest so a bounded tap count can't band. - max: 6.0, - default: 0.0, - }, + ParamDef::vec2("offset", 64.0, [0.0, 0.0]) + .with_label("Offset") + .with_description( + "How far this fringe is displaced from the original, and in which direction.", + ) + .with_unit(UnitType::Pixels), + ParamDef::float("scale", 0.9, 1.1, 1.0) + .with_label("Scale") + .with_description("Magnification of this fringe — values below 1 pull it inward."), + ParamDef::color("color", [1.0, 1.0, 1.0]) + .with_label("Color") + .with_description("Which color this fringe contributes."), + ParamDef::float("blur", 0.0, 6.0, 0.0) + .with_label("Blur") + .with_description("Softens this fringe so it reads as defocus rather than a hard copy.") + .with_unit(UnitType::Pixels), ]; /// One `aberrations` list param with the photographic 3-entry default: red @@ -64,11 +60,11 @@ const ABERRATION_ITEM: &[ParamDef] = &[ /// (1.00 / 0.99 / 0.98), a 1% step per channel — the wavelength-dependent focus /// of a real lens fringing the shorter wavelengths inward. Each is softened a /// touch. -pub const PARAMS: &[ParamDef] = &[ParamDef::List { - name: "aberrations", - item: ABERRATION_ITEM, - max_len: MAX_ABERRATIONS, - default: &[ +pub const PARAMS: &[ParamDef] = &[ParamDef::list( + "aberrations", + ABERRATION_ITEM, + MAX_ABERRATIONS, + &[ &[ ("scale", ConstParamValue::Float(1.0)), ("color", ConstParamValue::Color([1.0, 0.0, 0.0])), @@ -85,7 +81,44 @@ pub const PARAMS: &[ParamDef] = &[ParamDef::List { ("blur", ConstParamValue::Float(0.6)), ], ], -}]; +) +.with_label("Fringes") +.with_description("The colored copies the lens splits the image into.")]; + +/// One preview for both surfaces, beside the schema they share. A `static` so +/// both registrations hold the same address, which is what makes the sharing +/// structural rather than two copies that happen to agree today. +pub static PREVIEW: PreviewAnim = PreviewAnim::LOOPING; + +/// What that preview shows at `t`: the three fringes spread outward from their +/// photographic resting positions and close again, softening as they go — so it +/// shows the fringe *forming* rather than a still that could be mistaken for a +/// blurry image. +/// +/// The filter reads this off its registration and the veil calls it from +/// [`Veil::preview_at`](crate::gpu::veil::Veil::preview_at) — the two surfaces +/// share the motion the same way they share the schema. +pub fn preview_params(t: f32) -> Vec { + let swing = swing(t); + // Red holds at unit magnification; the shorter wavelengths step inward, + // 1% each at rest and 3% at the far end of the sweep. + let fringe = |index: f32, color: [f32; 3]| { + BTreeMap::from([ + ("offset".to_string(), ParamValue::Vec2([0.0, 0.0])), + ( + "scale".to_string(), + ParamValue::Float(1.0 - index * (0.01 + 0.02 * swing)), + ), + ("color".to_string(), ParamValue::Color(color)), + ("blur".to_string(), ParamValue::Float(0.6 + 1.2 * swing)), + ]) + }; + vec![ParamValue::List(vec![ + fringe(0.0, [1.0, 0.0, 0.0]), + fringe(1.0, [0.0, 1.0, 0.0]), + fringe(2.0, [0.0, 0.0, 1.0]), + ])] +} /// One aberration in the shader's uniform (48 B). Field offsets match /// `struct Aberration` in `lib/aberration.wgsl` (vec3 `axis` at offset 16). The @@ -268,7 +301,10 @@ pub fn register() -> FilterPipelineRegistration { display_name: "Chromatic Aberration", icon: "lucide-lab:venn", description: DESCRIPTION, + hotkey_action: "filterChromatic_aberration", params: PARAMS, + preview: Some(PREVIEW), + preview_at: Some(preview_params), create_pipeline, } } diff --git a/crates/darkly/src/gpu/filters/curves.rs b/crates/darkly/src/gpu/filters/curves.rs index b69a044f..3b49931d 100644 --- a/crates/darkly/src/gpu/filters/curves.rs +++ b/crates/darkly/src/gpu/filters/curves.rs @@ -24,6 +24,7 @@ use crate::brush::curve_math::CurveLut; use crate::gpu::filter::{FilterEffect, FilterPipelineRegistration}; use crate::gpu::lut_filter::{bake_lut, lut_param_filter, lut_shader_source, Baked}; use crate::gpu::params::{ParamDef, ParamValue}; +use crate::gpu::preview::{swing_signed, PreviewAnim}; /// Identity curve — a straight line through the two endpoints. const IDENTITY: &[[f32; 2]] = &[[0.0, 0.0], [1.0, 1.0]]; @@ -32,38 +33,30 @@ const IDENTITY: &[[f32; 2]] = &[[0.0, 0.0], [1.0, 1.0]]; /// [`build_lut`] indexes these positionally (matching [`Channel`]) and the /// shader reads baked components in the same order. pub const PARAMS: &[ParamDef] = &[ - ParamDef::Curve { - name: "rgb", - default: IDENTITY, - }, - ParamDef::Curve { - name: "red", - default: IDENTITY, - }, - ParamDef::Curve { - name: "green", - default: IDENTITY, - }, - ParamDef::Curve { - name: "blue", - default: IDENTITY, - }, - ParamDef::Curve { - name: "alpha", - default: IDENTITY, - }, - ParamDef::Curve { - name: "hue", - default: IDENTITY, - }, - ParamDef::Curve { - name: "saturation", - default: IDENTITY, - }, - ParamDef::Curve { - name: "lightness", - default: IDENTITY, - }, + ParamDef::curve("rgb", IDENTITY) + .with_label("RGB") + .with_description("Tone curve applied to all three color channels together."), + ParamDef::curve("red", IDENTITY) + .with_label("Red") + .with_description("Tone curve applied to the red channel alone."), + ParamDef::curve("green", IDENTITY) + .with_label("Green") + .with_description("Tone curve applied to the green channel alone."), + ParamDef::curve("blue", IDENTITY) + .with_label("Blue") + .with_description("Tone curve applied to the blue channel alone."), + ParamDef::curve("alpha", IDENTITY) + .with_label("Alpha") + .with_description("Tone curve applied to opacity."), + ParamDef::curve("hue", IDENTITY) + .with_label("Hue") + .with_description("Remaps hue against itself, shifting colors around the wheel."), + ParamDef::curve("saturation", IDENTITY) + .with_label("Saturation") + .with_description("Remaps saturation, letting muted and vivid areas move apart."), + ParamDef::curve("lightness", IDENTITY) + .with_label("Lightness") + .with_description("Remaps lightness independently of hue and saturation."), ]; /// Read a curve param's control points by index, falling back to identity when @@ -89,13 +82,45 @@ fn create_pipeline(device: &wgpu::Device) -> Arc { Arc::new(lut_param_filter(device, &lut_shader_source(), build_lut)) } +/// Contrast rises above the identity transfer, falls below it, and returns. +/// +/// The swept curve carries four control points at fixed `x` coordinates, so +/// only `y` moves and the points stay ascending for [`CurveLut::from_points`]' +/// sorted-input precondition. `IDENTITY` is deliberately not the resting curve: +/// it holds two points, an S-curve needs four, and a preview that changed point +/// count mid-sweep would step rather than move. The four-point resting curve is +/// collinear, so the natural-cubic spline reproduces the straight line exactly +/// and the resting frames match a default-parameter render. +/// +/// Only the composite `rgb` channel moves; the other seven stay at their +/// two-point defaults, which keeps the per-channel and HSL rows of the baked LUT +/// inactive and the result reading as a tone swing rather than a hue mess. +fn preview_params(t: f32) -> Vec { + let bend = 0.18 * swing_signed(t); + let mut params: Vec = PARAMS.iter().map(ParamDef::default_value).collect(); + params[0] = ParamValue::Curve(vec![ + // rgb + [0.0, 0.0], + [0.25, 0.25 - bend], + [0.75, 0.75 + bend], + [1.0, 1.0], + ]); + params +} + pub fn register() -> FilterPipelineRegistration { FilterPipelineRegistration { type_id: "curves", display_name: "Curves", icon: "fa6-solid:chart-line", description: "Remap tones and colors with editable per-channel curves.", + hotkey_action: "filterCurves", params: PARAMS, + // A signed sweep rests in the middle, so the default still would be the + // frame that looks like no effect at all. The quarter point is its + // positive extreme. + preview: Some(PreviewAnim::LOOPING.with_still_at(0.25)), + preview_at: Some(preview_params), create_pipeline, } } diff --git a/crates/darkly/src/gpu/filters/hsv.rs b/crates/darkly/src/gpu/filters/hsv.rs index fe57df2c..bbbea05b 100644 --- a/crates/darkly/src/gpu/filters/hsv.rs +++ b/crates/darkly/src/gpu/filters/hsv.rs @@ -21,37 +21,28 @@ use crate::gpu::effect::EffectCache; use crate::gpu::filter::{FilterEffect, FilterPipelineRegistration}; use crate::gpu::param_filter::{ParamFilter, SrcSampling}; use crate::gpu::params::{ParamDef, ParamValue}; +use crate::gpu::preview::{swing, swing_signed, PreviewAnim}; /// Parameter schema. `model` is an enum dropdown; the three scalars are plain /// rows; `colorize` is a checkbox that (in the shader) overrides the model. pub const PARAMS: &[ParamDef] = &[ - ParamDef::Enum { - name: "model", - options: &["HSV", "HSL", "HSY"], - default: 0, - }, - ParamDef::Float { - name: "hue", - min: -180.0, - max: 180.0, - default: 0.0, - }, - ParamDef::Float { - name: "saturation", - min: -100.0, - max: 100.0, - default: 0.0, - }, - ParamDef::Float { - name: "value", - min: -100.0, - max: 100.0, - default: 0.0, - }, - ParamDef::Bool { - name: "colorize", - default: false, - }, + ParamDef::enumeration("model", &["HSV", "HSL", "HSY"], 0) + .with_label("Color Model") + .with_description("Which cylindrical model the adjustment works in."), + ParamDef::float("hue", -180.0, 180.0, 0.0) + .with_label("Hue") + .with_description("Rotation applied to every pixel's hue."), + ParamDef::float("saturation", -100.0, 100.0, 0.0) + .with_label("Saturation") + .with_description("Pushes colors toward grey or toward full intensity."), + ParamDef::float("value", -100.0, 100.0, 0.0) + .with_label("Value") + .with_description("Lightens or darkens without changing hue."), + ParamDef::boolean("colorize", false) + .with_label("Colorize") + .with_description( + "Replaces every hue with the chosen one, tinting the layer a single color.", + ), ]; /// The HSV fragment shader: the shared colour-space lib prepended to `hsv.wgsl` @@ -133,13 +124,32 @@ fn create_pipeline(device: &wgpu::Device) -> Arc { )) } +/// The image lightens, darkens and returns while the hue rotates out and back, +/// the two sweeps running concurrently. `model` and `colorize` stay at their +/// defaults, so what moves is exactly the pair of knobs the filter is named +/// for. A full 360° spin is expressible as `hue: -180 → 180` — the parameter's +/// own endpoints are the same colour — but it would not end where it began, so +/// the ping-pong closes instead. +fn preview_params(t: f32) -> Vec { + let mut params: Vec = PARAMS.iter().map(ParamDef::default_value).collect(); + params[1] = ParamValue::Float(180.0 * swing(t)); // hue + params[3] = ParamValue::Float(60.0 * swing_signed(t)); // value + params +} + pub fn register() -> FilterPipelineRegistration { FilterPipelineRegistration { type_id: "hsv", display_name: "Hue/Saturation", icon: "fa6-solid:palette", description: "Rotate hue and scale saturation and value, with optional colorize.", + hotkey_action: "filterHsv", params: PARAMS, + // A signed sweep rests in the middle, so the default still would be the + // frame that looks like no effect at all. The quarter point is its + // positive extreme. + preview: Some(PreviewAnim::LOOPING.with_still_at(0.25)), + preview_at: Some(preview_params), create_pipeline, } } diff --git a/crates/darkly/src/gpu/filters/invert.rs b/crates/darkly/src/gpu/filters/invert.rs index 69dd0c8a..cb1bb326 100644 --- a/crates/darkly/src/gpu/filters/invert.rs +++ b/crates/darkly/src/gpu/filters/invert.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use crate::gpu::effect::MaskedFilterPipeline; use crate::gpu::filter::{FilterEffect, FilterPipelineRegistration}; +use crate::gpu::preview::PreviewAnim; /// Prepend the shared color atom to the invert shader so `fs_invert` / /// `fs_invert_masked` can call `invert_color` — the same `include_str!` @@ -36,7 +37,13 @@ pub fn register() -> FilterPipelineRegistration { display_name: "Invert Colors", icon: "fa6-solid:circle-half-stroke", description: "Invert every color channel for a photo-negative.", + hotkey_action: "filterInvert", params: &[], + // Invert takes no parameters, so there is nothing to sweep and nothing + // to declare: one frame of the filter fully applied, which is the whole + // of what it does. + preview: Some(PreviewAnim::STILL), + preview_at: None, create_pipeline, } } diff --git a/crates/darkly/src/gpu/filters/levels.rs b/crates/darkly/src/gpu/filters/levels.rs index 09996b71..362cb17a 100644 --- a/crates/darkly/src/gpu/filters/levels.rs +++ b/crates/darkly/src/gpu/filters/levels.rs @@ -20,6 +20,7 @@ use std::sync::Arc; use crate::gpu::filter::{FilterEffect, FilterPipelineRegistration}; use crate::gpu::lut_filter::{bake_lut, lut_param_filter, lut_shader_source, Baked}; use crate::gpu::params::{ParamDef, ParamValue}; +use crate::gpu::preview::{swing_signed, PreviewAnim}; /// Identity levels — `[inBlack, inWhite, gamma, outBlack, outWhite]`. Maps the /// full `[0,1]` input range linearly onto `[0,1]` output: a no-op transfer. @@ -29,38 +30,32 @@ const IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0]; /// [Curves](super::curves::PARAMS). Load-bearing: [`build_lut`] indexes these /// positionally (matching [`Channel`](crate::gpu::lut_filter::Channel)). pub const PARAMS: &[ParamDef] = &[ - ParamDef::Levels { - name: "rgb", - default: IDENTITY, - }, - ParamDef::Levels { - name: "red", - default: IDENTITY, - }, - ParamDef::Levels { - name: "green", - default: IDENTITY, - }, - ParamDef::Levels { - name: "blue", - default: IDENTITY, - }, - ParamDef::Levels { - name: "alpha", - default: IDENTITY, - }, - ParamDef::Levels { - name: "hue", - default: IDENTITY, - }, - ParamDef::Levels { - name: "saturation", - default: IDENTITY, - }, - ParamDef::Levels { - name: "lightness", - default: IDENTITY, - }, + ParamDef::levels("rgb", IDENTITY) + .with_label("RGB") + .with_description( + "Black point, white point and gamma for all three color channels together.", + ), + ParamDef::levels("red", IDENTITY) + .with_label("Red") + .with_description("Black point, white point and gamma for the red channel alone."), + ParamDef::levels("green", IDENTITY) + .with_label("Green") + .with_description("Black point, white point and gamma for the green channel alone."), + ParamDef::levels("blue", IDENTITY) + .with_label("Blue") + .with_description("Black point, white point and gamma for the blue channel alone."), + ParamDef::levels("alpha", IDENTITY) + .with_label("Alpha") + .with_description("Black point, white point and gamma for opacity."), + ParamDef::levels("hue", IDENTITY) + .with_label("Hue") + .with_description("Black point, white point and gamma applied to hue."), + ParamDef::levels("saturation", IDENTITY) + .with_label("Saturation") + .with_description("Black point, white point and gamma applied to saturation."), + ParamDef::levels("lightness", IDENTITY) + .with_label("Lightness") + .with_description("Black point, white point and gamma applied to lightness."), ]; /// Read a levels param by index, falling back to identity when missing/malformed. @@ -108,13 +103,42 @@ fn create_pipeline(device: &wgpu::Device) -> Arc { Arc::new(lut_param_filter(device, &lut_shader_source(), build_lut)) } +/// The input range pinches inward against a brightening gamma, then opens back +/// out against a darkening one, and returns — the two halves of what the +/// control does, in one pass. +/// +/// `gamma` is a raw exponent rather than a perceptual scale, so it sweeps as a +/// ratio around 1.0 rather than an even numeric spread: the two extremes are +/// reciprocals and read as equal and opposite. Only the composite `rgb` channel +/// moves; the other seven stay at their identity defaults. +fn preview_params(t: f32) -> Vec { + let s = swing_signed(t); + let pinch = 0.15 * s.max(0.0); + let mut params: Vec = PARAMS.iter().map(ParamDef::default_value).collect(); + params[0] = ParamValue::Levels([ + // rgb + pinch, + 1.0 - pinch, + 2.2f32.powf(-s), + 0.0, + 1.0, + ]); + params +} + pub fn register() -> FilterPipelineRegistration { FilterPipelineRegistration { type_id: "levels", display_name: "Levels", icon: "fa6-solid:sliders", description: "Tone mapping with black point, white point, gamma, and output range.", + hotkey_action: "filterLevels", params: PARAMS, + // A signed sweep rests in the middle, so the default still would be the + // frame that looks like no effect at all. The quarter point is its + // positive extreme. + preview: Some(PreviewAnim::LOOPING.with_still_at(0.25)), + preview_at: Some(preview_params), create_pipeline, } } diff --git a/crates/darkly/src/gpu/hash.rs b/crates/darkly/src/gpu/hash.rs new file mode 100644 index 00000000..a639b1ee --- /dev/null +++ b/crates/darkly/src/gpu/hash.rs @@ -0,0 +1,23 @@ +//! Integer hashing shared by anything that needs deterministic pseudo-randomness +//! from a coordinate, an index or a seed. +//! +//! Lives at `gpu` scope rather than inside any one of its callers: a hash is not +//! a property of noise voids, of film grain, or of preview backdrops, and naming +//! it after whichever of them was written first would make every later caller +//! reach across into a module it has nothing to do with. +//! +//! Credits: +//! +//! • `pcg_hash` is the `pcg` variant from Mark Jarzynski and Marc Olano, "Hash +//! Functions for GPU Rendering", Journal of Computer Graphics Techniques 9(3), +//! 2020, . + +/// Integer PCG hash: one multiply-add, an xorshift by a state-derived amount, +/// a second multiply, and a final xorshift. Fast, well-distributed across the +/// whole 32-bit range, and free of the visible axis-aligned structure the +/// cheaper `sin`-based hashes show once their output is used as a lattice value. +pub fn pcg_hash(n: u32) -> u32 { + let mut h = n.wrapping_mul(747796405).wrapping_add(2891336453); + h = ((h >> ((h >> 28) + 4)) ^ h).wrapping_mul(277803737); + (h >> 22) ^ h +} diff --git a/crates/darkly/src/gpu/mod.rs b/crates/darkly/src/gpu/mod.rs index bc43338e..7f422427 100644 --- a/crates/darkly/src/gpu/mod.rs +++ b/crates/darkly/src/gpu/mod.rs @@ -110,6 +110,7 @@ pub mod filter; pub mod filters; pub mod floating_preview; pub mod flood_fill; +pub mod hash; pub mod histogram; pub mod lut_filter; pub mod ortho_transform; @@ -130,10 +131,8 @@ pub mod transform; pub mod vector_renderer; pub mod veil; pub mod veil_chain; -pub mod veil_preview; pub mod veils; pub mod video_stream_void; pub mod view; pub mod void; -pub mod void_preview; pub mod voids; diff --git a/crates/darkly/src/gpu/params.rs b/crates/darkly/src/gpu/params.rs index d3029cf7..ef1f7cc2 100644 --- a/crates/darkly/src/gpu/params.rs +++ b/crates/darkly/src/gpu/params.rs @@ -1,12 +1,12 @@ +use crate::units::UnitType; use std::collections::BTreeMap; -/// A `const`-constructible parameter value, used only for schema-level defaults -/// (a [`ParamDef::List`]'s per-entry overrides). [`ParamValue`] owns `String`s -/// and `Vec`s that can't be built in a `const`, so the schema carries this -/// `'static`-friendly mirror and lifts it to a `ParamValue` at registry build. -#[derive(Clone, Copy, Debug, serde::Serialize)] -#[serde(untagged)] -#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +/// A `const`-constructible parameter value, used for schema-level defaults — +/// today, a [`ParamKind::List`]'s per-entry overrides. [`ParamValue`] owns +/// `String`s and `Vec`s that can't be built in a `const`, so the schema carries +/// this `'static`-friendly mirror and lifts it through +/// [`to_value`](ConstParamValue::to_value). +#[derive(Clone, Copy, Debug, PartialEq)] pub enum ConstParamValue { Bool(bool), Int(i32), @@ -14,9 +14,20 @@ pub enum ConstParamValue { Str(&'static str), Color([f32; 3]), Vec2([f32; 2]), + /// Curve control points, mirroring [`ParamKind::Curve`]'s default. + Curve(&'static [[f32; 2]]), + /// A levels transfer, mirroring [`ParamKind::Levels`]'s default. + Levels([f32; 5]), + /// Per-entry named overlays over a list item's own defaults, mirroring + /// [`ParamKind::List`]'s default. + List(&'static [&'static [(&'static str, ConstParamValue)]]), } impl ConstParamValue { + /// Lift to a [`ParamValue`]. Every scalar shape mirrors directly; a nested + /// [`List`](ConstParamValue::List) lifts to an empty list, because + /// expanding its entries would need the `item` defs it overlays and a + /// schema declares no list of lists. fn to_value(self) -> ParamValue { match self { ConstParamValue::Bool(b) => ParamValue::Bool(b), @@ -25,58 +36,82 @@ impl ConstParamValue { ConstParamValue::Str(s) => ParamValue::String(s.to_string()), ConstParamValue::Color(c) => ParamValue::Color(c), ConstParamValue::Vec2(v) => ParamValue::Vec2(v), + ConstParamValue::Curve(pts) => ParamValue::Curve(pts.to_vec()), + ConstParamValue::Levels(a) => ParamValue::Levels(a), + ConstParamValue::List(_) => ParamValue::List(Vec::new()), } } } /// Schema definition for a single effect parameter (filter or veil). /// Each module defines a `const` array of these describing its parameters. -#[derive(Clone, Debug, serde::Serialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] -pub enum ParamDef { +/// +/// Authored through the `const fn` constructors and `with_*` builders, matching +/// how [`PortDef`](crate::nodegraph::PortDef) is written: +/// +/// ```ignore +/// ParamDef::float("hue", -180.0, 180.0, 0.0) +/// .with_label("Hue") +/// .with_description("Rotation applied to every pixel's hue.") +/// .with_unit(UnitType::Degrees) +/// ``` +/// +/// The metadata every parameter shares lives on the struct; only the +/// value-shaped part varies, in [`ParamKind`]. Adding the next shared field is +/// one struct field plus one builder rather than an edit at every declaration. +#[derive(Clone, Debug)] +pub struct ParamDef { + pub name: &'static str, + /// Display label. `None` → the UI title-cases `name`. + pub label: Option<&'static str>, + /// One-sentence summary of what this parameter does, in the same painter + /// vocabulary as a registration's `description`. + pub description: Option<&'static str>, + /// Display unit. Renders a suffix and, for [`UnitType::Percent`] / + /// [`UnitType::Degrees`], converts on the way to the UI — so a parameter + /// already stored in display space declares [`UnitType::Raw`]. + pub unit: UnitType, + pub kind: ParamKind, +} + +/// The value-shaped half of a [`ParamDef`] — what this parameter stores, its +/// range, and its default. +#[derive(Clone, Debug)] +pub enum ParamKind { Float { - name: &'static str, min: f32, max: f32, default: f32, }, Int { - name: &'static str, min: i32, max: i32, default: i32, }, Bool { - name: &'static str, default: bool, }, String { - name: &'static str, default: &'static str, }, Curve { - name: &'static str, default: &'static [[f32; 2]], }, /// Levels adjustment — a black/gamma/white/output transfer, stored as /// `[inBlack, inWhite, gamma, outBlack, outWhite]` (all normalized `[0,1]` /// except `gamma`, the raw `0.1–10` exponent). Baked into the same LUT as a - /// [`Curve`](ParamDef::Curve) by the shared LUT-filter scaffold. + /// [`Curve`](ParamKind::Curve) by the shared LUT-filter scaffold. Levels { - name: &'static str, default: [f32; 5], }, /// Enum displayed as a dropdown. Stored as Int (index into `options`). Enum { - name: &'static str, options: &'static [&'static str], default: i32, }, /// Float displayed as a plain text input instead of a scrub bar. /// Use for values where dragging is impractical (large ranges, precise entry). FloatInput { - name: &'static str, min: f32, max: f32, default: f32, @@ -84,7 +119,6 @@ pub enum ParamDef { /// Icon picker displayed as a dropdown with FA icon previews. /// Stored as String (FA class name). `options` lists the available icons. Icon { - name: &'static str, options: &'static [(&'static str, &'static str)], default: &'static str, }, @@ -94,14 +128,12 @@ pub enum ParamDef { /// texel values (like the Curves LUT), so they carry the sRGB triple raw. /// See `frontend/src/lib/color.ts`'s `hexToRgb01`/`rgb01ToHex`. Color { - name: &'static str, default: [f32; 3], }, /// A 2D vector — direction + magnitude, edited via the draggable offset pad. /// `max` is the magnitude clamp (the pad's edge radius); values are stored /// with magnitude ≤ `max`. Vec2 { - name: &'static str, max: f32, default: [f32; 2], }, @@ -111,13 +143,107 @@ pub enum ParamDef { /// `default` supplies per-entry named overrides layered on top of `item`'s /// own defaults; entries not overridden fall back to the item schema. List { - name: &'static str, item: &'static [ParamDef], max_len: usize, default: &'static [&'static [(&'static str, ConstParamValue)]], }, } +impl ParamDef { + const fn of(name: &'static str, kind: ParamKind) -> Self { + ParamDef { + name, + label: None, + description: None, + unit: UnitType::Raw, + kind, + } + } + + pub const fn float(name: &'static str, min: f32, max: f32, default: f32) -> Self { + Self::of(name, ParamKind::Float { min, max, default }) + } + + pub const fn int(name: &'static str, min: i32, max: i32, default: i32) -> Self { + Self::of(name, ParamKind::Int { min, max, default }) + } + + pub const fn boolean(name: &'static str, default: bool) -> Self { + Self::of(name, ParamKind::Bool { default }) + } + + pub const fn string(name: &'static str, default: &'static str) -> Self { + Self::of(name, ParamKind::String { default }) + } + + pub const fn curve(name: &'static str, default: &'static [[f32; 2]]) -> Self { + Self::of(name, ParamKind::Curve { default }) + } + + pub const fn levels(name: &'static str, default: [f32; 5]) -> Self { + Self::of(name, ParamKind::Levels { default }) + } + + pub const fn enumeration( + name: &'static str, + options: &'static [&'static str], + default: i32, + ) -> Self { + Self::of(name, ParamKind::Enum { options, default }) + } + + pub const fn float_input(name: &'static str, min: f32, max: f32, default: f32) -> Self { + Self::of(name, ParamKind::FloatInput { min, max, default }) + } + + pub const fn icon( + name: &'static str, + options: &'static [(&'static str, &'static str)], + default: &'static str, + ) -> Self { + Self::of(name, ParamKind::Icon { options, default }) + } + + pub const fn color(name: &'static str, default: [f32; 3]) -> Self { + Self::of(name, ParamKind::Color { default }) + } + + pub const fn vec2(name: &'static str, max: f32, default: [f32; 2]) -> Self { + Self::of(name, ParamKind::Vec2 { max, default }) + } + + pub const fn list( + name: &'static str, + item: &'static [ParamDef], + max_len: usize, + default: &'static [&'static [(&'static str, ConstParamValue)]], + ) -> Self { + Self::of( + name, + ParamKind::List { + item, + max_len, + default, + }, + ) + } + + pub const fn with_label(mut self, label: &'static str) -> Self { + self.label = Some(label); + self + } + + pub const fn with_description(mut self, description: &'static str) -> Self { + self.description = Some(description); + self + } + + pub const fn with_unit(mut self, unit: UnitType) -> Self { + self.unit = unit; + self + } +} + /// A concrete runtime parameter value, read from an effect instance. /// /// Variants are ordered for `#[serde(untagged)]` deserialization: serde @@ -176,7 +302,7 @@ pub fn param_values_from_json(obj: &serde_json::Value, defs: &[ParamDef]) -> Vec None => return defs.iter().map(|d| d.default_value()).collect(), }; defs.iter() - .map(|def| def.value_from_json(map.get(def.name()))) + .map(|def| def.value_from_json(map.get(def.name))) .collect() } @@ -192,7 +318,7 @@ fn clamp_magnitude(v: [f32; 2], max: f32) -> [f32; 2] { } } -/// Expand a [`ParamDef::List`]'s schema defaults into concrete entries: each +/// Expand a [`ParamKind::List`]'s schema defaults into concrete entries: each /// default entry starts from the `item` schema's own per-field defaults, then /// applies that entry's named overrides on top. fn list_default( @@ -204,7 +330,7 @@ fn list_default( .map(|overrides| { item.iter() .map(|d| { - let key = d.name(); + let key = d.name; let val = overrides .iter() .find(|(k, _)| *k == key) @@ -219,19 +345,19 @@ fn list_default( impl ParamDef { pub fn default_value(&self) -> ParamValue { - match self { - ParamDef::Float { default, .. } => ParamValue::Float(*default), - ParamDef::Int { default, .. } => ParamValue::Int(*default), - ParamDef::Bool { default, .. } => ParamValue::Bool(*default), - ParamDef::String { default, .. } => ParamValue::String(default.to_string()), - ParamDef::Curve { default, .. } => ParamValue::Curve(default.to_vec()), - ParamDef::Levels { default, .. } => ParamValue::Levels(*default), - ParamDef::Enum { default, .. } => ParamValue::Int(*default), - ParamDef::FloatInput { default, .. } => ParamValue::Float(*default), - ParamDef::Icon { default, .. } => ParamValue::String(default.to_string()), - ParamDef::Color { default, .. } => ParamValue::Color(*default), - ParamDef::Vec2 { default, .. } => ParamValue::Vec2(*default), - ParamDef::List { item, default, .. } => ParamValue::List(list_default(item, default)), + match &self.kind { + ParamKind::Float { default, .. } => ParamValue::Float(*default), + ParamKind::Int { default, .. } => ParamValue::Int(*default), + ParamKind::Bool { default, .. } => ParamValue::Bool(*default), + ParamKind::String { default, .. } => ParamValue::String(default.to_string()), + ParamKind::Curve { default, .. } => ParamValue::Curve(default.to_vec()), + ParamKind::Levels { default, .. } => ParamValue::Levels(*default), + ParamKind::Enum { default, .. } => ParamValue::Int(*default), + ParamKind::FloatInput { default, .. } => ParamValue::Float(*default), + ParamKind::Icon { default, .. } => ParamValue::String(default.to_string()), + ParamKind::Color { default, .. } => ParamValue::Color(*default), + ParamKind::Vec2 { default, .. } => ParamValue::Vec2(*default), + ParamKind::List { item, default, .. } => ParamValue::List(list_default(item, default)), } } @@ -239,53 +365,53 @@ impl ParamDef { /// coercing to this def's concrete [`ParamValue`] variant. The `List` arm /// recurses over its `item` defs per entry, so no arm is duplicated. pub fn value_from_json(&self, raw: Option<&serde_json::Value>) -> ParamValue { - match self { - ParamDef::Float { default, .. } => { + match &self.kind { + ParamKind::Float { default, .. } => { ParamValue::Float(raw.and_then(|v| v.as_f64()).unwrap_or(*default as f64) as f32) } - ParamDef::Int { default, .. } => { + ParamKind::Int { default, .. } => { ParamValue::Int(raw.and_then(|v| v.as_f64()).unwrap_or(*default as f64) as i32) } - ParamDef::Bool { default, .. } => { + ParamKind::Bool { default, .. } => { ParamValue::Bool(raw.and_then(|v| v.as_bool()).unwrap_or(*default)) } - ParamDef::String { default, .. } => { + ParamKind::String { default, .. } => { ParamValue::String(raw.and_then(|v| v.as_str()).unwrap_or(default).to_string()) } - ParamDef::Curve { default, .. } => { + ParamKind::Curve { default, .. } => { let points = raw .and_then(|v| serde_json::from_value::>(v.clone()).ok()) .unwrap_or_else(|| default.to_vec()); ParamValue::Curve(points) } - ParamDef::Levels { default, .. } => { + ParamKind::Levels { default, .. } => { let arr = raw .and_then(|v| serde_json::from_value::<[f32; 5]>(v.clone()).ok()) .unwrap_or(*default); ParamValue::Levels(arr) } - ParamDef::Enum { default, .. } => { + ParamKind::Enum { default, .. } => { ParamValue::Int(raw.and_then(|v| v.as_f64()).unwrap_or(*default as f64) as i32) } - ParamDef::FloatInput { default, .. } => { + ParamKind::FloatInput { default, .. } => { ParamValue::Float(raw.and_then(|v| v.as_f64()).unwrap_or(*default as f64) as f32) } - ParamDef::Icon { default, .. } => { + ParamKind::Icon { default, .. } => { ParamValue::String(raw.and_then(|v| v.as_str()).unwrap_or(default).to_string()) } - ParamDef::Color { default, .. } => { + ParamKind::Color { default, .. } => { let c = raw .and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok()) .unwrap_or(*default); ParamValue::Color(c) } - ParamDef::Vec2 { default, max, .. } => { + ParamKind::Vec2 { default, max, .. } => { let v = raw .and_then(|v| serde_json::from_value::<[f32; 2]>(v.clone()).ok()) .unwrap_or(*default); ParamValue::Vec2(clamp_magnitude(v, *max)) } - ParamDef::List { + ParamKind::List { item, max_len, default, @@ -299,7 +425,7 @@ impl ParamDef { let obj = entry.as_object(); item.iter() .map(|d| { - let key = d.name(); + let key = d.name; let child = obj.and_then(|o| o.get(key)); (key.to_string(), d.value_from_json(child)) }) @@ -313,23 +439,6 @@ impl ParamDef { } } - pub fn name(&self) -> &'static str { - match self { - ParamDef::Float { name, .. } - | ParamDef::FloatInput { name, .. } - | ParamDef::Int { name, .. } - | ParamDef::Bool { name, .. } - | ParamDef::String { name, .. } - | ParamDef::Curve { name, .. } - | ParamDef::Levels { name, .. } - | ParamDef::Enum { name, .. } - | ParamDef::Icon { name, .. } - | ParamDef::Color { name, .. } - | ParamDef::Vec2 { name, .. } - | ParamDef::List { name, .. } => name, - } - } - /// Coerce an externally-typed scalar (e.g. a value parsed from YAML) /// into the concrete `ParamValue` variant this def expects. Floats /// also accept bare integers, since YAML's `1` and `1.0` are @@ -337,48 +446,48 @@ impl ParamDef { pub fn coerce_portable(&self, v: PortableValue) -> Result { let actual = v.kind_label(); let mismatch = |expected: &'static str| Err(ParamTypeMismatch { expected, actual }); - match self { - ParamDef::Bool { .. } => match v { + match &self.kind { + ParamKind::Bool { .. } => match v { PortableValue::Bool(b) => Ok(ParamValue::Bool(b)), _ => mismatch("bool"), }, - ParamDef::Int { .. } | ParamDef::Enum { .. } => match v { + ParamKind::Int { .. } | ParamKind::Enum { .. } => match v { PortableValue::Int(i) => Ok(ParamValue::Int(i as i32)), _ => mismatch("integer"), }, - ParamDef::Float { .. } | ParamDef::FloatInput { .. } => match v { + ParamKind::Float { .. } | ParamKind::FloatInput { .. } => match v { PortableValue::Float(f) => Ok(ParamValue::Float(f as f32)), PortableValue::Int(i) => Ok(ParamValue::Float(i as f32)), _ => mismatch("number"), }, - ParamDef::String { .. } | ParamDef::Icon { .. } => match v { + ParamKind::String { .. } | ParamKind::Icon { .. } => match v { PortableValue::String(s) => Ok(ParamValue::String(s)), _ => mismatch("string"), }, - ParamDef::Curve { .. } => match v { + ParamKind::Curve { .. } => match v { PortableValue::Curve(c) => Ok(ParamValue::Curve(c)), _ => mismatch("curve (list of [x, y] pairs)"), }, - ParamDef::Levels { .. } => match v { + ParamKind::Levels { .. } => match v { PortableValue::Levels(a) => Ok(ParamValue::Levels(a)), _ => mismatch("levels (5 numbers)"), }, - ParamDef::Color { .. } => match v { + ParamKind::Color { .. } => match v { PortableValue::Color(c) => Ok(ParamValue::Color(c)), _ => mismatch("color (3 numbers)"), }, - ParamDef::Vec2 { max, .. } => match v { + ParamKind::Vec2 { max, .. } => match v { PortableValue::Vec2(a) => Ok(ParamValue::Vec2(clamp_magnitude(a, *max))), _ => mismatch("vec2 (2 numbers)"), }, - ParamDef::List { item, .. } => match v { + ParamKind::List { item, .. } => match v { PortableValue::List(entries) => { let out = entries .into_iter() .map(|entry| { item.iter() .map(|d| { - let key = d.name(); + let key = d.name; let val = match entry.get(key) { Some(pv) => d.coerce_portable(pv.clone())?, None => d.default_value(), @@ -471,38 +580,68 @@ mod tests { use super::*; use std::collections::BTreeMap; + /// Every parameter a user can see carries a label and a description, so no + /// documentation table ships with a blank cell and no properties panel + /// falls back to a raw snake_case field name. Walks the catalogs rather + /// than the registries directly, which is exactly the set that reaches + /// both the UI and the export — including the item schema inside a `List`, + /// where a blank cell is easiest to miss. + #[test] + fn every_param_has_a_label_and_description() { + fn check(owner: &str, defs: &[ParamDef]) { + for d in defs { + assert!( + d.label.is_some_and(|l| !l.is_empty()), + "`{owner}.{}` has no label", + d.name + ); + assert!( + d.description.is_some_and(|s| !s.is_empty()), + "`{owner}.{}` has no description", + d.name + ); + if let ParamKind::List { item, .. } = &d.kind { + check(&format!("{owner}.{}", d.name), item); + } + } + } + + let mut checked = 0usize; + for reg in crate::gpu::filter::FilterPipelineRegistry::new().types() { + check(reg.type_id, reg.params); + checked += reg.params.len(); + } + for reg in crate::gpu::veil::VeilRegistry::new().types() { + check(reg.type_id, reg.params); + checked += reg.params.len(); + } + for reg in crate::gpu::void::VoidRegistry::new().types() { + check(reg.type_id, reg.params); + checked += reg.params.len(); + } + assert!(checked > 0, "no parameters found — the scan found nothing"); + } + // A small list schema used across the List/Vec2/Color tests: one entry is a // named group of `{ offset: Vec2, scale: Float, color: Color }`, with two // default entries exercising per-entry overrides on top of item defaults. const ITEM: &[ParamDef] = &[ - ParamDef::Vec2 { - name: "offset", - max: 64.0, - default: [0.0, 0.0], - }, - ParamDef::Float { - name: "scale", - min: 0.9, - max: 1.1, - default: 1.0, - }, - ParamDef::Color { - name: "color", - default: [1.0, 1.0, 1.0], - }, + ParamDef::vec2("offset", 64.0, [0.0, 0.0]), + ParamDef::float("scale", 0.9, 1.1, 1.0), + ParamDef::color("color", [1.0, 1.0, 1.0]), ]; - const LIST: ParamDef = ParamDef::List { - name: "aberrations", - item: ITEM, - max_len: 4, - default: &[ + const LIST: ParamDef = ParamDef::list( + "aberrations", + ITEM, + 4, + &[ &[("scale", ConstParamValue::Float(1.004))], &[ ("offset", ConstParamValue::Vec2([2.0, 0.0])), ("color", ConstParamValue::Color([0.0, 1.0, 0.0])), ], ], - }; + ); /// Regression: `ParamValue::Int(n)` must round-trip through JSON without /// degrading to `ParamValue::Float`. The bug: Rough Watercolor's shape @@ -632,10 +771,7 @@ mod tests { /// including a nested List. #[test] fn portable_coercion_round_trips_new_kinds() { - let color_def = ParamDef::Color { - name: "c", - default: [0.0; 3], - }; + let color_def = ParamDef::color("c", [0.0; 3]); let color = ParamValue::Color([0.5, 0.25, 0.75]); let back = color_def .coerce_portable(PortableValue::from_param(&color)) diff --git a/crates/darkly/src/gpu/preview.rs b/crates/darkly/src/gpu/preview.rs index 4569fff3..68b20ede 100644 --- a/crates/darkly/src/gpu/preview.rs +++ b/crates/darkly/src/gpu/preview.rs @@ -1,13 +1,23 @@ -//! Effect-agnostic picker-preview primitives shared by the veil and void -//! preview renderers. +//! How a previewable registry entry's preview moves, and the primitives every +//! preview is rendered through. //! -//! Both renderers produce small, looping thumbnail frames of an effect for the -//! "Add …" pickers, read back asynchronously by the engine. The GPU plumbing -//! differs — veils need a ping-pong texture *pair* (they read one, write the -//! other), voids render straight into a single destination — so each renderer -//! owns its own textures. What's genuinely common lives here: the thumbnail -//! sizing constants and the aspect-fit helper that turns a canvas size into a -//! preview size. +//! A preview is a short sequence of thumbnail frames of one effect, shown in +//! the editor's pickers and written to disk as documentation. An entry declares +//! *that* it has one — a [`PreviewAnim`] on its registration — and *how it +//! moves* as code: `Veil::preview_at` / `Void::preview_at` for the per-instance +//! kinds, a `fn(f32) -> Vec` on the registration for filters, whose +//! effect object is shared and holds no parameters. +//! +//! **The convention every `preview_at` body follows**: take `t`, set fields, +//! sync the GPU resources those fields feed — in that order, once. Repetition +//! across bodies is extracted into plain helpers here ([`swing`], +//! [`swing_signed`]) that a body calls and stays in control of. +//! +//! **Absolute, not incremental.** `preview_at(0.5)` puts the instance in the +//! same state whether it follows `preview_at(0.4)` or nothing at all. That is +//! what lets a sequence be rebuilt, resumed, or sampled out of order without a +//! replay, and `preview_at_is_absolute` in `tests/picker_preview.rs` is what +//! holds every entry to it. /// Longest preview-thumbnail edge, in pixels. The source is fit into this box /// preserving its aspect ratio, so previews aren't distorted regardless of the @@ -18,8 +28,345 @@ pub const PREVIEW_MAX_DIM: u32 = 256; pub const ANIMATED_FRAMES: u32 = 48; /// Capture / playback rate, in frames per second. pub const PREVIEW_FPS: u32 = 24; -/// Per-frame delta time (seconds) fed to animated effects' `update_time`. -pub const PREVIEW_DT: f32 = 1.0 / PREVIEW_FPS as f32; +/// Seconds of an effect's own clock one preview sequence covers. A veil whose +/// motion is temporal rather than parametric maps `t` onto this span. +pub const PREVIEW_SECONDS: f32 = 2.0; + +/// Pixel format every preview is rendered and read back in. Matches the +/// compositor's accumulator and the layer-texture atlas, so a preview frame +/// carries the same kind of pixels the canvas does. +pub const PREVIEW_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +/// Which of an entry's two previews is wanted. +/// +/// A picker shows every card at once, so it asks for [`Still`](Self::Still) +/// — seventeen cards moving at once is noise, and seventeen sequences is +/// forty-eight times the work. [`Animated`](Self::Animated) is what a card asks +/// for when the pointer is over it, and it is the only thing that ever costs a +/// full sequence. +/// +/// Both are the same motion sampled differently, which is what keeps the +/// hand-off invisible: `preview_at` is absolute, so a still is literally the +/// animation's frame at [`PreviewAnim::still_at`], rendered without rendering +/// the rest. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +pub enum PreviewVariant { + /// One frame — the moment of the motion that stands for it. + Still, + /// The whole sequence. + Animated, +} + +/// That an entry has a preview, how it plays back, and which moment of it +/// stands for the whole. +/// +/// The motion itself is a method, not data — this says only how long it runs, +/// how it ends, and where to freeze it. `loops` is declared rather than derived +/// for exactly that reason: the only thing that knows whether the last frame +/// hands back to the first is the body that wrote the motion. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PreviewAnim { + /// Images in the sequence. `1` is a still at the entry's own parameters. + pub frames: u32, + /// Playback rate. + pub fps: u32, + /// Whether the last frame hands back to the first without a visible jump. + pub loops: bool, + /// Where on the timeline the [`PreviewVariant::Still`] is taken — the one + /// frame a picker card shows before anyone hovers it, and the poster frame + /// of the documentation asset. + /// + /// The default `0.5` is the peak of [`swing`], which is where a sweep is + /// furthest from its resting value and so most legible as a single image. + /// An entry whose motion peaks elsewhere overrides it with + /// [`with_still_at`](Self::with_still_at) — a sweep resting at `t = 0` would + /// otherwise pick the frame that looks like no effect at all. + pub still_at: f32, +} + +impl PreviewAnim { + /// The conventional animated preview: [`ANIMATED_FRAMES`] at + /// [`PREVIEW_FPS`], ending where it began. What a parameter sweep that + /// returns to its resting value declares. + pub const LOOPING: Self = Self { + frames: ANIMATED_FRAMES, + fps: PREVIEW_FPS, + loops: true, + still_at: 0.5, + }; + + /// The same length, for motion that runs one way and does not return — a + /// clock integrated forward, a counter that only counts up. + pub const ONE_WAY: Self = Self { + frames: ANIMATED_FRAMES, + fps: PREVIEW_FPS, + loops: false, + still_at: 0.5, + }; + + /// A single frame at the entry's own parameters, for an entry with nothing + /// to sweep. Both variants render the same image, so hovering such a card + /// changes nothing — which is the honest thing for an effect that has one + /// state. + pub const STILL: Self = Self { + frames: 1, + fps: PREVIEW_FPS, + loops: true, + still_at: 0.0, + }; + + /// Take the still somewhere other than the middle. For a sweep that runs + /// signed — out one way, back, out the other — where the middle is the + /// resting value and the quarter point is the extreme. + pub const fn with_still_at(self, still_at: f32) -> Self { + Self { still_at, ..self } + } + + /// The frame index the still is taken at, clamped into the sequence. + pub fn still_frame(&self) -> u32 { + let frames = self.frames.max(1); + ((self.still_at * frames as f32) as u32).min(frames - 1) + } +} + +/// Normalized timeline position of frame `i` of `frames`. Frame `frames` itself +/// is `t == 1.0` — the frame *after* the last, which is where a looping +/// sequence hands back to frame 0. +pub fn frame_t(i: u32, frames: u32) -> f32 { + i as f32 / frames.max(1) as f32 +} + +/// A smooth out-and-back sweep: `0` at `t = 0`, `1` at `t = 0.5`, back to `0` +/// at `t = 1`, at rest at both ends. +/// +/// The shape a control sweep wants — the ends match a render at the resting +/// value, so the sequence closes and the frames either side of the wrap agree. +pub fn swing(t: f32) -> f32 { + 0.5 - 0.5 * (t * std::f32::consts::TAU).cos() +} + +/// A signed out-and-back sweep: `0 → 1 → 0 → -1 → 0`, peaking at `t = 0.25` and +/// troughing at `t = 0.75`. For a control whose two directions read differently +/// and are both worth showing in one pass. +pub fn swing_signed(t: f32) -> f32 { + (t * std::f32::consts::TAU).sin() +} + +/// The normalized coordinate at the centre of pixel `(x, y)` in a +/// `width × height` image. +/// +/// Each axis is divided by its own extent, so a field described this way is one +/// continuous image evaluated at whatever resolution — and whatever aspect +/// ratio — is asked for. +pub fn pixel_centre(x: u32, y: u32, width: u32, height: u32) -> (f32, f32) { + ( + (x as f32 + 0.5) / width.max(1) as f32, + (y as f32 + 0.5) / height.max(1) as f32, + ) +} + +/// Rasterize a field described in normalized coordinates into a `width × height` +/// RGBA8 buffer, sampling at pixel centres. +/// +/// The framing shared by every generated preview image: the documentation +/// subject, the blend-mode source layer, and the backdrop a brush preview stroke +/// is staged over. Describing the image as a function of position rather than of +/// pixel indices is what makes a render at one size a genuine resample of the +/// same picture at another. +pub fn field_rgba(width: u32, height: u32, field: impl Fn(f32, f32) -> [f32; 4]) -> Vec { + let mut out = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + for x in 0..width { + let (u, v) = pixel_centre(x, y, width, height); + for ch in field(u, v) { + out.push((ch.clamp(0.0, 1.0) * 255.0).round() as u8); + } + } + } + out +} + +// --------------------------------------------------------------------------- +// Staging a preview for an entry that transports content rather than making it +// --------------------------------------------------------------------------- + +/// How a preview must be staged for a node whose output depends on canvas +/// content it did not write. +/// +/// Such a node transports the destination rather than writing to it, so over a +/// flat preview backdrop it produces that same flat backdrop and renders +/// nothing. Both halves answer that one problem — a still dab has no motion for +/// a displacement to reveal at all, so it shows the glyph, while a stroke gets +/// something to transport — which is why a node declares them together or not +/// at all. +/// +/// Declared by the node, because whether a node reads what is already there is +/// a fact about the node. A brush inherits it from whichever of its nodes +/// declares one. +#[derive(Clone, Copy, Debug, serde::Serialize)] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +pub struct PreviewStaging { + /// Iconify glyph shown in the dab slot, where a single stationary sample + /// has no motion to make the effect visible at all. + pub icon: &'static str, + /// Field painted under the stroke preview, giving the node something to + /// transport. + pub backdrop: PreviewBackdrop, +} + +/// What is painted under a preview stroke, as a field in normalized coordinates +/// sampled at pixel centres — the same framing [`field_rgba`] gives the +/// documentation subject, for the same reason. +/// +/// [`Stripes`](Self::Stripes) is the only staging that exists; +/// [`Flat`](Self::Flat) means "none". A second field — a checkerboard, a +/// gradient — slots in beside them without any consumer changing, which is what +/// this is an enum rather than a bool for. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +pub enum PreviewBackdrop { + /// One theme background colour. What every brush that deposits pigment + /// wants, and what the render path expresses as a plain clear. + Flat, + /// Alternating vertical bands between two mid-tones drawn from the theme, + /// so a displacement, a smear or a blur has a boundary to act on wherever + /// the stroke passes — following Krita's + /// `KisPresetLivePreviewView::paintBackground` + /// (`libs/ui/widgets/kis_preset_live_preview_view.cpp:120-154`), which + /// stripes the background for its `colorsmudge`, `deformbrush` and `filter` + /// engines for exactly this reason. + /// + /// A multi-octave noise field was built and measured against this and + /// **rejected**. On the numbers it wins — it responds to a displacement + /// everywhere rather than only where one crosses a band edge, which at the + /// few-pixel displacements a liquify stroke produces is the difference + /// between a handful of the dab's pixels changing and half of them. On the + /// rendered thumbnails it loses, and the thumbnails are the artifact. Two + /// reasons, both about legibility rather than about pixel counts: + /// + /// - **A copy of a homogeneous field is that field.** Clone transports + /// pixels from a fixed offset away; over noise the copied region is + /// statistically identical to what it replaced, so a third of the dab's + /// pixels differ and *nothing reads*. Stripes have a period to be out of + /// phase with, which is what makes the copied region visibly misaligned. + /// - **Every operator's mark competes with the field's own texture.** Two + /// flat tones state the boundary and nothing else, so what the stroke did + /// to that boundary is the only structure in the frame. + /// + /// A single period does leave blur reading poorly — Krita's own comment + /// concedes its stripes "may or may not show things depending on the + /// filter…but it is better than nothing". `blur.strength`'s + /// `preview_value` is the answer to that, and it is a smaller intervention + /// than replacing the field. + Stripes, +} + +impl PreviewBackdrop { + /// Bands across the render width. Normalized, so the period does not depend + /// on the render size, and the crop the framer applies afterwards cannot + /// change it. Krita's ratio is twenty bands across a 320 px widget. + const BANDS: f32 = 16.0; + + /// Color at normalized position `(u, v)` for a theme running from `bg` to + /// `fg`. Both stripe tones are held between the poles so a brush that *does* + /// deposit still contrasts against either band — Krita paints `80,80,80` and + /// `140,140,140` under a stroke forced to white, and these are the same two + /// tones expressed in whichever direction the theme runs. + pub fn sample(self, u: f32, _v: f32, fg: [f32; 4], bg: [f32; 4]) -> [f32; 4] { + let mix = |t: f32| { + let mut c = bg; + for ch in 0..3 { + c[ch] = bg[ch] + (fg[ch] - bg[ch]) * t; + } + c + }; + match self { + Self::Flat => bg, + Self::Stripes => { + let band = (u * Self::BANDS).floor() as i32; + mix(if band.rem_euclid(2) == 0 { 0.28 } else { 0.55 }) + } + } + } + + /// Write this backdrop into `view` / `texture`, which must be the same + /// `Rgba8Unorm` render target. + /// + /// [`Flat`](Self::Flat) is a plain clear — the fast path every depositing + /// brush and every dab preview takes; [`Stripes`](Self::Stripes) builds the + /// field on the CPU and uploads it. The queue write is ordered before the + /// submission that carries `encoder`, so either variant is in place by the + /// time anything reads the target. + pub fn fill( + self, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + view: &wgpu::TextureView, + texture: &wgpu::Texture, + (width, height): (u32, u32), + fg: [f32; 4], + bg: [f32; 4], + ) { + match self { + Self::Flat => { + let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("preview-backdrop-clear"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: bg[0] as f64, + g: bg[1] as f64, + b: bg[2] as f64, + a: bg[3] as f64, + }), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + } + Self::Stripes => { + let pixels = field_rgba(width, height, |u, v| self.sample(u, v, fg, bg)); + queue.write_texture( + texture.as_image_copy(), + &pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + } + } + } + + /// Offset, in normalized canvas units, at which a copy of this backdrop + /// reads as *distinct from* the backdrop — what a node that transports + /// pixels from elsewhere (clone) needs its source anchor set to. + /// + /// Owned here because only the field that defines the period can say what + /// offset escapes it. [`Stripes`](Self::Stripes) repeats every *two* bands — + /// one of each tone — so the offset is one band, which is half that period + /// and lands the copied bands exactly out of phase with the ones underneath. + /// A vertical component would be useless (the field is constant in `v`), and + /// an offset of a whole period would be the identity. + pub fn source_offset(self) -> [f32; 2] { + match self { + Self::Flat => [0.0, 0.0], + Self::Stripes => [1.0 / Self::BANDS, 0.0], + } + } +} /// Fit a `w × h` source into a box of [`PREVIEW_MAX_DIM`] on its longest edge, /// preserving aspect ratio. Sources already within the box are kept as-is. @@ -35,3 +382,432 @@ pub fn fit_preview_dims(w: u32, h: u32) -> (u32, u32) { let ph = ((h as f32 * scale).round() as u32).max(1); (pw, ph) } + +// --------------------------------------------------------------------------- +// The target every preview is rendered into +// --------------------------------------------------------------------------- + +/// Preview-sized texture pair. View 0 is what the effect reads — the downscaled +/// source, or a cleared texture for an effect that generates its own content; +/// view 1 is what it writes and what the capture reads back. +struct PreviewTextures { + width: u32, + height: u32, + textures: [wgpu::Texture; 2], + views: [wgpu::TextureView; 2], +} + +/// Two preview-sized textures, a sampler, and the soft downscale that fills the +/// source — everything a preview needs that is not the effect itself. +/// +/// One instance is reusable across entries and across consumers: it lazily +/// allocates its sampler and pipeline and reallocates its textures only when +/// the preview dimensions change. Which *subject* it holds is an input, not a +/// fork — the editor loads its own composite, the documentation renderer loads +/// a fixed synthetic field, and nothing downstream can tell. +pub struct PreviewTarget { + textures: Option, + sampler: Option, + /// Soft multi-tap downscale used to copy the (often much larger) source + /// into the preview-sized input texture without hard aliasing. + downscale: Option, +} + +impl Default for PreviewTarget { + fn default() -> Self { + Self::new() + } +} + +impl PreviewTarget { + pub fn new() -> Self { + PreviewTarget { + textures: None, + sampler: None, + downscale: None, + } + } + + /// Aspect-fit `src_w × src_h` into the preview box and downscale `source` + /// into the source texture, reallocating if the dimensions changed. For + /// mechanisms that read a source. + pub fn load_source( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + source: &wgpu::TextureView, + src_w: u32, + src_h: u32, + ) { + self.ensure(device, src_w, src_h); + let textures = self.textures.as_ref().expect("ensure allocates"); + let downscale = self.downscale.as_ref().expect("ensure allocates"); + let source_bg = super::effect::create_blit_bind_group( + device, + &downscale.bind_group_layout, + source, + self.sampler.as_ref().expect("ensure allocates"), + "preview-source-bg", + ); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("preview-load-source"), + }); + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("preview-downscale"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &textures.views[0], + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + rpass.set_pipeline(&downscale.pipeline); + rpass.set_bind_group(0, &source_bg, &[]); + rpass.draw(0..3, 0..1); + } + queue.submit([encoder.finish()]); + } + + /// Aspect-fit `src_w × src_h` and clear the source texture. For mechanisms + /// that generate their own content and never sample view 0 — the clear is + /// what keeps it a defined value rather than whatever the previous entry + /// left there. + pub fn clear_source( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + src_w: u32, + src_h: u32, + ) { + self.ensure(device, src_w, src_h); + let textures = self.textures.as_ref().expect("ensure allocates"); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("preview-clear-source"), + }); + super::clear_view_transparent(&mut encoder, &textures.views[0], "preview-clear-source"); + queue.submit([encoder.finish()]); + } + + /// The preview dimensions of the currently loaded target, or `(0, 0)` if + /// nothing is loaded. + pub fn size(&self) -> (u32, u32) { + self.textures + .as_ref() + .map(|t| (t.width, t.height)) + .unwrap_or((0, 0)) + } + + /// Both views, in the ping-pong order a [`Veil`](super::veil::Veil)'s + /// `create_cache` expects: it reads index 0 and is encoded against index 1. + pub fn views(&self) -> &[wgpu::TextureView; 2] { + &self.textures.as_ref().expect("load or clear first").views + } + + pub fn source_view(&self) -> &wgpu::TextureView { + &self.views()[0] + } + + pub fn output_view(&self) -> &wgpu::TextureView { + &self.views()[1] + } + + /// The texture holding the most recently encoded frame — readback source. + pub fn output_texture(&self) -> &wgpu::Texture { + &self + .textures + .as_ref() + .expect("load or clear first") + .textures[1] + } + + /// The downscaled source a mechanism reads. Test-only: nothing in the + /// engine or the binary reads the source back, and `AGENTS.md` + /// §No Blocking GPU Readbacks keeps readback surface behind the gate. + #[cfg(any(test, feature = "testing"))] + pub fn source_texture(&self) -> &wgpu::Texture { + &self + .textures + .as_ref() + .expect("load or clear first") + .textures[0] + } + + pub fn sampler(&self) -> &wgpu::Sampler { + self.sampler.as_ref().expect("load or clear first") + } + + fn ensure(&mut self, device: &wgpu::Device, src_w: u32, src_h: u32) { + if self.sampler.is_none() { + self.sampler = Some(device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("preview-sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + })); + } + if self.downscale.is_none() { + self.downscale = Some(super::effect::create_downscale_pipeline( + device, + PREVIEW_FORMAT, + "preview-downscale", + )); + } + let (pw, ph) = fit_preview_dims(src_w, src_h); + let realloc = match &self.textures { + Some(t) => t.width != pw || t.height != ph, + None => true, + }; + if realloc { + self.textures = Some(make_textures(device, pw, ph)); + } + } +} + +fn make_textures(device: &wgpu::Device, width: u32, height: u32) -> PreviewTextures { + let usage = wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST; + let (t0, v0) = super::create_texture_with_view( + device, + width, + height, + PREVIEW_FORMAT, + "preview-source", + usage, + ); + let (t1, v1) = super::create_texture_with_view( + device, + width, + height, + PREVIEW_FORMAT, + "preview-output", + usage, + ); + PreviewTextures { + width, + height, + textures: [t0, t1], + views: [v0, v1], + } +} + +// --------------------------------------------------------------------------- +// How a catalog answers "is this previewable, and how do I run it" +// --------------------------------------------------------------------------- + +/// The registries a mechanism may need to open an entry, borrowed from whoever +/// owns them: the compositor in the engine, `docs_render::Gpu` in the binary. +/// One field per previewable catalog. +/// +/// This is the one hand-written per-catalog list in the design, and it cannot +/// be generated: a session must be opened against a *concretely typed* +/// registry, so the alternatives are a downcast through `Any` — which +/// `AGENTS.md` §Type-owned dispatch forbids — or a named field. **Growth rule:** +/// a new previewable catalog costs one field here and one line each in +/// `Compositor::preview_registries` and `docs_render::Gpu`; a new +/// non-previewable catalog costs nothing. +pub struct PreviewRegistries<'a> { + pub veils: &'a mut super::veil::VeilRegistry, + pub voids: &'a mut super::void::VoidRegistry, + pub filters: &'a mut super::filter::FilterPipelineRegistry, +} + +/// Everything a catalog knows statically about one previewable entry. +pub struct PreviewEntry { + /// The registry's own `'static` id, which keys long-lived state without + /// leaking a `String` per preview. + pub type_id: &'static str, + pub anim: PreviewAnim, +} + +/// How one catalog answers "is this previewable, and how do I open it". One +/// implementation per previewable catalog, in that catalog's own module. +pub trait PreviewMechanism { + /// `None` for an id this catalog does not know or one that declares no + /// preview — the single question both consumers ask before doing any work. + /// Answerable without a device. + fn resolve(&self, type_id: &str) -> Option; + + /// Whether this kind reads the target's source texture. Voids generate + /// their own content and answer `false`, which is what tells the caller to + /// clear the source rather than load one. + fn reads_source(&self) -> bool; + + /// Open a session for `type_id` against the registries it needs. The + /// session owns the concrete effect instance for the rest of the sequence, + /// which is what lets a mechanism drive its own instance without anyone + /// recovering a concrete type from a trait object. `None` on an unknown + /// `type_id` — an unknown entry is a no-op, never a panic. + fn open<'a>( + &self, + regs: PreviewRegistries<'a>, + type_id: &str, + ) -> Option>; +} + +/// One open preview, mid-sequence. Holds its catalog's concrete instance and +/// whatever registry access another frame needs. +pub trait PreviewSession { + /// Bring the instance to the state its preview shows at `t`, building it on + /// the first call and rebuilding whatever the new state invalidated. + /// + /// Absolute, like the `preview_at` it forwards to: the frame this produces + /// depends on `t` and nothing else, which is why a sequence can be dropped + /// and re-opened mid-run without replaying anything. + fn set_t(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, target: &PreviewTarget, t: f32); + + /// Encode this frame: read `target.source_view()`, write + /// `target.output_view()`. + fn encode( + &mut self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + target: &PreviewTarget, + ); +} + +// --------------------------------------------------------------------------- +// The sequence, and the driver over it +// --------------------------------------------------------------------------- + +/// One preview being generated, frame by frame. Owns the session, so it also +/// owns the effect instance and the registry borrow for its whole life. +/// +/// A *steppable* object rather than a closed loop, because the two consumers +/// want the same frames at different rates: the documentation binary wants all +/// of them now, the browser a bounded number per tick. +pub struct PreviewSequence<'a> { + session: Box, + anim: PreviewAnim, + variant: PreviewVariant, + cursor: u32, +} + +impl<'a> PreviewSequence<'a> { + /// `None` when `mech` does not know `type_id` or it declares no preview — + /// how an unknown entry becomes a no-op rather than a panic. Does not touch + /// the target: loading or clearing the source is the caller's, because only + /// the caller knows what the source *is*. + pub fn open( + mech: &dyn PreviewMechanism, + regs: PreviewRegistries<'a>, + type_id: &str, + variant: PreviewVariant, + ) -> Option { + let entry = mech.resolve(type_id)?; + Some(PreviewSequence { + session: mech.open(regs, type_id)?, + anim: entry.anim, + variant, + cursor: 0, + }) + } + + pub fn anim(&self) -> PreviewAnim { + self.anim + } + + /// Frames this sequence will produce: the whole animation, or the one frame + /// that stands for it. + pub fn total(&self) -> u32 { + match self.variant { + PreviewVariant::Still => 1, + PreviewVariant::Animated => self.anim.frames.max(1), + } + } + + /// Where on the timeline frame `i` of this sequence sits. The only place the + /// two variants differ, and the reason they cannot drift apart: a still is + /// the animation sampled at one point, not a separate rendering of it. + fn t_at(&self, i: u32) -> f32 { + match self.variant { + PreviewVariant::Still => self.anim.still_at, + PreviewVariant::Animated => frame_t(i, self.total()), + } + } + + pub fn is_done(&self) -> bool { + self.cursor >= self.total() + } + + /// Resume at frame `cursor`. Free, and that is the point: `set_t` is + /// absolute, so a sequence re-opened mid-run reaches the same state the + /// uninterrupted one would have without replaying a single frame. + pub fn seek(&mut self, cursor: u32) { + self.cursor = cursor; + } + + /// Encode exactly one frame and hand the still-open encoder to `capture`, + /// which owns finishing and submitting it. That is what lets the engine + /// append a readback request into the *same* submission, so a frame's + /// readback captures it before the next frame overwrites the texture. + /// Answers `false` when the sequence was already complete. + pub fn step( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + target: &PreviewTarget, + capture: impl FnOnce(wgpu::CommandEncoder, &wgpu::Texture, u32, u32), + ) -> bool { + let (i, total) = (self.cursor, self.total()); + if i >= total { + return false; + } + self.session.set_t(device, queue, target, self.t_at(i)); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("preview-frame"), + }); + self.session.encode(device, &mut encoder, target); + capture(encoder, target.output_texture(), i, total); + self.cursor += 1; + true + } +} + +/// Run a sequence to completion. The blocking consumer's whole loop. +pub fn drive( + seq: &mut PreviewSequence, + device: &wgpu::Device, + queue: &wgpu::Queue, + target: &PreviewTarget, + mut capture: impl FnMut(wgpu::CommandEncoder, &wgpu::Texture, u32, u32), +) { + while seq.step(device, queue, target, &mut capture) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Both sweeps rest at their ends and reach their extremes where the + /// bodies that call them expect — the property every `preview_at` written + /// against them relies on for its sequence to close. + #[test] + fn the_sweeps_rest_at_their_ends_and_peak_where_they_say() { + let near = |a: f32, b: f32| (a - b).abs() < 1e-6; + assert!(near(swing(0.0), 0.0)); + assert!(near(swing(0.5), 1.0)); + assert!(near(swing(1.0), 0.0)); + assert!(near(swing_signed(0.0), 0.0)); + assert!(near(swing_signed(0.25), 1.0)); + assert!(near(swing_signed(0.75), -1.0)); + assert!(near(swing_signed(1.0), 0.0)); + + // Monotone across the rising half — without this a sweep could step + // and still satisfy every "the frames differ" assertion downstream. + for i in 0..(ANIMATED_FRAMES / 2) { + let (a, b) = ( + swing(frame_t(i, ANIMATED_FRAMES)), + swing(frame_t(i + 1, ANIMATED_FRAMES)), + ); + assert!(a < b, "the sweep fell on its rising half at frame {i}"); + } + } +} diff --git a/crates/darkly/src/gpu/veil.rs b/crates/darkly/src/gpu/veil.rs index 1e02cf15..44f6480e 100644 --- a/crates/darkly/src/gpu/veil.rs +++ b/crates/darkly/src/gpu/veil.rs @@ -3,6 +3,11 @@ use std::sync::Arc; pub use super::effect::{EffectCache, EffectPipeline}; pub use super::params::{ParamDef, ParamValue}; +use super::preview::{ + PreviewAnim, PreviewEntry, PreviewMechanism, PreviewRegistries, PreviewSession, PreviewTarget, + PREVIEW_FORMAT, +}; +use crate::catalog::{Catalog, CatalogEntry}; /// Viewport-level post-processing effect ("veil"). /// Veils run on the fully-presented image at screen resolution, @@ -25,8 +30,12 @@ pub trait Veil: std::fmt::Debug { /// from and write to these at whatever resolution the chain provides. /// When `rendering.veil_scale` is below 1.0 the chain passes smaller /// textures automatically; veils never need to know about the distinction. + /// + /// Takes `&mut self` so a veil whose uniform struct folds in something it + /// is only handed here — the render resolution, a decoded texture's aspect + /// — can keep it, and rewrite that struct later from state alone. fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -58,6 +67,25 @@ pub trait Veil: std::fmt::Debug { /// Default is a no-op for non-animated veils. fn update_time(&mut self, _queue: &wgpu::Queue, _cache: &EffectCache, _dt: f32) {} + /// Put this instance into the state its preview shows at normalized time + /// `t ∈ [0, 1]`, and sync whatever GPU resources that state feeds. + /// + /// Absolute, not incremental: `preview_at(0.5)` produces the same state + /// whether it follows `preview_at(0.4)` or nothing at all. + /// + /// Answers whether `cache` still describes this instance. A veil whose + /// cache *shape* is a function of its parameters — pixelate's aux chain is + /// the one in the tree — sets its fields and answers `false`, and the + /// caller rebuilds through [`create_cache`](Self::create_cache) before + /// encoding. The default is a no-op answering `true`, which renders a still + /// at the instance's own parameters. + /// + /// See [`super::preview`] for the shape every body follows and the sweeps + /// they share. + fn preview_at(&mut self, _queue: &wgpu::Queue, _cache: &EffectCache, _t: f32) -> bool { + true + } + /// Encode all render passes into the command encoder. /// The veil reads from `ping_pong[src_idx]` (via pre-built bind groups) /// and must write its final output to `dst_view`. @@ -79,21 +107,52 @@ pub struct VeilRegistration { /// include the terms users would search for. pub description: &'static str, pub params: &'static [ParamDef], + /// How long this veil's preview runs, or `None` for a veil with nothing + /// worth showing. Declaring an animation is what makes a veil previewable — + /// the two facts are one. What the preview *does* over that span is + /// [`Veil::preview_at`]. + pub preview: Option, pub create_pipeline: fn(&wgpu::Device, wgpu::TextureFormat) -> EffectPipeline, pub from_params: fn(&[ParamValue], Arc) -> Box, } +/// Id of the catalog this registry projects into. +pub const CATALOG_ID: &str = "veils"; + +impl VeilRegistration { + pub fn catalog_entry(&self) -> CatalogEntry { + // Veils render a live preview in their picker, so no icon. + CatalogEntry::new(self.type_id, self.display_name) + .with_description(self.description) + .with_params(self.params) + .with_supports_preview(self.preview.is_some()) + } +} + +/// The veil catalog — every registered veil, sorted by `type_id`. +pub fn catalog() -> Catalog { + Catalog::new( + CATALOG_ID, + "Veils", + VeilRegistry::new() + .types() + .into_iter() + .map(VeilRegistration::catalog_entry) + .collect(), + ) + .with_description("Non-destructive effects stacked above a layer's pixels.") +} + /// Auto-discovered veil registry with lazy pipeline caching. pub struct VeilRegistry { entries: HashMap<&'static str, RegistryEntry>, } struct RegistryEntry { - display_name: &'static str, - description: &'static str, - create_pipeline: fn(&wgpu::Device, wgpu::TextureFormat) -> EffectPipeline, - params: &'static [ParamDef], - from_params: fn(&[ParamValue], Arc) -> Box, + /// The full registration this entry was built from. All metadata accessors + /// read straight off this, so a new `VeilRegistration` field is exposed + /// without widening any tuple or touching the registry. + reg: VeilRegistration, cached_pipeline: Option>, } @@ -110,11 +169,7 @@ impl VeilRegistry { entries.insert( reg.type_id, RegistryEntry { - display_name: reg.display_name, - description: reg.description, - create_pipeline: reg.create_pipeline, - params: reg.params, - from_params: reg.from_params, + reg, cached_pipeline: None, }, ); @@ -122,29 +177,21 @@ impl VeilRegistry { VeilRegistry { entries } } - /// Return all registered veil type IDs with display name, description, - /// and parameter definitions. - #[allow(clippy::type_complexity)] - pub fn types( - &self, - ) -> Vec<( - &'static str, - &'static str, - &'static str, - &'static [ParamDef], - )> { - let mut types: Vec<_> = self - .entries - .iter() - .map(|(&id, e)| (id, e.display_name, e.description, e.params)) - .collect(); - types.sort_by_key(|(id, _, _, _)| *id); + /// Return every registered veil's full [`VeilRegistration`], sorted by + /// `type_id` for deterministic UI ordering. Callers read whatever fields + /// they need off the registration — a new field is free here. + pub fn types(&self) -> Vec<&VeilRegistration> { + let mut types: Vec<&VeilRegistration> = self.entries.values().map(|e| &e.reg).collect(); + types.sort_by_key(|reg| reg.type_id); types } /// Get the static parameter definitions for a veil type. pub fn param_defs(&self, type_id: &str) -> &'static [ParamDef] { - self.entries.get(type_id).map(|e| e.params).unwrap_or(&[]) + self.entries + .get(type_id) + .map(|e| e.reg.params) + .unwrap_or(&[]) } /// Resolve a runtime `&str` type id to the registry's `&'static str` key, @@ -162,12 +209,18 @@ impl VeilRegistry { self.entries.contains_key(type_id) } + /// How long a veil type's preview runs. `None` for an unknown type or one + /// that declares no preview. + pub fn preview(&self, type_id: &str) -> Option { + self.entries.get(type_id)?.reg.preview + } + /// Get the human-friendly display name for a veil type, falling back to /// the `type_id` literal when the type is unknown. pub fn display_name(&self, type_id: &str) -> &'static str { self.entries .get(type_id) - .map(|e| e.display_name) + .map(|e| e.reg.display_name) .unwrap_or("") } @@ -184,7 +237,7 @@ impl VeilRegistry { .unwrap_or_else(|| panic!("Unknown veil type: {type_id}")); entry .cached_pipeline - .get_or_insert_with(|| Arc::new((entry.create_pipeline)(device, format))) + .get_or_insert_with(|| Arc::new((entry.reg.create_pipeline)(device, format))) .clone() } @@ -202,8 +255,119 @@ impl VeilRegistry { .unwrap_or_else(|| panic!("Unknown veil type: {type_id}")); let pipeline = entry .cached_pipeline - .get_or_insert_with(|| Arc::new((entry.create_pipeline)(device, format))) + .get_or_insert_with(|| Arc::new((entry.reg.create_pipeline)(device, format))) .clone(); - (entry.from_params)(params, pipeline) + (entry.reg.from_params)(params, pipeline) + } +} + +// --------------------------------------------------------------------------- +// Preview mechanism +// --------------------------------------------------------------------------- + +/// This catalog's answer to [`PreviewMechanism`]. Exported by name so +/// `build.rs` finds it while scanning this module's source and emits a +/// `preview_mechanisms()` row for `veils`; a catalog with nothing to export is +/// simply silent. +pub fn preview_mechanism() -> &'static dyn PreviewMechanism { + &VeilMechanism +} + +struct VeilMechanism; + +impl PreviewMechanism for VeilMechanism { + fn resolve(&self, type_id: &str) -> Option { + let registry = VeilRegistry::new(); + Some(PreviewEntry { + type_id: registry.static_type_id(type_id)?, + anim: registry.preview(type_id)?, + }) + } + + fn reads_source(&self) -> bool { + true + } + + fn open<'a>( + &self, + regs: PreviewRegistries<'a>, + type_id: &str, + ) -> Option> { + let type_id = regs.veils.static_type_id(type_id)?; + Some(Box::new(VeilSession { + registry: regs.veils, + type_id, + instance: None, + })) + } +} + +/// One open veil preview: the instance and the cache it was built against. +/// +/// Rebuilding is a normal outcome rather than a failure mode — [`Veil::preview_at`] +/// answers `false` when the state it just entered no longer fits the cache, and +/// a rebuilt instance at `t` is fully described by `t`. +struct VeilSession<'a> { + registry: &'a mut VeilRegistry, + type_id: &'static str, + instance: Option<(Box, EffectCache)>, +} + +impl<'a> VeilSession<'a> { + fn build(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, target: &PreviewTarget) { + let defaults: Vec = self + .registry + .param_defs(self.type_id) + .iter() + .map(ParamDef::default_value) + .collect(); + let mut veil = self + .registry + .create_veil(self.type_id, &defaults, device, PREVIEW_FORMAT); + let cache = build_cache(&mut *veil, device, queue, target); + self.instance = Some((veil, cache)); + } +} + +/// The one place a veil's cache is built against a preview target, so the two +/// callers — the first build and a `preview_at` that invalidated its cache — +/// cannot disagree about what it is built from. +fn build_cache( + veil: &mut dyn Veil, + device: &wgpu::Device, + queue: &wgpu::Queue, + target: &PreviewTarget, +) -> EffectCache { + let (w, h) = target.size(); + veil.create_cache(device, queue, target.views(), target.sampler(), w, h) +} + +impl<'a> PreviewSession for VeilSession<'a> { + fn set_t( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + target: &PreviewTarget, + t: f32, + ) { + if self.instance.is_none() { + self.build(device, queue, target); + } + let (veil, cache) = self.instance.as_mut().expect("built above"); + if !veil.preview_at(queue, cache, t) { + *cache = build_cache(&mut **veil, device, queue, target); + } + } + + fn encode( + &mut self, + _device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + target: &PreviewTarget, + ) { + let Some((veil, cache)) = self.instance.as_ref() else { + return; + }; + veil.encode(encoder, cache, 0, target.output_view()); } } diff --git a/crates/darkly/src/gpu/veil_chain.rs b/crates/darkly/src/gpu/veil_chain.rs index e020fb27..b82d23ea 100644 --- a/crates/darkly/src/gpu/veil_chain.rs +++ b/crates/darkly/src/gpu/veil_chain.rs @@ -140,7 +140,12 @@ impl VeilChain { // --- Veil management --- /// Add a veil to the chain. Creates GPU resources immediately. - pub fn add_veil(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, veil: Box) { + pub fn add_veil( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + mut veil: Box, + ) { self.ensure_textures(device); self.ensure_scaling_pipelines(device); let native_views = self.views.as_ref().unwrap(); @@ -148,7 +153,7 @@ impl VeilChain { let (scaling, cache) = create_veil_resources( device, queue, - &*veil, + &mut *veil, native_views, &self.sampler, self.downscale_pipeline.as_ref(), @@ -210,7 +215,7 @@ impl VeilChain { device: &wgpu::Device, queue: &wgpu::Queue, index: usize, - new_veil: Box, + mut new_veil: Box, ) { if index >= self.entries.len() { return; @@ -222,7 +227,7 @@ impl VeilChain { let (scaling, cache) = create_veil_resources( device, queue, - &*new_veil, + &mut *new_veil, native_views, &self.sampler, self.downscale_pipeline.as_ref(), @@ -511,7 +516,7 @@ impl VeilChain { let (scaling, cache) = create_veil_resources( device, queue, - &*entry.veil, + &mut *entry.veil, native_views, &self.sampler, self.downscale_pipeline.as_ref(), @@ -537,7 +542,7 @@ impl VeilChain { fn create_veil_resources( device: &wgpu::Device, queue: &wgpu::Queue, - veil: &dyn Veil, + veil: &mut dyn Veil, native_views: &[wgpu::TextureView; 2], sampler: &wgpu::Sampler, scaling_layout: Option<&EffectPipeline>, diff --git a/crates/darkly/src/gpu/veil_preview.rs b/crates/darkly/src/gpu/veil_preview.rs deleted file mode 100644 index 0fbac33d..00000000 --- a/crates/darkly/src/gpu/veil_preview.rs +++ /dev/null @@ -1,244 +0,0 @@ -//! Offscreen veil preview renderer. -//! -//! Produces small, looping thumbnail frames of a single veil applied to the -//! user's **current canvas** — so the picker shows what each effect would do to -//! their actual art, not a stock sample. Entirely self-contained: its own -//! preview-sized ping-pong textures and a veil instance built fresh from the -//! registry, so generating a preview never touches the live veil chain, the -//! compositor's surface, or the document. -//! -//! Mirrors the brush editor's offscreen approach -//! (`brush/preview_renderer.rs`): a reusable renderer that holds its scratch -//! target between calls and reallocates only on size change. The engine drives -//! per-frame async readback (`engine/veils.rs`) using the same -//! `ReadbackScheduler` pattern as export — no blocking GPU readbacks. -//! -//! Data flow per frame: the (downscaled) composite lives in `ping_pong[0]`; the -//! veil reads it (`src_idx = 0`) and writes its output to `ping_pong[1]`, which -//! is read back. Animated veils advance via `update_time` between frames; static -//! veils render a single frame. Previews are **not cached** — each time the -//! picker opens, frames are regenerated against the current canvas. - -use super::effect::{self, EffectPipeline}; -use super::params::ParamValue; -use super::preview::{fit_preview_dims, PREVIEW_DT}; -use super::veil::{Veil, VeilRegistry}; - -/// Ping-pong textures sized to the preview thumbnail. `pingpong[0]` holds the -/// downscaled composite (veil input); `pingpong[1]` receives each veil output. -struct PreviewTextures { - width: u32, - height: u32, - pingpong: [wgpu::Texture; 2], - views: [wgpu::TextureView; 2], -} - -/// Renders veil preview frames into an offscreen RGBA texture. One instance is -/// reusable across veils and renders; it lazily allocates its sampler, downscale -/// pipeline, and target, reallocating the target only when the preview -/// dimensions change. -pub struct VeilPreviewRenderer { - textures: Option, - sampler: Option, - /// Soft multi-tap downscale used to copy the (often much larger) composite - /// into the preview-sized input texture without hard aliasing. - downscale: Option, -} - -impl VeilPreviewRenderer { - pub fn new() -> Self { - Self { - textures: None, - sampler: None, - downscale: None, - } - } - - /// Downscale `source` (the current composite) into the preview input - /// texture, (re)allocating the target if the preview dimensions changed. - /// Must be called before [`build_veil`](Self::build_veil) / - /// [`encode_frame`](Self::encode_frame) each generation, since the source - /// content (and possibly the canvas size) may have changed. - pub fn load_source( - &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - source_view: &wgpu::TextureView, - source_width: u32, - source_height: u32, - format: wgpu::TextureFormat, - ) { - self.ensure_sampler(device); - self.ensure_downscale(device, format); - - let (pw, ph) = fit_preview_dims(source_width, source_height); - let realloc = match &self.textures { - Some(t) => t.width != pw || t.height != ph, - None => true, - }; - if realloc { - self.textures = Some(make_textures(device, pw, ph, format)); - } - - let textures = self.textures.as_ref().unwrap(); - let downscale = self.downscale.as_ref().unwrap(); - let source_bg = effect::create_blit_bind_group( - device, - &downscale.bind_group_layout, - source_view, - self.sampler.as_ref().unwrap(), - "veil-preview-source-bg", - ); - - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("veil-preview-load-source"), - }); - { - let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("veil-preview-downscale"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &textures.views[0], - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - ..Default::default() - }); - rpass.set_pipeline(&downscale.pipeline); - rpass.set_bind_group(0, &source_bg, &[]); - rpass.draw(0..3, 0..1); - } - queue.submit([encoder.finish()]); - } - - /// Build a veil instance + its GPU cache over the loaded composite. Returns - /// the veil (its `needs_animation()` decides the frame count) and its cache; - /// the caller then encodes frames via [`encode_frame`](Self::encode_frame) - /// and reads back [`output_texture`](Self::output_texture). - /// - /// [`load_source`](Self::load_source) must have run first. - pub fn build_veil( - &self, - device: &wgpu::Device, - queue: &wgpu::Queue, - registry: &mut VeilRegistry, - type_id: &str, - params: &[ParamValue], - format: wgpu::TextureFormat, - ) -> (Box, super::effect::EffectCache) { - let textures = self - .textures - .as_ref() - .expect("load_source must run before build_veil"); - let veil = registry.create_veil(type_id, params, device, format); - let cache = veil.create_cache( - device, - queue, - &textures.views, - self.sampler.as_ref().unwrap(), - textures.width, - textures.height, - ); - (veil, cache) - } - - /// The preview dimensions (width, height) of the currently loaded target, - /// or `(0, 0)` if nothing is loaded. - pub fn preview_size(&self) -> (u32, u32) { - self.textures - .as_ref() - .map(|t| (t.width, t.height)) - .unwrap_or((0, 0)) - } - - /// Per-frame delta time animated veils should be stepped by between - /// [`encode_frame`](Self::encode_frame) calls. - pub fn frame_dt(&self) -> f32 { - PREVIEW_DT - } - - /// Encode the veil's render passes for one frame: read the composite from - /// `ping_pong[0]`, write the result to `ping_pong[1]`. - pub fn encode_frame( - &self, - encoder: &mut wgpu::CommandEncoder, - veil: &dyn Veil, - cache: &super::effect::EffectCache, - ) { - let textures = self.textures.as_ref().unwrap(); - veil.encode(encoder, cache, 0, &textures.views[1]); - } - - /// The texture holding the most recently encoded frame — readback source. - pub fn output_texture(&self) -> &wgpu::Texture { - &self.textures.as_ref().unwrap().pingpong[1] - } - - fn ensure_sampler(&mut self, device: &wgpu::Device) { - if self.sampler.is_none() { - self.sampler = Some(device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("veil-preview-sampler"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - })); - } - } - - fn ensure_downscale(&mut self, device: &wgpu::Device, format: wgpu::TextureFormat) { - if self.downscale.is_none() { - self.downscale = Some(effect::create_downscale_pipeline( - device, - format, - "veil-preview-downscale", - )); - } - } -} - -impl Default for VeilPreviewRenderer { - fn default() -> Self { - Self::new() - } -} - -fn make_textures( - device: &wgpu::Device, - width: u32, - height: u32, - format: wgpu::TextureFormat, -) -> PreviewTextures { - let make = |label: &str| { - device.create_texture(&wgpu::TextureDescriptor { - label: Some(label), - size: wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC - | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }) - }; - let pingpong = [make("veil-preview-input"), make("veil-preview-output")]; - let views = [ - pingpong[0].create_view(&wgpu::TextureViewDescriptor::default()), - pingpong[1].create_view(&wgpu::TextureViewDescriptor::default()), - ]; - PreviewTextures { - width, - height, - pingpong, - views, - } -} diff --git a/crates/darkly/src/gpu/veils/black_and_white.rs b/crates/darkly/src/gpu/veils/black_and_white.rs index f9717214..cb1ec2fc 100644 --- a/crates/darkly/src/gpu/veils/black_and_white.rs +++ b/crates/darkly/src/gpu/veils/black_and_white.rs @@ -5,7 +5,7 @@ //! file owns only the veil-side bindings and render pass. use crate::gpu::black_and_white as bw; -use crate::gpu::effect::{EffectCache, EffectPipeline}; +use crate::gpu::effect::{create_effect_pipeline, Binding, EffectCache, EffectPipeline}; use crate::gpu::veil::{ParamValue, Veil, VeilRegistration}; use std::sync::Arc; @@ -15,6 +15,7 @@ pub fn register() -> VeilRegistration { display_name: bw::DISPLAY_NAME, description: bw::DESCRIPTION, params: bw::PARAMS, + preview: Some(bw::PREVIEW), create_pipeline, from_params: |params, shared| Box::new(BlackAndWhite::new(params, shared)), } @@ -57,8 +58,18 @@ impl Veil for BlackAndWhite { self.params.clone() } + fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { + self.params = bw::preview_params(t); + cache.write_uniform( + queue, + 0, + bytemuck::cast_slice(&bw::pack_uniform(&self.params)), + ); + true + } + fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -132,88 +143,18 @@ impl Veil for BlackAndWhite { } } -fn create_pipeline(device: &wgpu::Device, _format: wgpu::TextureFormat) -> EffectPipeline { - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("black-and-white-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ], - }); - - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("black-and-white-pipeline-layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("black-and-white-shader"), - source: wgpu::ShaderSource::Wgsl( - format!( - "{}\n{}", - bw::SHADER_LIB, - include_str!("../../../shaders/veils/black_and_white.wgsl"), - ) - .into(), - ), - }); - - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("black-and-white-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_black_and_white"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - - EffectPipeline { - pipeline, - bind_group_layout, - } +fn create_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) -> EffectPipeline { + let shader = format!( + "{}\n{}", + bw::SHADER_LIB, + include_str!("../../../shaders/veils/black_and_white.wgsl"), + ); + create_effect_pipeline( + device, + format, + "black-and-white", + &[Binding::Texture, Binding::Sampler, Binding::Uniform], + &shader, + "fs_black_and_white", + ) } diff --git a/crates/darkly/src/gpu/veils/chromatic_aberration.rs b/crates/darkly/src/gpu/veils/chromatic_aberration.rs index 55853115..23ff95b1 100644 --- a/crates/darkly/src/gpu/veils/chromatic_aberration.rs +++ b/crates/darkly/src/gpu/veils/chromatic_aberration.rs @@ -7,9 +7,9 @@ use std::sync::Arc; -use crate::gpu::effect::{EffectCache, EffectPipeline}; +use crate::gpu::effect::{create_effect_pipeline, Binding, EffectCache, EffectPipeline}; use crate::gpu::filters::chromatic_aberration::{ - pack_uniform, GpuAberrationParams, DESCRIPTION, PARAMS, + pack_uniform, preview_params, GpuAberrationParams, DESCRIPTION, PARAMS, PREVIEW, }; use crate::gpu::veil::{ParamValue, Veil, VeilRegistration}; @@ -19,6 +19,7 @@ pub fn register() -> VeilRegistration { display_name: "Chromatic Aberration", description: DESCRIPTION, params: PARAMS, + preview: Some(PREVIEW), create_pipeline, from_params: |params, shared| Box::new(ChromaticAberration::new(params.to_vec(), shared)), } @@ -49,8 +50,14 @@ impl Veil for ChromaticAberration { self.params.clone() } + fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { + self.params = preview_params(t); + cache.write_uniform(queue, 0, bytemuck::bytes_of(&pack_uniform(&self.params))); + true + } + fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -124,89 +131,19 @@ impl Veil for ChromaticAberration { } } -fn create_pipeline(device: &wgpu::Device, _format: wgpu::TextureFormat) -> EffectPipeline { - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("chromatic-aberration-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ], - }); - - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("chromatic-aberration-pipeline-layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("chromatic-aberration-shader"), - // Prepend the shared aberration lib — same pattern the filter uses. - source: wgpu::ShaderSource::Wgsl( - format!( - "{}\n{}", - include_str!("../../../shaders/lib/aberration.wgsl"), - include_str!("../../../shaders/veils/chromatic_aberration.wgsl"), - ) - .into(), - ), - }); - - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("chromatic-aberration-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_chromatic_aberration"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - - EffectPipeline { - pipeline, - bind_group_layout, - } +fn create_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) -> EffectPipeline { + // Prepend the shared aberration lib — same pattern the filter uses. + let shader = format!( + "{}\n{}", + include_str!("../../../shaders/lib/aberration.wgsl"), + include_str!("../../../shaders/veils/chromatic_aberration.wgsl"), + ); + create_effect_pipeline( + device, + format, + "chromatic-aberration", + &[Binding::Texture, Binding::Sampler, Binding::Uniform], + &shader, + "fs_chromatic_aberration", + ) } diff --git a/crates/darkly/src/gpu/veils/frozen.rs b/crates/darkly/src/gpu/veils/frozen.rs index 51d12bbd..18ed813a 100644 --- a/crates/darkly/src/gpu/veils/frozen.rs +++ b/crates/darkly/src/gpu/veils/frozen.rs @@ -1,4 +1,5 @@ -use crate::gpu::effect::{EffectCache, EffectPipeline}; +use crate::gpu::effect::{create_effect_pipeline, Binding, EffectCache, EffectPipeline}; +use crate::gpu::preview::{swing, PreviewAnim}; use crate::gpu::veil::{ParamDef, ParamValue, Veil, VeilRegistration}; use std::sync::Arc; @@ -7,24 +8,15 @@ use std::sync::Arc; const FROZEN_NORMAL_BYTES: &[u8] = include_bytes!("../../../resources/veils/frozen.jpg"); const PARAMS: &[ParamDef] = &[ - ParamDef::Float { - name: "strength", - min: 0.0, - max: 0.2, - default: 0.04, - }, - ParamDef::Float { - name: "scale", - min: 0.1, - max: 5.0, - default: 1.0, - }, - ParamDef::Float { - name: "chromatic", - min: 0.0, - max: 1.0, - default: 0.1, - }, + ParamDef::float("strength", 0.0, 0.2, 0.04) + .with_label("Strength") + .with_description("How far the frosted surface displaces what is behind it."), + ParamDef::float("scale", 0.1, 5.0, 1.0) + .with_label("Scale") + .with_description("Size of the ice crystals."), + ParamDef::float("chromatic", 0.0, 1.0, 0.1) + .with_label("Chromatic") + .with_description("Color separation through the ice, like light through a prism."), ]; pub fn register() -> VeilRegistration { @@ -33,6 +25,7 @@ pub fn register() -> VeilRegistration { display_name: "Frozen", description: "Frost the view behind a pane of refracting ice.", params: PARAMS, + preview: Some(PreviewAnim::LOOPING), create_pipeline: create_frozen_pipeline, from_params: |params, shared| { let strength = match params.first() { @@ -70,11 +63,19 @@ struct FrozenUniforms { pub struct Frozen { /// UV displacement magnitude. 0 = no refraction, 0.2 = heavy distortion. pub strength: f32, - /// Tile density for the ice pattern. 1.0 = one tile across the shorter - /// screen dimension; higher = more, finer crystals. + /// Size of the ice crystals. 1.0 = one tile of the normal map across + /// `sqrt(area)`; higher = **fewer, larger** crystals, because the shader + /// divides the sampling extent by this. Note the refraction magnitude does + /// *not* ride along — `strength` is absolute UV displacement, so raising + /// `scale` alone makes the frost read as milder. pub scale: f32, /// Chromatic aberration: 0 = clean refraction, 1 = pronounced prism edge. pub chromatic: f32, + /// Render resolution and the decoded normal map's aspect, kept from + /// `create_cache` so [`uniforms`](Self::uniforms) rebuilds the whole struct + /// from state. + resolution: (f32, f32), + normal_aspect: f32, shared: Arc, } @@ -84,9 +85,24 @@ impl Frozen { strength, scale, chromatic, + resolution: (0.0, 0.0), + normal_aspect: 1.0, shared, } } + + fn uniforms(&self) -> FrozenUniforms { + FrozenUniforms { + resolution_x: self.resolution.0, + resolution_y: self.resolution.1, + normal_aspect: self.normal_aspect, + strength: self.strength, + scale: self.scale, + chromatic: self.chromatic, + _pad0: 0.0, + _pad1: 0.0, + } + } } impl Veil for Frozen { @@ -106,8 +122,26 @@ impl Veil for Frozen { ] } + /// The ice crystals coarsen and tighten again — `scale` sweeps across a + /// wide band so the frost pattern visibly grows and shrinks. + /// + /// `strength` rides with it rather than holding at its schema default. + /// Displacement is absolute UV (`disp = n.xy * strength * …` in the + /// shader, with no `scale` term), so a fixed `strength` against a zooming + /// pattern halves the warp *per crystal* as the crystals double — which + /// the eye reads as the refraction weakening, not as the crystals growing. + /// Sweeping the two together holds warp-per-crystal constant, and that is + /// what makes the motion read as size. `chromatic` holds, so no colour + /// fringing rides along. + fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { + self.scale = 0.6 + 1.8 * swing(t); + self.strength = 0.04 * self.scale; + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); + true + } + fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -120,7 +154,8 @@ impl Veil for Frozen { .expect("failed to decode frozen normal map") .to_rgba8(); let (nw, nh) = decoded.dimensions(); - let normal_aspect = nw as f32 / nh as f32; + self.normal_aspect = nw as f32 / nh as f32; + self.resolution = (render_width as f32, render_height as f32); let normal_tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("frozen-normal"), @@ -170,23 +205,13 @@ impl Veil for Frozen { ..Default::default() }); - let uniforms = FrozenUniforms { - resolution_x: render_width as f32, - resolution_y: render_height as f32, - normal_aspect, - strength: self.strength, - scale: self.scale, - chromatic: self.chromatic, - _pad0: 0.0, - _pad1: 0.0, - }; let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("frozen-uniforms"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniforms)); + queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&self.uniforms())); let layout = &self.shared.bind_group_layout; let bind_groups: [wgpu::BindGroup; 2] = std::array::from_fn(|i| { @@ -253,97 +278,19 @@ impl Veil for Frozen { } } -fn create_frozen_pipeline(device: &wgpu::Device, _format: wgpu::TextureFormat) -> EffectPipeline { - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("frozen-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 4, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, +fn create_frozen_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) -> EffectPipeline { + create_effect_pipeline( + device, + format, + "frozen", + &[ + Binding::Texture, + Binding::Sampler, + Binding::Uniform, + Binding::Texture, + Binding::Sampler, ], - }); - - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("frozen-pipeline-layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("frozen-shader"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../../shaders/veils/frozen.wgsl").into()), - }); - - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("frozen-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_frozen"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - - EffectPipeline { - pipeline, - bind_group_layout, - } + include_str!("../../../shaders/veils/frozen.wgsl"), + "fs_frozen", + ) } diff --git a/crates/darkly/src/gpu/veils/grain.rs b/crates/darkly/src/gpu/veils/grain.rs index 5f96b246..280870ed 100644 --- a/crates/darkly/src/gpu/veils/grain.rs +++ b/crates/darkly/src/gpu/veils/grain.rs @@ -1,26 +1,19 @@ -use crate::gpu::effect::{EffectCache, EffectPipeline}; +use crate::gpu::effect::{create_effect_pipeline, Binding, EffectCache, EffectPipeline}; +use crate::gpu::hash::pcg_hash; +use crate::gpu::preview::{PreviewAnim, ANIMATED_FRAMES}; use crate::gpu::veil::{ParamDef, ParamValue, Veil, VeilRegistration}; use std::sync::Arc; const PARAMS: &[ParamDef] = &[ - ParamDef::Float { - name: "speed", - min: 0.0, - max: 1.0, - default: 0.05, - }, - ParamDef::Float { - name: "color", - min: 0.0, - max: 1.0, - default: 0.0, - }, - ParamDef::Float { - name: "opacity", - min: 0.0, - max: 1.0, - default: 1.0, - }, + ParamDef::float("speed", 0.0, 1.0, 0.05) + .with_label("Speed") + .with_description("How fast the grain reshuffles; zero holds a single still pattern."), + ParamDef::float("color", 0.0, 1.0, 0.0) + .with_label("Color") + .with_description("Blends the grain from monochrome speckle toward colored noise."), + ParamDef::float("opacity", 0.0, 1.0, 1.0) + .with_label("Opacity") + .with_description("How strongly the grain shows over the image."), ]; pub fn register() -> VeilRegistration { @@ -29,6 +22,7 @@ pub fn register() -> VeilRegistration { display_name: "Grain", description: "Film grain noise over the view, optionally animated.", params: PARAMS, + preview: Some(PreviewAnim::ONE_WAY), create_pipeline: create_evolve_pipeline, from_params: |params, shared| { let speed = match params.first() { @@ -84,6 +78,15 @@ impl Grain { shared, } } + + fn uniforms(&self) -> GrainUniforms { + GrainUniforms { + seed: self.frame_count, + color: self.color, + rate: self.speed, + opacity: self.opacity, + } + } } impl Veil for Grain { @@ -107,22 +110,31 @@ impl Veil for Grain { self.speed > 0.0 } + /// Two seconds of grain reshuffling, one fresh pattern per frame. + /// + /// The evolve pass replaces a `speed` fraction of pixels and keeps the + /// rest, so at any lower rate what a frame shows depends on the frames + /// before it. Pinning the rate to its maximum makes every pixel fresh, so + /// the pattern is a function of `seed` alone — which is both what makes + /// this absolute and the most legible thing a grain preview can show. + fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { + self.speed = 1.0; + self.frame_count = (t * ANIMATED_FRAMES as f32).round(); + // A full reshuffle leaves nothing of the previous state to preserve, so + // the ping-pong has nothing to alternate for and the slot is fixed. + self.noise_idx = 0; + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); + true + } + fn update_time(&mut self, queue: &wgpu::Queue, cache: &EffectCache, _dt: f32) { self.frame_count += 1.0; self.noise_idx = 1 - self.noise_idx; - let uniforms = GrainUniforms { - seed: self.frame_count, - color: self.color, - rate: self.speed, - opacity: self.opacity, - }; - if let Some(buf) = cache.uniform_bufs.first() { - queue.write_buffer(buf, 0, bytemuck::bytes_of(&uniforms)); - } + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); } fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -130,19 +142,13 @@ impl Veil for Grain { render_width: u32, render_height: u32, ) -> EffectCache { - let uniforms = GrainUniforms { - seed: 0.0, - color: self.color, - rate: self.speed, - opacity: self.opacity, - }; let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("grain-uniforms"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniforms)); + queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&self.uniforms())); // Two noise state textures for ping-pong evolution. let noise_textures: Vec = (0..2) @@ -231,98 +237,27 @@ impl Veil for Grain { }) }); - // --- Apply pipeline (4-binding layout: input + noise + sampler + uniform) --- - let apply_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("grain-apply-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, + // Apply pipeline: input + sampler + uniform + per-frame noise texture. + let apply = create_effect_pipeline( + device, + wgpu::TextureFormat::Rgba8Unorm, + "grain-apply", + &[ + Binding::Texture, + Binding::Sampler, + Binding::Uniform, + Binding::Texture, ], - }); - - let apply_pipeline_layout = - device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("grain-apply-pipeline-layout"), - bind_group_layouts: &[Some(&apply_bgl)], - immediate_size: 0, - }); - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("grain-apply-shader"), - source: wgpu::ShaderSource::Wgsl( - include_str!("../../../shaders/veils/grain.wgsl").into(), - ), - }); - - let apply_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("grain-apply-pipeline"), - layout: Some(&apply_pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_apply"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); + include_str!("../../../shaders/veils/grain.wgsl"), + "fs_apply", + ); // --- Apply bind groups: [noise_idx][input_ping_pong_idx] --- // Stored as bind_groups[1 + noise_idx][input_idx]. let apply_bgs_noise0: [wgpu::BindGroup; 2] = std::array::from_fn(|i| { device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some(&format!("grain-apply-bg-n0-i{i}")), - layout: &apply_bgl, + layout: &apply.bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, @@ -347,7 +282,7 @@ impl Veil for Grain { let apply_bgs_noise1: [wgpu::BindGroup; 2] = std::array::from_fn(|i| { device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some(&format!("grain-apply-bg-n1-i{i}")), - layout: &apply_bgl, + layout: &apply.bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, @@ -374,7 +309,7 @@ impl Veil for Grain { bind_groups: vec![evolve_bgs, apply_bgs_noise0, apply_bgs_noise1], aux_textures: noise_textures, aux_views: noise_views, - aux_pipelines: vec![apply_pipeline], + aux_pipelines: vec![apply.pipeline], } } @@ -437,88 +372,13 @@ impl Veil for Grain { } } -/// CPU-side PCG hash matching the GPU version. -fn pcg_hash(n: u32) -> u32 { - let mut h = n.wrapping_mul(747796405).wrapping_add(2891336453); - h = ((h >> ((h >> 28) + 4)) ^ h).wrapping_mul(277803737); - (h >> 22) ^ h -} - -fn create_evolve_pipeline(device: &wgpu::Device, _format: wgpu::TextureFormat) -> EffectPipeline { - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("grain-evolve-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ], - }); - - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("grain-evolve-pipeline-layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("grain-shader"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../../shaders/veils/grain.wgsl").into()), - }); - - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("grain-evolve-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_evolve"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - - EffectPipeline { - pipeline, - bind_group_layout, - } +fn create_evolve_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) -> EffectPipeline { + create_effect_pipeline( + device, + format, + "grain-evolve", + &[Binding::Texture, Binding::Sampler, Binding::Uniform], + include_str!("../../../shaders/veils/grain.wgsl"), + "fs_evolve", + ) } diff --git a/crates/darkly/src/gpu/veils/lens_blur.rs b/crates/darkly/src/gpu/veils/lens_blur.rs index f723d8ad..42c225e3 100644 --- a/crates/darkly/src/gpu/veils/lens_blur.rs +++ b/crates/darkly/src/gpu/veils/lens_blur.rs @@ -1,4 +1,5 @@ -use crate::gpu::effect::{EffectCache, EffectPipeline}; +use crate::gpu::effect::{create_effect_pipeline, Binding, EffectCache, EffectPipeline}; +use crate::gpu::preview::{swing, PreviewAnim}; use crate::gpu::veil::{ParamDef, ParamValue, Veil, VeilRegistration}; use std::sync::Arc; @@ -7,18 +8,12 @@ const PARAMS: &[ParamDef] = &[ // blur radius as a fraction of sqrt(canvas area), so user 1.0 = 3% of // sqrt(area) (≈ 30 px on a 1024² canvas) and the default user 1/3 // ≈ 0.01 of sqrt(area) (≈ 10 px on 1024²). - ParamDef::Float { - name: "radius", - min: 0.0, - max: 1.0, - default: 1.0 / 3.0, - }, - ParamDef::Float { - name: "threshold", - min: 0.01, - max: 1.0, - default: 0.1, - }, + ParamDef::float("radius", 0.0, 1.0, 1.0 / 3.0) + .with_label("Radius") + .with_description("Size of the defocus circle — how far out of focus the image sits."), + ParamDef::float("threshold", 0.01, 1.0, 0.1) + .with_label("Threshold") + .with_description("How bright a pixel must be before it blooms into a bokeh highlight."), ]; pub fn register() -> VeilRegistration { @@ -27,6 +22,7 @@ pub fn register() -> VeilRegistration { display_name: "Lens Blur", description: "Defocus the view with a soft camera-lens blur.", params: PARAMS, + preview: Some(PreviewAnim::LOOPING), create_pipeline: create_lens_blur_pipeline, from_params: |params, shared| { let radius = match params.first() { @@ -55,6 +51,9 @@ struct LensBlurUniforms { pub struct LensBlur { pub radius: f32, pub threshold: f32, + /// Render resolution, kept from `create_cache` so + /// [`uniforms`](Self::uniforms) rebuilds the whole struct from state. + resolution: (f32, f32), shared: Arc, } @@ -63,9 +62,19 @@ impl LensBlur { LensBlur { radius: radius.max(0.0), threshold: threshold.max(0.01), + resolution: (0.0, 0.0), shared, } } + + fn uniforms(&self) -> LensBlurUniforms { + LensBlurUniforms { + radius: self.radius, + threshold: self.threshold, + resolution_x: self.resolution.0, + resolution_y: self.resolution.1, + } + } } impl Veil for LensBlur { @@ -84,8 +93,17 @@ impl Veil for LensBlur { ] } + /// Focus pulls all the way out and back in. `radius` is a sample-footprint + /// parameter within a single pass rather than a pass count, so sweeping its + /// full band averages *below* the shipped default in cost. + fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { + self.radius = swing(t); + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); + true + } + fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -93,19 +111,14 @@ impl Veil for LensBlur { render_width: u32, render_height: u32, ) -> EffectCache { - let uniforms = LensBlurUniforms { - radius: self.radius, - threshold: self.threshold, - resolution_x: render_width as f32, - resolution_y: render_height as f32, - }; + self.resolution = (render_width as f32, render_height as f32); let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("lens-blur-uniforms"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniforms)); + queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&self.uniforms())); let layout = &self.shared.bind_group_layout; let bind_groups: [wgpu::BindGroup; 2] = std::array::from_fn(|i| { @@ -164,86 +177,13 @@ impl Veil for LensBlur { } } -fn create_lens_blur_pipeline( - device: &wgpu::Device, - _format: wgpu::TextureFormat, -) -> EffectPipeline { - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("lens-blur-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ], - }); - - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("lens-blur-pipeline-layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("lens-blur-shader"), - source: wgpu::ShaderSource::Wgsl( - include_str!("../../../shaders/veils/lens_blur.wgsl").into(), - ), - }); - - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("lens-blur-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_lens_blur"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - - EffectPipeline { - pipeline, - bind_group_layout, - } +fn create_lens_blur_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) -> EffectPipeline { + create_effect_pipeline( + device, + format, + "lens-blur", + &[Binding::Texture, Binding::Sampler, Binding::Uniform], + include_str!("../../../shaders/veils/lens_blur.wgsl"), + "fs_lens_blur", + ) } diff --git a/crates/darkly/src/gpu/veils/painting.rs b/crates/darkly/src/gpu/veils/painting.rs index 2e248542..40a321c4 100644 --- a/crates/darkly/src/gpu/veils/painting.rs +++ b/crates/darkly/src/gpu/veils/painting.rs @@ -1,29 +1,23 @@ // User-facing "Painting" veil. The underlying algorithm is the // generalized Kuwahara filter — see shader header for prior-art credit. -use crate::gpu::effect::{EffectCache, EffectPipeline}; +use crate::gpu::effect::{create_effect_pipeline, Binding, EffectCache, EffectPipeline}; +use crate::gpu::preview::{swing, PreviewAnim}; use crate::gpu::veil::{ParamDef, ParamValue, Veil, VeilRegistration}; +use crate::units::UnitType; use std::sync::Arc; const PARAMS: &[ParamDef] = &[ - ParamDef::Int { - name: "kernel_size", - min: 1, - max: 7, - default: 6, - }, - ParamDef::Float { - name: "sharpness", - min: 1.0, - max: 18.0, - default: 8.0, - }, - ParamDef::Float { - name: "hardness", - min: 1.0, - max: 200.0, - default: 100.0, - }, + ParamDef::int("kernel_size", 1, 7, 6) + .with_label("Brush Size") + .with_description("Width of the region each output pixel is averaged from — larger reads as broader strokes.") + .with_unit(UnitType::Pixels), + ParamDef::float("sharpness", 1.0, 18.0, 8.0) + .with_label("Sharpness") + .with_description("How crisply one painted region ends and the next begins."), + ParamDef::float("hardness", 1.0, 200.0, 100.0) + .with_label("Hardness") + .with_description("How strongly the strongest-oriented region wins, flattening detail into flat patches."), ]; pub fn register() -> VeilRegistration { @@ -32,6 +26,7 @@ pub fn register() -> VeilRegistration { display_name: "Painting", description: "Smooth the view into painterly, brush-like daubs.", params: PARAMS, + preview: Some(PreviewAnim::LOOPING), create_pipeline: create_painting_pipeline, from_params: |params, shared| { let kernel_size = match params.first() { @@ -69,6 +64,9 @@ pub struct Painting { pub kernel_size: i32, pub sharpness: f32, pub hardness: f32, + /// Render resolution, kept from `create_cache` so + /// [`uniforms`](Self::uniforms) rebuilds the whole struct from state. + resolution: (f32, f32), shared: Arc, } @@ -83,9 +81,21 @@ impl Painting { kernel_size: kernel_size.max(1), sharpness, hardness, + resolution: (0.0, 0.0), shared, } } + + fn uniforms(&self) -> PaintingUniforms { + PaintingUniforms { + kernel_size: self.kernel_size, + sharpness: self.sharpness, + hardness: self.hardness, + _pad: 0.0, + resolution_x: self.resolution.0, + resolution_y: self.resolution.1, + } + } } impl Veil for Painting { @@ -112,8 +122,19 @@ impl Veil for Painting { ] } + /// The brush widens from a single texel to the full Kuwahara window and + /// back, so each quantised step of the control is plainly visible. + /// `kernel_size` sets the sampling radius inside one pass — `O(kernel²)` + /// samples — so the ramp averages a radius of 4 against the shipped default + /// of 6 and is *cheaper* per frame than a default-parameter render. + fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { + self.kernel_size = (1.0 + 6.0 * swing(t)).round() as i32; + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); + true + } + fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -121,21 +142,14 @@ impl Veil for Painting { render_width: u32, render_height: u32, ) -> EffectCache { - let uniforms = PaintingUniforms { - kernel_size: self.kernel_size, - sharpness: self.sharpness, - hardness: self.hardness, - _pad: 0.0, - resolution_x: render_width as f32, - resolution_y: render_height as f32, - }; + self.resolution = (render_width as f32, render_height as f32); let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("painting-uniforms"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniforms)); + queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&self.uniforms())); let layout = &self.shared.bind_group_layout; let bind_groups: [wgpu::BindGroup; 2] = std::array::from_fn(|i| { @@ -194,83 +208,13 @@ impl Veil for Painting { } } -fn create_painting_pipeline(device: &wgpu::Device, _format: wgpu::TextureFormat) -> EffectPipeline { - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("painting-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ], - }); - - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("painting-pipeline-layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("painting-shader"), - source: wgpu::ShaderSource::Wgsl( - include_str!("../../../shaders/veils/painting.wgsl").into(), - ), - }); - - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("painting-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_painting"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - - EffectPipeline { - pipeline, - bind_group_layout, - } +fn create_painting_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) -> EffectPipeline { + create_effect_pipeline( + device, + format, + "painting", + &[Binding::Texture, Binding::Sampler, Binding::Uniform], + include_str!("../../../shaders/veils/painting.wgsl"), + "fs_painting", + ) } diff --git a/crates/darkly/src/gpu/veils/pixelate.rs b/crates/darkly/src/gpu/veils/pixelate.rs index 86e3e108..ba1ed190 100644 --- a/crates/darkly/src/gpu/veils/pixelate.rs +++ b/crates/darkly/src/gpu/veils/pixelate.rs @@ -1,18 +1,17 @@ use crate::gpu::effect::{create_blit_pipeline, EffectCache, EffectPipeline}; +use crate::gpu::preview::{swing, PreviewAnim}; use crate::gpu::veil::{ParamDef, ParamValue, Veil, VeilRegistration}; +use crate::units::UnitType; use std::sync::Arc; const PARAMS: &[ParamDef] = &[ - ParamDef::Int { - name: "scale", - min: 1, - max: 6, - default: 2, - }, - ParamDef::Bool { - name: "soft", - default: false, - }, + ParamDef::int("scale", 1, 6, 2) + .with_label("Block Size") + .with_description("Width of each block the image is averaged into.") + .with_unit(UnitType::Pixels), + ParamDef::boolean("soft", false) + .with_label("Soft Edges") + .with_description("Blends between blocks instead of leaving hard square edges."), ]; pub fn register() -> VeilRegistration { @@ -21,6 +20,10 @@ pub fn register() -> VeilRegistration { display_name: "Pixelate", description: "Downsample the view into a blocky pixel mosaic.", params: PARAMS, + // Half-way rather than at the peak: `swing(0.25)` is exactly 0.5, so the + // still lands mid-band — blocks big enough to read as a mosaic without + // the coarsest setting's near-total loss of the image. + preview: Some(PreviewAnim::LOOPING.with_still_at(0.25)), create_pipeline: create_pixelate_pipeline, from_params: |params, shared| { let scale = match params.first() { @@ -43,6 +46,11 @@ pub struct Pixelate { /// When true, upscale uses linear filtering (soft/blurry). /// When false, uses nearest-neighbor (hard pixel edges). pub soft: bool, + /// The `scale` the current [`EffectCache`] was built for. Pixelate's cache + /// *is* its parameters — one aux texture and bind group per halving — so + /// this is what lets [`preview_at`](Veil::preview_at) say when the cache it + /// was handed no longer describes the instance. + built_scale: Option, shared: Arc, } @@ -51,6 +59,7 @@ impl Pixelate { Pixelate { scale: scale.max(1), soft, + built_scale: None, shared, } } @@ -82,8 +91,20 @@ impl Veil for Pixelate { ] } + /// Blocks grow from a single pixel to the coarsest the control allows and + /// back, one visible quantised step at a time — which is what shows what + /// the control does in a way no single block size can. + /// + /// Every step changes the cache's *shape*, so this answers `false` whenever + /// the block size moved and the caller rebuilds. That is honest rather than + /// expensive: a rebuilt pixelate at `t` is fully described by `t`. + fn preview_at(&mut self, _queue: &wgpu::Queue, _cache: &EffectCache, t: f32) -> bool { + self.scale = (1.0 + 5.0 * swing(t)).round() as u32; + self.built_scale == Some(self.scale) + } + fn create_cache( - &self, + &mut self, device: &wgpu::Device, _queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -91,6 +112,7 @@ impl Veil for Pixelate { viewport_width: u32, viewport_height: u32, ) -> EffectCache { + self.built_scale = Some(self.scale); let n = self.num_halvings(); let layout = self.bind_group_layout(); let tex_usage = diff --git a/crates/darkly/src/gpu/veils/rainy_glass.rs b/crates/darkly/src/gpu/veils/rainy_glass.rs index ab927fa9..f8f9f4ee 100644 --- a/crates/darkly/src/gpu/veils/rainy_glass.rs +++ b/crates/darkly/src/gpu/veils/rainy_glass.rs @@ -1,38 +1,24 @@ -use crate::gpu::effect::{EffectCache, EffectPipeline}; +use crate::gpu::effect::{create_effect_pipeline, Binding, EffectCache, EffectPipeline}; +use crate::gpu::preview::{PreviewAnim, PREVIEW_SECONDS}; use crate::gpu::veil::{ParamDef, ParamValue, Veil, VeilRegistration}; use std::sync::Arc; const PARAMS: &[ParamDef] = &[ - ParamDef::Float { - name: "speed", - min: 0.0, - max: 3.0, - default: 0.5, - }, - ParamDef::Float { - name: "rain_amount", - min: 0.0, - max: 1.0, - default: 0.5, - }, - ParamDef::Float { - name: "direction", - min: 0.0, - max: 360.0, - default: 0.0, - }, - ParamDef::Float { - name: "fog_amount", - min: 0.0, - max: 1.0, - default: 0.0, - }, - ParamDef::Float { - name: "scale", - min: 0.1, - max: 5.0, - default: 1.4, - }, + ParamDef::float("speed", 0.0, 3.0, 0.5) + .with_label("Speed") + .with_description("How fast the droplets run down the glass."), + ParamDef::float("rain_amount", 0.0, 1.0, 0.5) + .with_label("Rain") + .with_description("How many droplets cover the glass."), + ParamDef::float("direction", 0.0, 360.0, 0.0) + .with_label("Direction") + .with_description("Which way the rain is driven."), + ParamDef::float("fog_amount", 0.0, 1.0, 0.0) + .with_label("Fog") + .with_description("How much condensation clouds the glass between droplets."), + ParamDef::float("scale", 0.1, 5.0, 1.4) + .with_label("Scale") + .with_description("Size of the droplets."), ]; pub fn register() -> VeilRegistration { @@ -41,6 +27,7 @@ pub fn register() -> VeilRegistration { display_name: "Rainy Glass", description: "Raindrops run down a pane of glass over the view.", params: PARAMS, + preview: Some(PreviewAnim::ONE_WAY), create_pipeline: create_rainy_glass_pipeline, from_params: |params, shared| { let speed = match params.first() { @@ -106,6 +93,9 @@ pub struct RainyGlass { pub scale: f32, /// Accumulated effective time (speed-scaled). time: f32, + /// Render resolution, kept from `create_cache` so + /// [`uniforms`](Self::uniforms) rebuilds the whole struct from state. + resolution: (f32, f32), shared: Arc, } @@ -125,9 +115,25 @@ impl RainyGlass { fog_amount, scale, time: 0.0, + resolution: (0.0, 0.0), shared, } } + + fn uniforms(&self) -> RainyGlassUniforms { + RainyGlassUniforms { + time: self.time, + rain_amount: self.rain_amount, + resolution_x: self.resolution.0, + resolution_y: self.resolution.1, + // Add π to compensate for our Y-flip (the vertex shader does + // `1 - uv.y`) against Shadertoy's Y-up convention. + direction: self.direction.to_radians() + std::f32::consts::PI, + fog_amount: self.fog_amount, + scale: self.scale, + _pad: 0.0, + } + } } impl Veil for RainyGlass { @@ -153,15 +159,23 @@ impl Veil for RainyGlass { self.speed > 0.0 } + /// Two seconds of droplets running down the glass. The motion *is* the + /// effect, so the preview runs the veil's own clock rather than a + /// parameter. It runs forward and does not return to its start, so the + /// sequence does not loop. + fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { + self.time = PREVIEW_SECONDS * t * self.speed; + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); + true + } + fn update_time(&mut self, queue: &wgpu::Queue, cache: &EffectCache, dt: f32) { self.time += dt * self.speed; - if let Some(buf) = cache.uniform_bufs.first() { - queue.write_buffer(buf, 0, bytemuck::bytes_of(&self.time)); - } + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); } fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -169,26 +183,14 @@ impl Veil for RainyGlass { render_width: u32, render_height: u32, ) -> EffectCache { - // Convert direction to radians and add π to compensate for our - // Y-flip (vertex shader does 1-uv.y) vs Shadertoy's Y-up convention. - let dir_rad = self.direction.to_radians() + std::f32::consts::PI; - let uniforms = RainyGlassUniforms { - time: self.time, - rain_amount: self.rain_amount, - resolution_x: render_width as f32, - resolution_y: render_height as f32, - direction: dir_rad, - fog_amount: self.fog_amount, - scale: self.scale, - _pad: 0.0, - }; + self.resolution = (render_width as f32, render_height as f32); let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("rainy-glass-uniforms"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniforms)); + queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&self.uniforms())); let layout = &self.shared.bind_group_layout; let bind_groups: [wgpu::BindGroup; 2] = std::array::from_fn(|i| { @@ -249,84 +251,14 @@ impl Veil for RainyGlass { fn create_rainy_glass_pipeline( device: &wgpu::Device, - _format: wgpu::TextureFormat, + format: wgpu::TextureFormat, ) -> EffectPipeline { - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("rainy-glass-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ], - }); - - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("rainy-glass-pipeline-layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("rainy-glass-shader"), - source: wgpu::ShaderSource::Wgsl( - include_str!("../../../shaders/veils/rainy_glass.wgsl").into(), - ), - }); - - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("rainy-glass-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_rainy_glass"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - - EffectPipeline { - pipeline, - bind_group_layout, - } + create_effect_pipeline( + device, + format, + "rainy-glass", + &[Binding::Texture, Binding::Sampler, Binding::Uniform], + include_str!("../../../shaders/veils/rainy_glass.wgsl"), + "fs_rainy_glass", + ) } diff --git a/crates/darkly/src/gpu/veils/vhs.rs b/crates/darkly/src/gpu/veils/vhs.rs index 0b26ee22..cf429830 100644 --- a/crates/darkly/src/gpu/veils/vhs.rs +++ b/crates/darkly/src/gpu/veils/vhs.rs @@ -1,38 +1,26 @@ -use crate::gpu::effect::{EffectCache, EffectPipeline}; +use crate::gpu::effect::{create_effect_pipeline, Binding, EffectCache, EffectPipeline}; +use crate::gpu::preview::{PreviewAnim, PREVIEW_SECONDS}; use crate::gpu::veil::{ParamDef, ParamValue, Veil, VeilRegistration}; use std::sync::Arc; const PARAMS: &[ParamDef] = &[ - ParamDef::Float { - name: "speed", - min: 0.0, - max: 3.0, - default: 0.5, - }, - ParamDef::Float { - name: "wobble", - min: 0.0, - max: 2.0, - default: 1.0, - }, - ParamDef::Float { - name: "switching", - min: 0.0, - max: 2.0, - default: 1.0, - }, - ParamDef::Float { - name: "bloom", - min: 0.0, - max: 2.0, - default: 1.0, - }, - ParamDef::Float { - name: "ac_beat", - min: 0.0, - max: 2.0, - default: 1.0, - }, + ParamDef::float("speed", 0.0, 3.0, 0.5) + .with_label("Speed") + .with_description("How fast the tape artefacts drift and flicker."), + ParamDef::float("wobble", 0.0, 2.0, 1.0) + .with_label("Wobble") + .with_description("Horizontal waver of each scanline, as if the tape were stretched."), + ParamDef::float("switching", 0.0, 2.0, 1.0) + .with_label("Switching Noise") + .with_description( + "Torn band of static at the bottom of the frame where the head switches.", + ), + ParamDef::float("bloom", 0.0, 2.0, 1.0) + .with_label("Bloom") + .with_description("How far bright areas smear and glow into their surroundings."), + ParamDef::float("ac_beat", 0.0, 2.0, 1.0) + .with_label("Hum Bar") + .with_description("Slow bright bar rolling up the frame from mains interference."), ]; pub fn register() -> VeilRegistration { @@ -41,6 +29,7 @@ pub fn register() -> VeilRegistration { display_name: "VHS", description: "Analog VHS tape artifacts — scanlines, noise, and color bleed.", params: PARAMS, + preview: Some(PreviewAnim::ONE_WAY), create_pipeline: create_vhs_pipeline, from_params: |params, shared| { let speed = match params.first() { @@ -150,15 +139,25 @@ impl Veil for Vhs { self.speed > 0.0 } + /// Two seconds of the veil's own tape clock. The artefacts this veil is + /// made of are temporal — the wobble, the switching noise, the AC beat — so + /// its preview runs time rather than any parameter. The clock runs forward + /// and does not return to where it started, so the sequence does not loop; + /// making it do so would mean a periodic time basis in the shader, which is + /// a change to the effect rather than to its preview. + fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { + self.time = PREVIEW_SECONDS * t * self.speed; + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); + true + } + fn update_time(&mut self, queue: &wgpu::Queue, cache: &EffectCache, dt: f32) { self.time += dt * self.speed; - if let Some(buf) = cache.uniform_bufs.first() { - queue.write_buffer(buf, 0, bytemuck::bytes_of(&self.uniforms())); - } + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); } fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -231,81 +230,13 @@ impl Veil for Vhs { } } -fn create_vhs_pipeline(device: &wgpu::Device, _format: wgpu::TextureFormat) -> EffectPipeline { - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("vhs-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ], - }); - - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("vhs-pipeline-layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("vhs-shader"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../../shaders/veils/vhs.wgsl").into()), - }); - - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("vhs-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_vhs"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - - EffectPipeline { - pipeline, - bind_group_layout, - } +fn create_vhs_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) -> EffectPipeline { + create_effect_pipeline( + device, + format, + "vhs", + &[Binding::Texture, Binding::Sampler, Binding::Uniform], + include_str!("../../../shaders/veils/vhs.wgsl"), + "fs_vhs", + ) } diff --git a/crates/darkly/src/gpu/veils/watercolor.rs b/crates/darkly/src/gpu/veils/watercolor.rs index 327deaee..7dec9d4f 100644 --- a/crates/darkly/src/gpu/veils/watercolor.rs +++ b/crates/darkly/src/gpu/veils/watercolor.rs @@ -1,20 +1,15 @@ -use crate::gpu::effect::{EffectCache, EffectPipeline}; +use crate::gpu::effect::{create_effect_pipeline, Binding, EffectCache, EffectPipeline}; +use crate::gpu::preview::{swing, PreviewAnim}; use crate::gpu::veil::{ParamDef, ParamValue, Veil, VeilRegistration}; use std::sync::Arc; const PARAMS: &[ParamDef] = &[ - ParamDef::Int { - name: "iterations", - min: 1, - max: 50, - default: 5, - }, - ParamDef::Float { - name: "wetness", - min: 0.0, - max: 2.0, - default: 0.5, - }, + ParamDef::int("iterations", 1, 50, 5) + .with_label("Iterations") + .with_description("How many times the pigment is allowed to bleed; more softens further."), + ParamDef::float("wetness", 0.0, 2.0, 0.5) + .with_label("Wetness") + .with_description("How freely pigment runs — dry holds its edge, wet pools outward."), ]; /// Size of the generated RGBA noise texture used as a flow map. @@ -26,6 +21,10 @@ pub fn register() -> VeilRegistration { display_name: "Watercolor", description: "Bleed the view outward into soft watercolor washes.", params: PARAMS, + // Half-way rather than at the peak: `swing(0.25)` is exactly 0.5, so the + // still lands mid-band — a visible bleed rather than the fully-dissolved + // wash the sweep's far end reaches. + preview: Some(PreviewAnim::LOOPING.with_still_at(0.25)), create_pipeline: create_watercolor_pipeline, from_params: |params, shared| { let iterations = match params.first() { @@ -56,6 +55,9 @@ struct WatercolorUniforms { pub struct Watercolor { pub iterations: i32, pub wetness: f32, + /// Render resolution, kept from `create_cache` so + /// [`uniforms`](Self::uniforms) rebuilds the whole struct from state. + resolution: (f32, f32), shared: Arc, } @@ -64,32 +66,36 @@ impl Watercolor { Watercolor { iterations: iterations.max(1), wetness, + resolution: (0.0, 0.0), shared, } } + /// The three passes share a layout and differ only in `pass_type`, so one + /// packing serves all three buffers. + fn uniforms(&self, pass_type: i32) -> WatercolorUniforms { + WatercolorUniforms { + pass_type, + wetness: self.wetness, + resolution_x: self.resolution.0, + resolution_y: self.resolution.1, + } + } + fn make_uniform_buf( &self, device: &wgpu::Device, queue: &wgpu::Queue, pass_type: i32, - width: u32, - height: u32, label: &str, ) -> wgpu::Buffer { - let uniforms = WatercolorUniforms { - pass_type, - wetness: self.wetness, - resolution_x: width as f32, - resolution_y: height as f32, - }; let buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - queue.write_buffer(&buf, 0, bytemuck::bytes_of(&uniforms)); + queue.write_buffer(&buf, 0, bytemuck::bytes_of(&self.uniforms(pass_type))); buf } } @@ -168,8 +174,32 @@ impl Veil for Watercolor { ] } + /// The wash bleeds outward and dries back. `iterations` is a *pass count* — + /// each one is a fullscreen blur — so its sweep is bounded well below the + /// slider's maximum: `1 → 10 → 1` averages 5.5 passes per frame, comparable + /// to the shipped default of 5, where the full `1 → 50` band would average + /// 25. The visible bleed still ramps from barely-there to twice the + /// default, and `wetness` costs nothing and carries the rest of the motion. + /// + /// The pass count lives in `encode`'s loop rather than in the cache's + /// shape, so nothing here needs rebuilding; only `wetness` reaches the + /// shader, through all three passes' uniforms. + fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { + let swing = swing(t); + self.iterations = (1.0 + 9.0 * swing).round() as i32; + self.wetness = 0.1 + 1.9 * swing; + for pass_type in 0..3 { + cache.write_uniform( + queue, + pass_type as usize, + bytemuck::bytes_of(&self.uniforms(pass_type)), + ); + } + true + } + fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, ping_pong_views: &[wgpu::TextureView; 2], @@ -177,33 +207,13 @@ impl Veil for Watercolor { render_width: u32, render_height: u32, ) -> EffectCache { + self.resolution = (render_width as f32, render_height as f32); let layout = &self.shared.bind_group_layout; // --- Uniform buffers for each pass type --- - let init_ub = self.make_uniform_buf( - device, - queue, - 0, - render_width, - render_height, - "watercolor-ub-init", - ); - let blur_ub = self.make_uniform_buf( - device, - queue, - 1, - render_width, - render_height, - "watercolor-ub-blur", - ); - let final_ub = self.make_uniform_buf( - device, - queue, - 2, - render_width, - render_height, - "watercolor-ub-final", - ); + let init_ub = self.make_uniform_buf(device, queue, 0, "watercolor-ub-init"); + let blur_ub = self.make_uniform_buf(device, queue, 1, "watercolor-ub-blur"); + let final_ub = self.make_uniform_buf(device, queue, 2, "watercolor-ub-final"); // --- Noise texture + repeat sampler for flow-map bias --- let (noise_tex, noise_view) = create_noise_texture(device, queue); @@ -380,100 +390,20 @@ impl Veil for Watercolor { fn create_watercolor_pipeline( device: &wgpu::Device, - _format: wgpu::TextureFormat, + format: wgpu::TextureFormat, ) -> EffectPipeline { - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("watercolor-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 4, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, + create_effect_pipeline( + device, + format, + "watercolor", + &[ + Binding::Texture, + Binding::Sampler, + Binding::Uniform, + Binding::Texture, + Binding::Sampler, ], - }); - - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("watercolor-pipeline-layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("watercolor-shader"), - source: wgpu::ShaderSource::Wgsl( - include_str!("../../../shaders/veils/watercolor.wgsl").into(), - ), - }); - - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("watercolor-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_watercolor"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - - EffectPipeline { - pipeline, - bind_group_layout, - } + include_str!("../../../shaders/veils/watercolor.wgsl"), + "fs_watercolor", + ) } diff --git a/crates/darkly/src/gpu/video_stream_void.rs b/crates/darkly/src/gpu/video_stream_void.rs index 5fb82a41..30c7fd4e 100644 --- a/crates/darkly/src/gpu/video_stream_void.rs +++ b/crates/darkly/src/gpu/video_stream_void.rs @@ -41,7 +41,6 @@ use crate::gpu::effect::{EffectCache, EffectPipeline}; use crate::gpu::void::{ CaptureKind, DirtyFlag, ExternalImageSource, ParamDef, ParamValue, Void, VoidRegistration, }; -use std::cell::Cell; use std::sync::Arc; /// Static per-variant description of a video-stream void. One of these is @@ -51,6 +50,9 @@ use std::sync::Arc; pub struct VideoStreamConfig { pub type_id: &'static str, pub display_name: &'static str, + /// One-sentence summary shown as a tooltip in the Add Void picker — + /// include the terms users would search for. + pub description: &'static str, pub icon: &'static str, /// Param schema — looked up by *name* (`"freeze"`, `"frame_divisor"`) by the /// shared code, so variants are decoupled from each other's param ordering. @@ -75,11 +77,12 @@ pub fn registration( VoidRegistration { type_id: config.type_id, display_name: config.display_name, + description: config.description, params: config.params, icon: config.icon, // The aux texture is a 1×1 placeholder until a frame arrives, so - // there's nothing meaningful to render at picker-preview time. - supports_preview: false, + // there is nothing meaningful to render, and nothing to animate. + preview: None, supports_live_transform: true, capture_kind: Some(config.capture_kind), default_transform: config.default_transform, @@ -100,7 +103,7 @@ pub fn build_void( /// Index of the named param within a config's schema, or `None` if absent. fn param_index(config: &VideoStreamConfig, name: &str) -> Option { - config.params.iter().position(|p| p.name() == name) + config.params.iter().position(|p| p.name == name) } /// Read the `"freeze"` toggle out of a positional param slice by resolving its @@ -195,11 +198,10 @@ pub struct VideoStreamVoid { /// the first frame arrives — matching the placeholder aux texture. src_w: u32, src_h: u32, - /// Canvas dimensions cached from `create_cache`. `Cell` because the trait - /// gives us `&self` there; `upload_external_image` reads these to rewrite - /// the uniforms when the source resolution changes. - canvas_w: Cell, - canvas_h: Cell, + /// Canvas dimensions kept from `create_cache`. `upload_external_image` + /// reads these to rewrite the uniforms when the source resolution changes. + canvas_w: u32, + canvas_h: u32, shared: Arc, dirty: DirtyFlag, } @@ -217,8 +219,8 @@ impl Clone for VideoStreamVoid { param_snapshot: self.param_snapshot.clone(), src_w: self.src_w, src_h: self.src_h, - canvas_w: Cell::new(self.canvas_w.get()), - canvas_h: Cell::new(self.canvas_h.get()), + canvas_w: self.canvas_w, + canvas_h: self.canvas_h, shared: self.shared.clone(), dirty: DirtyFlag::new_dirty(), } @@ -243,8 +245,8 @@ impl VideoStreamVoid { param_snapshot: normalize_params(config, params), src_w: 1, src_h: 1, - canvas_w: Cell::new(1), - canvas_h: Cell::new(1), + canvas_w: 1, + canvas_h: 1, shared, dirty: DirtyFlag::new_dirty(), } @@ -257,7 +259,7 @@ impl VideoStreamVoid { // shader's perspective divide is a no-op for them. let [inv_row0, inv_row1, inv_row2] = crate::gpu::transform::pack_inv_rows(&self.transform.to_projective()); - let (ox, oy, cw, ch) = self.content_rect(self.canvas_w.get(), self.canvas_h.get()); + let (ox, oy, cw, ch) = self.content_rect(self.canvas_w, self.canvas_h); VideoStreamUniforms { inv_row0, inv_row1, @@ -427,7 +429,7 @@ impl Void for VideoStreamVoid { .params .iter() .enumerate() - .map(|(i, def)| match def.name() { + .map(|(i, def)| match def.name { "freeze" => ParamValue::Bool(self.freeze), "frame_divisor" => ParamValue::Int(self.frame_divisor as i32), // Passthrough param (e.g. `url`): echo the stored value so the @@ -472,9 +474,7 @@ impl Void for VideoStreamVoid { self.freeze = read_freeze(self.config, params); self.frame_divisor = read_frame_divisor(self.config, params); self.param_snapshot = normalize_params(self.config, params); - if let Some(buf) = cache.uniform_bufs.first() { - queue.write_buffer(buf, 0, bytemuck::bytes_of(&self.uniforms())); - } + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); self.dirty.mark(); } @@ -488,15 +488,13 @@ impl Void for VideoStreamVoid { // rewrite the uniform. Never rebuild — that would drop the aux frame // texture (the `from_params` rebuild bug documented on `update_params`). self.transform = *transform; - if let Some(buf) = cache.uniform_bufs.first() { - queue.write_buffer(buf, 0, bytemuck::bytes_of(&self.uniforms())); - } + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); self.dirty.mark(); } fn content_extent(&self, canvas_w: u32, canvas_h: u32) -> (f32, f32, f32, f32) { // Use the compositor's LIVE canvas dims (passed in) rather than the - // cached Cell, so the gizmo bbox is correct immediately after a crop — + // cached copy, so the gizmo bbox is correct immediately after a crop — // `set_canvas_rect` updates the compositor's dims but not the void's // cached copy. self.content_rect(canvas_w, canvas_h) @@ -634,7 +632,7 @@ impl Void for VideoStreamVoid { } fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, _dst_view: &wgpu::TextureView, @@ -644,8 +642,8 @@ impl Void for VideoStreamVoid { ) -> EffectCache { // Cache the canvas dims; `upload_external_image` needs them to // rewrite uniforms when the source resolution changes. - self.canvas_w.set(render_width.max(1)); - self.canvas_h.set(render_height.max(1)); + self.canvas_w = render_width.max(1); + self.canvas_h = render_height.max(1); let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("void-video-stream-uniforms"), @@ -801,21 +799,14 @@ mod tests { // to camera / screenshare; identity seed transform keeps the affine math // easy to reason about. const TEST_PARAMS: &[ParamDef] = &[ - ParamDef::Bool { - name: "freeze", - default: false, - }, - ParamDef::Int { - name: "frame_divisor", - min: 1, - max: 60, - default: 4, - }, + ParamDef::boolean("freeze", false), + ParamDef::int("frame_divisor", 1, 60, 4), ]; static TEST_CONFIG: VideoStreamConfig = VideoStreamConfig { type_id: "test_video_stream", display_name: "Test", + description: "Test fixture.", icon: "tabler:test", params: TEST_PARAMS, capture_kind: CaptureKind::Camera, @@ -851,25 +842,15 @@ mod tests { // in for the Blender void — exercises that params the machinery doesn't model // still round-trip. const URL_PARAMS: &[ParamDef] = &[ - ParamDef::Bool { - name: "freeze", - default: false, - }, - ParamDef::Int { - name: "frame_divisor", - min: 1, - max: 60, - default: 4, - }, - ParamDef::String { - name: "url", - default: "http://localhost:8765/stream", - }, + ParamDef::boolean("freeze", false), + ParamDef::int("frame_divisor", 1, 60, 4), + ParamDef::string("url", "http://localhost:8765/stream"), ]; static URL_CONFIG: VideoStreamConfig = VideoStreamConfig { type_id: "test_url_stream", display_name: "Test URL", + description: "Test fixture.", icon: "tabler:test", params: URL_PARAMS, capture_kind: CaptureKind::Stream, @@ -1073,8 +1054,8 @@ mod tests { let mut x = make_void(); x.src_w = 100; x.src_h = 100; - x.canvas_w.set(100); - x.canvas_h.set(100); + x.canvas_w = 100; + x.canvas_h = 100; x }; @@ -1106,8 +1087,8 @@ mod tests { #[test] fn content_extent_overhangs_canvas() { let mut v = make_void(); - v.canvas_w.set(100); - v.canvas_h.set(100); + v.canvas_w = 100; + v.canvas_h = 100; v.src_w = 200; v.src_h = 100; let (ox, oy, w, h) = v.content_extent(100, 100); @@ -1121,9 +1102,9 @@ mod tests { /// cover-fit, so the content rect falls back to canvas-fill. #[test] fn content_extent_falls_back_to_canvas_without_frame() { - let v = make_void(); - v.canvas_w.set(80); - v.canvas_h.set(60); + let mut v = make_void(); + v.canvas_w = 80; + v.canvas_h = 60; let (ox, oy, w, h) = v.content_extent(80, 60); assert_eq!((ox, oy, w, h), (0.0, 0.0, 80.0, 60.0)); } diff --git a/crates/darkly/src/gpu/void.rs b/crates/darkly/src/gpu/void.rs index e2a923e5..af9464a9 100644 --- a/crates/darkly/src/gpu/void.rs +++ b/crates/darkly/src/gpu/void.rs @@ -21,6 +21,10 @@ use std::sync::Arc; pub use super::effect::{EffectCache, EffectPipeline}; pub use super::params::{ParamDef, ParamValue}; +use super::preview::{ + PreviewAnim, PreviewEntry, PreviewMechanism, PreviewRegistries, PreviewSession, PreviewTarget, + PREVIEW_FORMAT, +}; /// External image source for [`Void::upload_external_image`]. Today the only /// populated variant is `Web`, which wraps wgpu's WebGPU-only external-image @@ -116,8 +120,12 @@ pub trait Void: std::fmt::Debug { /// destination view (the void's own texture) so the void can build /// bind groups that target it directly — voids never sample from a /// ping-pong pair the way veils do. + /// + /// Takes `&mut self` so a void whose uniform struct folds in something it + /// is only handed here — the render resolution, the render-target→canvas + /// scale — can keep it, and rewrite that struct later from state alone. fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, dst_view: &wgpu::TextureView, @@ -139,6 +147,24 @@ pub trait Void: std::fmt::Debug { /// Per-frame uniform update for animated voids. Default is a no-op. fn update_time(&mut self, _queue: &wgpu::Queue, _cache: &EffectCache, _dt: f32) {} + /// Put this instance into the state its preview shows at normalized time + /// `t ∈ [0, 1]`, and sync whatever GPU resources that state feeds. + /// + /// Absolute, not incremental: `preview_at(0.5)` produces the same state + /// whether it follows `preview_at(0.4)` or nothing at all. + /// + /// Answers whether `cache` still describes this instance; a void whose + /// cache shape is a function of its parameters would answer `false` and be + /// rebuilt through [`create_cache`](Self::create_cache). The default is a + /// no-op answering `true`, which renders a still at the instance's own + /// parameters. + /// + /// See [`super::preview`] for the shape every body follows and the sweeps + /// they share. + fn preview_at(&mut self, _queue: &wgpu::Queue, _cache: &EffectCache, _t: f32) -> bool { + true + } + /// Replace this void's parameter values in place — update internal /// fields, rewrite the uniform buffer, but leave any stateful GPU /// resources (aux textures holding the camera's last received frame, @@ -262,17 +288,22 @@ pub enum CaptureKind { pub struct VoidRegistration { pub type_id: &'static str, pub display_name: &'static str, + /// One-sentence summary shown as a tooltip in the Add Void picker — + /// include the terms users would search for. + pub description: &'static str, pub params: &'static [ParamDef], /// Iconify icon name (e.g. `"tabler:galaxy"`). Always present — the layer /// panel renders it for void layers of this kind, and the picker falls back /// to it when the void declares no rendered preview. pub icon: &'static str, - /// Whether this void can render a meaningful picker thumbnail. When true the - /// "Add Void" picker shows a live rendered preview; when false it shows - /// [`icon`](Self::icon) instead. (The camera void opts out — its aux texture - /// is a 1×1 placeholder until a webcam frame arrives, so there's nothing to - /// render at preview time.) - pub supports_preview: bool, + /// How long this void's preview runs, or `None` for a void with nothing to + /// show. Declaring an animation is what makes a void previewable — the "Add + /// Void" picker renders a live thumbnail for the ones that do and falls + /// back to [`icon`](Self::icon) for the ones that don't. (The stream voids + /// opt out: their aux texture is a 1×1 placeholder until a browser frame + /// arrives, so there is nothing to render and nothing to animate.) What the + /// preview *does* over that span is [`Void::preview_at`]. + pub preview: Option, /// Whether this void exposes a live, user-editable transform (driven by the /// generic gizmo, stored on [`crate::layer::VoidLayer::transform`]). Voids /// that opt in implement [`Void::set_transform`]; the rest leave it false @@ -292,6 +323,34 @@ pub struct VoidRegistration { pub from_params: fn(&[ParamValue], Arc) -> Box, } +/// Id of the catalog this registry projects into. +pub const CATALOG_ID: &str = "voids"; + +impl VoidRegistration { + pub fn catalog_entry(&self) -> crate::catalog::CatalogEntry { + crate::catalog::CatalogEntry::new(self.type_id, self.display_name) + .with_icon(self.icon) + .with_description(self.description) + .with_params(self.params) + .with_supports_preview(self.preview.is_some()) + .with_capture_kind(self.capture_kind) + } +} + +/// The void catalog — every registered void, sorted by `type_id`. +pub fn catalog() -> crate::catalog::Catalog { + crate::catalog::Catalog::new( + CATALOG_ID, + "Voids", + VoidRegistry::new() + .types() + .into_iter() + .map(VoidRegistration::catalog_entry) + .collect(), + ) + .with_description("Sources that generate a layer's pixels instead of storing them.") +} + /// Auto-discovered void registry with lazy pipeline caching. Each void /// kind contributes one [`VoidRegistration`] via its module's `register()`; /// `build.rs` collects them into [`super::voids::registrations`] and the @@ -346,6 +405,12 @@ impl VoidRegistry { .unwrap_or(&[]) } + /// How long a void type's preview runs. `None` for an unknown type or one + /// that declares no preview. + pub fn preview(&self, type_id: &str) -> Option { + self.entries.get(type_id)?.reg.preview + } + /// The iconify icon name for a void kind (layer-panel icon + picker /// fallback). Empty for unknown types. pub fn icon(&self, type_id: &str) -> &'static str { @@ -435,6 +500,109 @@ impl VoidRegistry { } } +// --------------------------------------------------------------------------- +// Preview mechanism +// --------------------------------------------------------------------------- + +/// This catalog's answer to [`PreviewMechanism`]. Exported by name so +/// `build.rs` finds it while scanning this module's source and emits a +/// `preview_mechanisms()` row for `voids`. +pub fn preview_mechanism() -> &'static dyn PreviewMechanism { + &VoidMechanism +} + +struct VoidMechanism; + +impl PreviewMechanism for VoidMechanism { + fn resolve(&self, type_id: &str) -> Option { + let registry = VoidRegistry::new(); + Some(PreviewEntry { + type_id: registry.static_type_id(type_id)?, + anim: registry.preview(type_id)?, + }) + } + + /// A void generates its content from a shader with no input, so the + /// target's source texture is cleared rather than loaded. + fn reads_source(&self) -> bool { + false + } + + fn open<'a>( + &self, + regs: PreviewRegistries<'a>, + type_id: &str, + ) -> Option> { + let type_id = regs.voids.static_type_id(type_id)?; + Some(Box::new(VoidSession { + registry: regs.voids, + type_id, + instance: None, + })) + } +} + +/// One open void preview: the instance and the cache it renders through. +struct VoidSession<'a> { + registry: &'a mut VoidRegistry, + type_id: &'static str, + instance: Option<(Box, EffectCache)>, +} + +impl<'a> PreviewSession for VoidSession<'a> { + fn set_t( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + target: &PreviewTarget, + t: f32, + ) { + if self.instance.is_none() { + let defaults: Vec = self + .registry + .param_defs(self.type_id) + .iter() + .map(ParamDef::default_value) + .collect(); + let mut void = + self.registry + .create_void(self.type_id, &defaults, device, PREVIEW_FORMAT); + let cache = build_cache(&mut *void, device, queue, target); + self.instance = Some((void, cache)); + } + let (void, cache) = self.instance.as_mut().expect("built above"); + if !void.preview_at(queue, cache, t) { + *cache = build_cache(&mut **void, device, queue, target); + } + } + + fn encode( + &mut self, + _device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + target: &PreviewTarget, + ) { + let Some((void, cache)) = self.instance.as_ref() else { + return; + }; + void.encode(encoder, cache, target.output_view()); + } +} + +/// The one place a void's cache is built against a preview target, so the two +/// callers — the first build and a `preview_at` that invalidated its cache — +/// cannot disagree about what it is built from. A void writes straight into the +/// output view, so that is what its bind groups target. +fn build_cache( + void: &mut dyn Void, + device: &wgpu::Device, + queue: &wgpu::Queue, + target: &PreviewTarget, +) -> EffectCache { + let (w, h) = target.size(); + void.create_cache(device, queue, target.output_view(), target.sampler(), w, h) +} + #[cfg(test)] mod dirty_flag_tests { use super::DirtyFlag; diff --git a/crates/darkly/src/gpu/void_preview.rs b/crates/darkly/src/gpu/void_preview.rs deleted file mode 100644 index ea88348e..00000000 --- a/crates/darkly/src/gpu/void_preview.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Offscreen void preview renderer. -//! -//! Produces small, looping thumbnail frames of a single void for the "Add Void" -//! picker. Unlike a veil (which post-processes the user's current canvas through -//! a ping-pong texture pair), a void *generates* its content from scratch — so -//! there is no source to downscale and no pair to ping-pong between. The void -//! renders straight into one preview-sized destination texture, which is read -//! back. A void instance is built fresh from the registry each generation, so -//! the preview never touches the live layer stack, the compositor's surface, or -//! the document. -//! -//! The shared sizing primitives ([`fit_preview_dims`], the frame-count / fps -//! constants) live in [`super::preview`]; the engine drives per-frame async -//! readback (`engine/voids.rs`) using the same `ReadbackScheduler` pattern as -//! the veil path — no blocking GPU readbacks. - -use super::params::ParamValue; -use super::preview::fit_preview_dims; -use super::void::{Void, VoidRegistry}; - -/// Single preview-sized destination texture the void renders into. -struct PreviewTexture { - width: u32, - height: u32, - texture: wgpu::Texture, - view: wgpu::TextureView, -} - -/// Renders void preview frames into an offscreen RGBA texture. One instance is -/// reusable across voids and renders; it lazily allocates its sampler and -/// output target, reallocating the target only when the preview dimensions -/// change. -pub struct VoidPreviewRenderer { - target: Option, - sampler: Option, -} - -impl VoidPreviewRenderer { - pub fn new() -> Self { - Self { - target: None, - sampler: None, - } - } - - /// Build a void instance + its GPU cache targeting the preview output - /// texture, (re)allocating that texture if the aspect-fit dimensions of - /// `canvas_w × canvas_h` changed. Returns the void (its `needs_animation()` - /// decides the frame count) and its cache; the caller then encodes frames - /// via [`encode_frame`](Self::encode_frame) and reads back - /// [`output_texture`](Self::output_texture). - #[allow(clippy::too_many_arguments)] - pub fn build_void( - &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - registry: &mut VoidRegistry, - type_id: &str, - params: &[ParamValue], - canvas_w: u32, - canvas_h: u32, - format: wgpu::TextureFormat, - ) -> (Box, super::effect::EffectCache) { - self.ensure_sampler(device); - - let (pw, ph) = fit_preview_dims(canvas_w, canvas_h); - let realloc = match &self.target { - Some(t) => t.width != pw || t.height != ph, - None => true, - }; - if realloc { - self.target = Some(make_texture(device, pw, ph, format)); - } - - let target = self.target.as_ref().unwrap(); - let void = registry.create_void(type_id, params, device, format); - let cache = void.create_cache( - device, - queue, - &target.view, - self.sampler.as_ref().unwrap(), - target.width, - target.height, - ); - (void, cache) - } - - /// The preview dimensions (width, height) of the currently built target, or - /// `(0, 0)` if nothing is built. - pub fn preview_size(&self) -> (u32, u32) { - self.target - .as_ref() - .map(|t| (t.width, t.height)) - .unwrap_or((0, 0)) - } - - /// Encode the void's render passes for one frame into the output texture. - pub fn encode_frame( - &self, - encoder: &mut wgpu::CommandEncoder, - void: &dyn Void, - cache: &super::effect::EffectCache, - ) { - let target = self.target.as_ref().unwrap(); - void.encode(encoder, cache, &target.view); - } - - /// The texture holding the most recently encoded frame — readback source. - pub fn output_texture(&self) -> &wgpu::Texture { - &self.target.as_ref().unwrap().texture - } - - fn ensure_sampler(&mut self, device: &wgpu::Device) { - if self.sampler.is_none() { - self.sampler = Some(device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("void-preview-sampler"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - })); - } - } -} - -impl Default for VoidPreviewRenderer { - fn default() -> Self { - Self::new() - } -} - -fn make_texture( - device: &wgpu::Device, - width: u32, - height: u32, - format: wgpu::TextureFormat, -) -> PreviewTexture { - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("void-preview-output"), - size: wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC - | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - PreviewTexture { - width, - height, - texture, - view, - } -} diff --git a/crates/darkly/src/gpu/voids/blender.rs b/crates/darkly/src/gpu/voids/blender.rs index bf2d7191..5b681e58 100644 --- a/crates/darkly/src/gpu/voids/blender.rs +++ b/crates/darkly/src/gpu/voids/blender.rs @@ -30,30 +30,26 @@ const PARAMS: &[ParamDef] = &[ // Freeze on the last received frame; suppresses uploads (GPU holds the last // frame) while the frontend keeps the HTTP stream open, so unfreezing // resumes instantly (see camera void). - ParamDef::Bool { - name: "freeze", - default: false, - }, + ParamDef::boolean("freeze", false) + .with_label("Freeze") + .with_description("Holds the last received frame instead of following the live stream."), // rAF frames to skip between decoded-frame → GPU uploads (see camera void). - ParamDef::Int { - name: "frame_divisor", - min: 1, - max: 60, - default: 4, - }, + ParamDef::int("frame_divisor", 1, 60, 4) + .with_label("Frame Skip") + .with_description("Take one frame in this many, to lighten the load."), // Where the frontend `fetch`es the frame stream. Not read by the Rust void — // `VideoStreamVoid` resolves params by name and ignores this one; it's // document-persisted purely so the frontend knows where to connect and so // the endpoint round-trips through save/load. - ParamDef::String { - name: "url", - default: DEFAULT_URL, - }, + ParamDef::string("url", DEFAULT_URL) + .with_label("Stream URL") + .with_description("Address the Blender frame stream is served from."), ]; static CONFIG: VideoStreamConfig = VideoStreamConfig { type_id: TYPE_ID, display_name: "Blender", + description: "Live viewport frames streamed from a running Blender session.", icon: "file-icons:blender", params: PARAMS, capture_kind: CaptureKind::Stream, @@ -92,7 +88,7 @@ mod tests { // String param carrying the localhost default so a freshly-created void // connects without the user typing anything. let reg = register(); - let names: Vec<_> = reg.params.iter().map(|p| p.name()).collect(); + let names: Vec<_> = reg.params.iter().map(|p| p.name).collect(); assert_eq!(names, vec!["freeze", "frame_divisor", "url"]); let defaults: Vec = reg.params.iter().map(|d| d.default_value()).collect(); diff --git a/crates/darkly/src/gpu/voids/camera.rs b/crates/darkly/src/gpu/voids/camera.rs index 5a9800af..fdbd6cbe 100644 --- a/crates/darkly/src/gpu/voids/camera.rs +++ b/crates/darkly/src/gpu/voids/camera.rs @@ -22,10 +22,9 @@ const PARAMS: &[ParamDef] = &[ // so the GPU holds the last frame, and the JS-side `MediaStreamSource` // suppresses uploads — but keeps the stream open so toggling back off // resumes the live feed instantly (no re-prompt). - ParamDef::Bool { - name: "freeze", - default: false, - }, + ParamDef::boolean("freeze", false) + .with_label("Freeze") + .with_description("Holds the last captured frame instead of following the live feed."), // How many rAF frames to skip between webcam → GPU uploads. 1 = upload // every frame (live 60fps), 4 = upload every 4th frame (~15fps at 60Hz // rAF, the default). Higher values trade smoothness for GPU/CPU savings — @@ -35,12 +34,9 @@ const PARAMS: &[ParamDef] = &[ // The JS-side `MediaStreamSource.tick()` reads this value from the layer // params and gates its own upload accordingly; nothing here reads the field // at render time. - ParamDef::Int { - name: "frame_divisor", - min: 1, - max: 60, - default: 4, - }, + ParamDef::int("frame_divisor", 1, 60, 4) + .with_label("Frame Skip") + .with_description("Capture one frame in this many, to lighten the load."), ]; /// Horizontal flip about the canvas center — the selfie view every video-call @@ -56,6 +52,7 @@ fn selfie_flip(canvas_w: u32, _canvas_h: u32) -> crate::transform::Transform { static CONFIG: VideoStreamConfig = VideoStreamConfig { type_id: TYPE_ID, display_name: "Camera", + description: "Live frames from a connected webcam, mirrored like a selfie.", icon: "tabler:camera", params: PARAMS, capture_kind: CaptureKind::Camera, @@ -81,7 +78,7 @@ mod tests { #[test] fn params_are_freeze_and_divisor() { - let names: Vec<_> = PARAMS.iter().map(|p| p.name()).collect(); + let names: Vec<_> = PARAMS.iter().map(|p| p.name).collect(); assert_eq!(names, vec!["freeze", "frame_divisor"]); } diff --git a/crates/darkly/src/gpu/voids/noise.rs b/crates/darkly/src/gpu/voids/noise.rs index 43fc7037..f3516083 100644 --- a/crates/darkly/src/gpu/voids/noise.rs +++ b/crates/darkly/src/gpu/voids/noise.rs @@ -12,8 +12,10 @@ use crate::gpu::effect::{ create_blit_bind_group, create_blit_pipeline, EffectCache, EffectPipeline, }; +use crate::gpu::hash::pcg_hash; +use crate::gpu::preview::{swing, PreviewAnim}; use crate::gpu::void::{DirtyFlag, ParamDef, ParamValue, Void, VoidRegistration}; -use std::cell::Cell; +use crate::units::UnitType; use std::sync::Arc; /// Procedural-render downscale factor. The FBM shader runs into an aux @@ -39,69 +41,57 @@ const PARAMS: &[ParamDef] = &[ // Seed indexes the procedural field — every integer produces a different // noise pattern, so a randomize button (or just typing a number) gives // the "infinite combinations of entropy" the README promises. - ParamDef::Int { - name: "seed", - min: 0, - max: i32::MAX, - default: 42, - }, + ParamDef::int("seed", 0, i32::MAX, 42) + .with_label("Seed") + .with_description("Picks which noise pattern is generated; any two seeds look unrelated."), // Octave count of the underlying FBM. More octaves = more detail; cost // scales linearly. 5 is a good cloud-like default. - ParamDef::Int { - name: "octaves", - min: 1, - max: 8, - default: 5, - }, + ParamDef::int("octaves", 1, 8, 5) + .with_label("Detail") + .with_description("How many layers of ever-finer noise are stacked up."), // Feature size in canvas pixels. Higher = larger blobs; lower = // grainier. The default is tuned for 1k–2k canvases producing visible // cloud structure without going either flat or noisy. Converted to // a frequency multiplier (1 / size) at uniform-write time. - ParamDef::Float { - name: "size", - min: 20.0, - max: 2000.0, - default: 200.0, - }, + ParamDef::float("size", 20.0, 2000.0, 200.0) + .with_label("Size") + .with_description("How large the noise features are on the canvas.") + .with_unit(UnitType::Pixels), // Domain-warp strength. 0 = pure FBM, increasing values produce more // marbled / swirly deformation per Quilez's warp. - ParamDef::Float { - name: "warp", - min: 0.0, - max: 3.0, - default: 1.5, - }, + ParamDef::float("warp", 0.0, 3.0, 1.5) + .with_label("Warp") + .with_description("Bends the noise into marbled, swirling shapes."), // Darkness / tonal contrast. Applied as `pow(value, 1.0 + darkness)` // in the shader. 0 = linear (washed-out grayscale); higher values // push midtones toward black, giving a Watery-style deep base with // brighter peaks. Range tuned so the default looks like a moodier // cloud field, not a flat gray ramp. - ParamDef::Float { - name: "darkness", - min: 0.0, - max: 3.0, - default: 1.0, - }, + ParamDef::float("darkness", 0.0, 3.0, 1.0) + .with_label("Darkness") + .with_description( + "Pushes the midtones down, deepening the field beneath the bright peaks.", + ), // Time slider — z-coordinate into the 3D noise volume. Each value // produces a different cross-section of the same FBM field; scrub to // explore variations of the current seed without changing pattern // identity. Range chosen so the full slider covers many full noise-cell // crossings at the default Z scale (Z_SCALE = 0.15 in the shader). - ParamDef::Float { - name: "time", - min: 0.0, - max: 100.0, - default: 0.0, - }, + ParamDef::float("time", 0.0, 100.0, 0.0) + .with_label("Time") + .with_description( + "Scrubs through variations of the same seed without changing its character.", + ), ]; pub fn register() -> VoidRegistration { VoidRegistration { type_id: TYPE_ID, display_name: "Noise", + description: "Procedural fractal noise — clouds, grain and organic texture from a seed.", params: PARAMS, icon: "tabler:galaxy", - supports_preview: true, + preview: Some(PreviewAnim::LOOPING), supports_live_transform: true, // Purely procedural — no external capture, identity seed transform. capture_kind: None, @@ -150,10 +140,9 @@ pub struct Noise { /// User transform (gizmo affine). The shader samples the field through its /// inverse, so the noise pattern pans / scales / rotates under the gizmo. transform: crate::transform::Transform, - /// Render-target→canvas px scale, baked at `create_cache`. `Cell` because - /// the trait hands `&self` there; cached so uniform writes need no extra - /// argument and never clobber it. - canvas_scale: Cell, + /// Render-target→canvas px scale, kept from `create_cache` so every + /// uniform write rebuilds the whole struct from state and never clobbers it. + canvas_scale: f32, shared: Arc, dirty: DirtyFlag, } @@ -171,7 +160,7 @@ impl Clone for Noise { darkness: self.darkness, time: self.time, transform: self.transform, - canvas_scale: Cell::new(self.canvas_scale.get()), + canvas_scale: self.canvas_scale, shared: self.shared.clone(), dirty: DirtyFlag::new_dirty(), } @@ -212,7 +201,7 @@ impl Noise { darkness, time, transform: crate::transform::Transform::identity(), - canvas_scale: Cell::new(1.0), + canvas_scale: 1.0, shared, dirty: DirtyFlag::new_dirty(), } @@ -228,7 +217,7 @@ impl Noise { warp: self.warp, darkness: self.darkness, time: self.time, - canvas_scale: self.canvas_scale.get(), + canvas_scale: self.canvas_scale, _pad0: 0.0, inv_row0, inv_row1, @@ -265,6 +254,17 @@ impl Void for Noise { self.dirty.mark(); } + /// The field drifts forward through the noise volume and rewinds. `time` is + /// an ordinary parameter rather than the animation trait's clock, so the + /// whole sweep is a value that can be written and re-written — which is what + /// lets it return to where it started and close the loop. + fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { + self.time = 6.0 * swing(t); + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); + self.dirty.mark(); + true + } + fn update_params(&mut self, queue: &wgpu::Queue, cache: &EffectCache, params: &[ParamValue]) { self.seed = match params.first() { Some(ParamValue::Int(v)) => *v, @@ -292,9 +292,7 @@ impl Void for Noise { }; // Full write — `canvas_scale` is cached on the struct, so rebuilding // the whole uniform can't clobber it. - if let Some(buf) = cache.uniform_bufs.first() { - queue.write_buffer(buf, 0, bytemuck::bytes_of(&self.uniforms())); - } + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); self.dirty.mark(); } @@ -305,14 +303,12 @@ impl Void for Noise { transform: &crate::transform::Transform, ) { self.transform = *transform; - if let Some(buf) = cache.uniform_bufs.first() { - queue.write_buffer(buf, 0, bytemuck::bytes_of(&self.uniforms())); - } + cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); self.dirty.mark(); } fn create_cache( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, _dst_view: &wgpu::TextureView, @@ -322,7 +318,7 @@ impl Void for Noise { ) -> EffectCache { let aux_w = (render_width / AUX_DOWNSCALE).max(AUX_MIN_DIM); let aux_h = (render_height / AUX_DOWNSCALE).max(AUX_MIN_DIM); - self.canvas_scale.set(render_width as f32 / aux_w as f32); + self.canvas_scale = render_width as f32 / aux_w as f32; let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("void-noise-uniforms"), @@ -609,11 +605,3 @@ fn seed_noise_volume(dim: u32, seed: u32) -> Vec { } bytes } - -/// PCG hash matching the GPU-side `fbm_pcg` in `shaders/lib/fbm.wgsl`. -/// Used to seed the 3D noise volume on the CPU. -fn pcg_hash(n: u32) -> u32 { - let mut h = n.wrapping_mul(747796405).wrapping_add(2891336453); - h = ((h >> ((h >> 28) + 4)) ^ h).wrapping_mul(277803737); - (h >> 22) ^ h -} diff --git a/crates/darkly/src/gpu/voids/screenshare.rs b/crates/darkly/src/gpu/voids/screenshare.rs index c72f5f65..2282fd59 100644 --- a/crates/darkly/src/gpu/voids/screenshare.rs +++ b/crates/darkly/src/gpu/voids/screenshare.rs @@ -17,22 +17,19 @@ const PARAMS: &[ParamDef] = &[ // Freeze on the last received frame; suppresses uploads (GPU holds the // last frame) while keeping the share open — stopping a getDisplayMedia // track would end the share permanently, so freeze must not close it. - ParamDef::Bool { - name: "freeze", - default: false, - }, + ParamDef::boolean("freeze", false) + .with_label("Freeze") + .with_description("Holds the last captured frame instead of following the live feed."), // rAF frames to skip between capture → GPU uploads (see camera void). - ParamDef::Int { - name: "frame_divisor", - min: 1, - max: 60, - default: 4, - }, + ParamDef::int("frame_divisor", 1, 60, 4) + .with_label("Frame Skip") + .with_description("Capture one frame in this many, to lighten the load."), ]; static CONFIG: VideoStreamConfig = VideoStreamConfig { type_id: TYPE_ID, display_name: "Screen Share", + description: "Live frames from a shared screen, window or browser tab.", icon: "tabler:screen-share", params: PARAMS, capture_kind: CaptureKind::Display, diff --git a/crates/darkly/src/lib.rs b/crates/darkly/src/lib.rs index 56279b8c..63f411e7 100644 --- a/crates/darkly/src/lib.rs +++ b/crates/darkly/src/lib.rs @@ -1,7 +1,15 @@ +pub mod action; +pub mod actions; pub mod brush; +pub mod catalog; pub mod clipboard; pub mod config; pub mod coord; +/// Renders the documentation preview assets. Performs blocking GPU readbacks, +/// so it lives behind the same gate as `gpu::test_utils` — engine, compositor +/// and WASM-bridge code cannot name it in a production build. +#[cfg(any(test, feature = "testing"))] +pub mod docs_render; pub mod document; pub mod engine; pub mod format; @@ -16,6 +24,7 @@ pub mod tool; pub mod tools; pub mod transform; pub mod undo; +pub mod units; /// Darkly's version — the latest git tag plus the commit height since it /// (`git describe --tags --long`, e.g. `v0.3.0-1-gf0c3ea9`), baked in by diff --git a/crates/darkly/src/nodegraph/compiler.rs b/crates/darkly/src/nodegraph/compiler.rs index 5ff92c99..a2b65efb 100644 --- a/crates/darkly/src/nodegraph/compiler.rs +++ b/crates/darkly/src/nodegraph/compiler.rs @@ -260,7 +260,7 @@ mod tests { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, ); map.insert( @@ -277,7 +277,7 @@ mod tests { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, ); map.insert( @@ -291,7 +291,7 @@ mod tests { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, ); map @@ -564,7 +564,7 @@ mod tests { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, ); reg.insert( @@ -581,7 +581,7 @@ mod tests { is_gpu: false, is_terminal: false, supports_erase: true, - preview_fallback_icon: None, + preview_staging: None, }, ); diff --git a/crates/darkly/src/nodegraph/graph.rs b/crates/darkly/src/nodegraph/graph.rs index a3889cb3..94302cc1 100644 --- a/crates/darkly/src/nodegraph/graph.rs +++ b/crates/darkly/src/nodegraph/graph.rs @@ -1,3 +1,4 @@ +use crate::units::UnitType; use std::collections::{HashMap, HashSet}; use indexmap::IndexMap; @@ -63,59 +64,6 @@ pub enum PortDir { Output, } -/// Display unit for numeric ports. -/// -/// Defines how a port's internal value is converted for display in the UI. -/// The conversion methods use `f32` math — any numeric wire type (Scalar, -/// Int) can round-trip through them. Non-numeric types (Bool, Color) -/// ignore this field. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] -pub enum UnitType { - /// Identity — display and internal are both raw values (shown as `0.50`). - #[default] - Normalized, - /// Display as percentage: `display = value × 100`, suffix `%`. - Percent, - /// Wire unit is radians; display in degrees. `display = value × 180/π`, suffix `°`. - Degrees, - /// Identity with no suffix — useful for dimensionless multipliers. - Raw, - /// Identity with `px` suffix — value is in canvas pixels. - Pixels, -} - -impl UnitType { - /// Convert from port-space to display-space. - pub fn to_display(self, value: f32) -> f32 { - match self { - Self::Normalized | Self::Raw | Self::Pixels => value, - Self::Percent => value * 100.0, - Self::Degrees => value * (180.0 / std::f32::consts::PI), - } - } - - /// Convert from display-space back to port-space. - pub fn from_display(self, display: f32) -> f32 { - match self { - Self::Normalized | Self::Raw | Self::Pixels => display, - Self::Percent => display / 100.0, - Self::Degrees => display * (std::f32::consts::PI / 180.0), - } - } - - /// Suffix string for display formatting. - pub fn suffix(self) -> &'static str { - match self { - Self::Normalized => "", - Self::Percent => "%", - Self::Degrees => "°", - Self::Raw => "", - Self::Pixels => "px", - } - } -} - /// Schema for a single port on a node type. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(bound = "")] @@ -202,8 +150,8 @@ pub struct PortDef { /// neutralizer (`reset_exposed_scrubs`) that targets every /// exposed scrub regardless of `preview_value`. /// - /// Canonical example: `paint.size` (0.1, so a huge brush's - /// preview still fits the small cursor mask and the editor + /// Canonical example: `brush_settings.size` (0.1, so a huge + /// brush's preview still fits the small cursor mask and the editor /// preview doesn't redraw on every size scrub). #[serde(default)] pub preview_value: Option, @@ -249,7 +197,7 @@ pub struct PortDef { /// stays "UI hint only, not enforced", and `with_natural_range` is the /// separate, explicit opt-in for wire-boundary range mapping. Most /// ports declare both with the same numbers; the two diverge for - /// over-drag sliders like `paint.size`, where the slider range is + /// over-drag sliders like `brush_settings.size`, where the range is /// a hint but the wire-side semantics are passthrough. #[serde(default)] pub natural_range: Option<(f32, f32)>, @@ -578,6 +526,17 @@ pub enum GraphError { InvalidIcon { icon: String, }, + /// A per-instance slider range was degenerate (`min == max`), inverted + /// (`min > max`), or non-finite. Every consumer of `PortDef::min`/`max` + /// normalizes with `(v - min) / (max - min)` and clamps with + /// `min(max, max(min, v))`, both of which break silently outside an + /// ascending finite range — so the invariant is enforced at the setter + /// rather than defended against at each reader. The offending values are + /// not carried: `GraphError` is `Eq`, and `f32` is not. + InvalidRange { + node: NodeId, + port: String, + }, } /// Accept only the byte shape Iconify names use (`prefix:name`): @@ -620,6 +579,13 @@ impl std::fmt::Display for GraphError { icon ) } + Self::InvalidRange { node, port } => { + write!( + f, + "slider range for '{}' on {:?} must be finite and ascending", + port, node + ) + } } } } @@ -1033,6 +999,52 @@ impl Graph { self.set_port_value(id, port_name, InputValue::Scalar(value)) } + /// Override an input port's slider bounds on this node instance, + /// narrowing (or widening, or re-centering) the range the registration + /// declared. The authored counterpart to [`Self::set_port_value`]: that + /// one sets *where the knob sits*, this one sets *how far it travels*. + /// + /// Both the brush bar and the node editor already read the instance + /// `PortDef::min`/`max`, so an override lands in every view at once — + /// which is why the range lives here rather than alongside the + /// brush-bar-only metadata in [`Graph::exposed_ports`]. A math node + /// whose registration declares `0..1` can therefore be given a bipolar + /// `-1..1` control by the brush author without a helper node in the + /// graph to recenter it. + /// + /// `min`/`max` are UI bounds only — nothing clamps the authored value to + /// them (see [`PortDef::min`]), so an existing out-of-range value + /// survives the override untouched until the user next scrubs. + pub fn set_port_range( + &mut self, + id: &NodeId, + port_name: &str, + min: f32, + max: f32, + ) -> Result<(), GraphError> { + if !min.is_finite() || !max.is_finite() || min >= max { + return Err(GraphError::InvalidRange { + node: id.clone(), + port: port_name.to_string(), + }); + } + let node = self + .nodes + .get_mut(id) + .ok_or_else(|| GraphError::NodeNotFound(id.clone()))?; + let port = node + .ports + .iter_mut() + .find(|p| p.name == port_name && p.dir == PortDir::Input) + .ok_or_else(|| GraphError::PortNotFound { + node: id.clone(), + port: port_name.to_string(), + })?; + port.min = min; + port.max = max; + Ok(()) + } + // Note: brush-bar exposure / label / description / icon overrides // live in `Graph::exposed_ports` now. Use `expose_port`, // `unexpose_port`, `set_exposed_port_meta`, and `reorder_exposed_port`. @@ -1481,54 +1493,6 @@ mod tests { assert_eq!(g2.nodes[&b].comment, ""); } - // ── UnitType tests ────────────────────────────────────────────── - - #[test] - fn unit_type_conversion_round_trip() { - for unit in [ - UnitType::Normalized, - UnitType::Percent, - UnitType::Degrees, - UnitType::Raw, - ] { - for &val in &[0.0, 0.25, 0.5, 0.75, 1.0] { - let display = unit.to_display(val); - let back = unit.from_display(display); - assert!( - (back - val).abs() < 1e-6, - "{:?}: to_display({}) = {}, from_display({}) = {} (expected {})", - unit, - val, - display, - display, - back, - val, - ); - } - } - } - - #[test] - fn unit_type_display_values() { - use std::f32::consts::PI; - assert!((UnitType::Percent.to_display(0.5) - 50.0).abs() < 1e-6); - // Degrees: wire unit is radians, display is degrees. - assert!((UnitType::Degrees.to_display(PI) - 180.0).abs() < 1e-4); - assert!((UnitType::Degrees.to_display(PI / 2.0) - 90.0).abs() < 1e-4); - assert!((UnitType::Degrees.to_display(0.0) - 0.0).abs() < 1e-6); - assert!((UnitType::Degrees.from_display(90.0) - PI / 2.0).abs() < 1e-4); - assert!((UnitType::Normalized.to_display(0.5) - 0.5).abs() < 1e-6); - assert!((UnitType::Raw.to_display(0.5) - 0.5).abs() < 1e-6); - } - - #[test] - fn unit_type_suffix() { - assert_eq!(UnitType::Percent.suffix(), "%"); - assert_eq!(UnitType::Degrees.suffix(), "°"); - assert_eq!(UnitType::Normalized.suffix(), ""); - assert_eq!(UnitType::Raw.suffix(), ""); - } - #[test] fn unit_type_serde_round_trip() { for unit in [ @@ -1717,6 +1681,97 @@ mod tests { assert_eq!(g.exposed_ports[&key].label, "Label"); } + #[test] + fn set_port_range_overrides_instance_bounds() { + let mut g = Graph::::new(); + let id = g.add_node("node", vec![scalar_in("val")]); + // `PortDef::input` starts at the 0..1 default. + let port = |g: &Graph| { + g.nodes()[&id] + .ports + .iter() + .find(|p| p.name == "val") + .map(|p| (p.min, p.max)) + .unwrap() + }; + assert_eq!(port(&g), (0.0, 1.0)); + + g.set_port_range(&id, "val", -1.0, 1.0).unwrap(); + assert_eq!(port(&g), (-1.0, 1.0)); + } + + #[test] + fn set_port_range_leaves_the_authored_value_alone() { + // Bounds are UI hints, not enforcement — narrowing the range around + // an out-of-band value must not silently rewrite it. + let mut g = Graph::::new(); + let id = g.add_node("node", vec![scalar_in("val")]); + g.set_port_default(&id, "val", 0.9).unwrap(); + g.set_port_range(&id, "val", 0.0, 0.5).unwrap(); + let port = g.nodes()[&id] + .ports + .iter() + .find(|p| p.name == "val") + .unwrap(); + assert_eq!(port.value.as_f32(), 0.9); + } + + #[test] + fn set_port_range_rejects_degenerate_and_inverted() { + let mut g = Graph::::new(); + let id = g.add_node("node", vec![scalar_in("val")]); + + for (min, max) in [ + (0.5, 0.5), + (1.0, 0.0), + (f32::NAN, 1.0), + (0.0, f32::INFINITY), + ] { + let err = g.set_port_range(&id, "val", min, max).unwrap_err(); + assert!( + matches!(err, GraphError::InvalidRange { .. }), + "({min}, {max}) should be rejected, got {err:?}" + ); + } + // Every rejection left the registration bounds intact. + let port = g.nodes()[&id] + .ports + .iter() + .find(|p| p.name == "val") + .unwrap(); + assert_eq!((port.min, port.max), (0.0, 1.0)); + } + + #[test] + fn set_port_range_rejects_outputs_and_unknown_ports() { + let mut g = Graph::::new(); + let id = g.add_node("node", vec![scalar_in("val"), scalar_out("result")]); + assert!(matches!( + g.set_port_range(&id, "result", 0.0, 2.0).unwrap_err(), + GraphError::PortNotFound { .. } + )); + assert!(matches!( + g.set_port_range(&id, "nope", 0.0, 2.0).unwrap_err(), + GraphError::PortNotFound { .. } + )); + } + + #[test] + fn port_range_survives_graph_json_round_trip() { + let mut g = Graph::::new(); + let id = g.add_node("node", vec![scalar_in("val")]); + g.set_port_range(&id, "val", -1.0, 1.0).unwrap(); + + let json = serde_json::to_string(&g).unwrap(); + let back: Graph = serde_json::from_str(&json).unwrap(); + let port = back.nodes()[&id] + .ports + .iter() + .find(|p| p.name == "val") + .unwrap(); + assert_eq!((port.min, port.max), (-1.0, 1.0)); + } + #[test] fn exposed_ports_round_trip_preserves_order() { let mut g = Graph::::new(); diff --git a/crates/darkly/src/nodegraph/mod.rs b/crates/darkly/src/nodegraph/mod.rs index 1747d19e..8793a463 100644 --- a/crates/darkly/src/nodegraph/mod.rs +++ b/crates/darkly/src/nodegraph/mod.rs @@ -11,9 +11,12 @@ mod layout; mod registration; pub use compiler::{ExecStep, ExecutionPlan, InputSlot}; +// `UnitType` is crate-level infrastructure (effect params declare one too); +// re-exported here because the node graph is where most callers reach it. +pub use crate::units::UnitType; pub use graph::{ exposed_port_key, Connection, ExposedPortMeta, FindTerminalError, Graph, GraphError, NodeId, - NodeInstance, PortDef, PortDir, PortRef, UnitType, + NodeInstance, PortDef, PortDir, PortRef, }; pub use layout::NodeLayout; pub use registration::NodeRegistration; diff --git a/crates/darkly/src/nodegraph/registration.rs b/crates/darkly/src/nodegraph/registration.rs index 4d17c1f0..92832756 100644 --- a/crates/darkly/src/nodegraph/registration.rs +++ b/crates/darkly/src/nodegraph/registration.rs @@ -2,6 +2,8 @@ use serde::Serialize; use super::graph::PortDef; use super::WireKind; +use crate::catalog::CatalogEntry; +use crate::gpu::preview::PreviewStaging; /// Static metadata describing a node type in a particular domain. /// @@ -19,7 +21,8 @@ pub struct NodeRegistration { pub type_id: &'static str, /// UI category for the add-node palette — describes what the node *does*, /// not how it executes. Current values: "input", "math", "modulate", - /// "color", "shape", "texture", "output", and "internal" (filtered out). + /// "color", "shape", "texture", "output". Nothing filters on it; every + /// registered node appears in the palette and in the catalog. pub category: &'static str, /// Human-readable name (e.g. "Pen Input", "Multiply"). pub display_name: &'static str, @@ -47,9 +50,34 @@ pub struct NodeRegistration { /// pixels (smudge, watercolor, liquify) override to `false` so the /// brush-tool options bar hides the erase toggle. pub supports_erase: bool, - /// Iconify icon shown in place of baked dab/stroke thumbnails for any - /// brush whose graph contains this node. Set by nodes whose output - /// depends on existing canvas content — stroking the flat preview - /// background renders blank, so the picker shows this icon instead. - pub preview_fallback_icon: Option<&'static str>, + /// How a preview of any brush containing this node must be staged. Set by + /// nodes whose output depends on existing canvas content — over a flat + /// preview background they render blank, so the stroke gets a field to + /// transport and the dab slot gets a glyph. `None` for a node that makes + /// its own marks, which is every node that does not sample the canvas. + pub preview_staging: Option, +} + +impl NodeRegistration { + /// This node type as one browsable catalog entry. + /// + /// Lives here rather than on a per-domain wrapper so a second node domain + /// gets its catalog for free — the domain contributes only the catalog's + /// identity. + /// + /// Ports are deliberately not projected into `params`: a port carries a + /// direction and a wire type, and + /// [`ParamInfo`](crate::engine::types::ParamInfo) has room for neither, so + /// the projection would present a node's outputs as settable parameters. + /// Documenting ports wants the registration serialized whole, which it + /// already can be, not flattened into the wrong shape. + /// + /// No icon: `preview_staging`'s glyph is a substitute for a brush preview + /// that cannot be baked, not a palette glyph, and only four node types + /// declare one. + pub fn catalog_entry(&self) -> CatalogEntry { + CatalogEntry::new(self.type_id, self.display_name) + .with_description(self.description) + .with_category(self.category) + } } diff --git a/crates/darkly/src/tool.rs b/crates/darkly/src/tool.rs index bf0f7159..7062732e 100644 --- a/crates/darkly/src/tool.rs +++ b/crates/darkly/src/tool.rs @@ -2,6 +2,7 @@ use std::any::{Any, TypeId}; use std::collections::HashMap; use std::sync::{Arc, OnceLock, RwLock}; +use crate::catalog::{Catalog, CatalogEntry}; use crate::gpu::params::ParamDef; /// What each tool module returns from its `register()` function. @@ -10,9 +11,47 @@ use crate::gpu::params::ParamDef; pub struct ToolRegistration { pub type_id: &'static str, pub display_name: &'static str, + /// Iconify name for the toolbar button. A tool whose glyph depends on + /// session state (the brush's eraser mode) overrides this in its frontend + /// descriptor; this is the registry's own, state-free answer. + pub icon: &'static str, + /// One-sentence summary of what the tool does on the canvas — the toolbar + /// tooltip and the reference manual's row for it. + pub description: &'static str, + /// Id of the action that selects this tool. Bindings in + /// `presets/*.yaml` name this string, and it is deliberately not derived + /// from `type_id` — `colorpicker` binds `colorPickerTool`. + pub hotkey_action: &'static str, pub params: &'static [ParamDef], } +/// Id of the catalog this registry projects into. +pub const CATALOG_ID: &str = "tools"; + +impl ToolRegistration { + pub fn catalog_entry(&self) -> CatalogEntry { + CatalogEntry::new(self.type_id, self.display_name) + .with_icon(self.icon) + .with_description(self.description) + .with_hotkey_action(self.hotkey_action) + .with_params(self.params) + } +} + +/// The tool catalog — every registered tool, sorted by `type_id`. +pub fn catalog() -> Catalog { + Catalog::new( + CATALOG_ID, + "Tools", + registry() + .types() + .into_iter() + .map(ToolRegistration::catalog_entry) + .collect(), + ) + .with_description("What a pointer does on the canvas — painting, filling, picking, selecting.") +} + /// Auto-discovered tool registry. Owns the human-friendly display name surface /// the UI consumes, plus the parameter-definition lookup used by the engine. pub struct ToolRegistry { @@ -20,8 +59,10 @@ pub struct ToolRegistry { } struct ToolEntry { - display_name: &'static str, - params: &'static [ParamDef], + /// The full registration this entry was built from. All metadata accessors + /// read straight off this, so a new `ToolRegistration` field is exposed + /// without widening any tuple or touching the registry. + reg: ToolRegistration, } impl Default for ToolRegistry { @@ -34,13 +75,7 @@ impl ToolRegistry { pub fn new() -> Self { let mut entries = HashMap::new(); for reg in crate::tools::registrations() { - entries.insert( - reg.type_id, - ToolEntry { - display_name: reg.display_name, - params: reg.params, - }, - ); + entries.insert(reg.type_id, ToolEntry { reg }); } ToolRegistry { entries } } @@ -48,23 +83,23 @@ impl ToolRegistry { pub fn display_name(&self, type_id: &str) -> &'static str { self.entries .get(type_id) - .map(|e| e.display_name) + .map(|e| e.reg.display_name) .unwrap_or("") } pub fn param_defs(&self, type_id: &str) -> &'static [ParamDef] { - self.entries.get(type_id).map(|e| e.params).unwrap_or(&[]) - } - - /// Return every registered tool as `(type_id, display_name, params)`, - /// sorted by `type_id` for deterministic output. - pub fn types(&self) -> Vec<(&'static str, &'static str, &'static [ParamDef])> { - let mut v: Vec<_> = self - .entries - .iter() - .map(|(&id, e)| (id, e.display_name, e.params)) - .collect(); - v.sort_by_key(|(id, _, _)| *id); + self.entries + .get(type_id) + .map(|e| e.reg.params) + .unwrap_or(&[]) + } + + /// Return every registered tool's full [`ToolRegistration`], sorted by + /// `type_id` for deterministic output. Callers read whatever fields they + /// need off the registration — a new field is free here. + pub fn types(&self) -> Vec<&ToolRegistration> { + let mut v: Vec<&ToolRegistration> = self.entries.values().map(|e| &e.reg).collect(); + v.sort_by_key(|reg| reg.type_id); v } } diff --git a/crates/darkly/src/tools/brush.rs b/crates/darkly/src/tools/brush.rs index 189a352a..3c461a51 100644 --- a/crates/darkly/src/tools/brush.rs +++ b/crates/darkly/src/tools/brush.rs @@ -4,6 +4,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "brush", display_name: "Brush", + icon: "fa6-solid:paintbrush", + description: "Paint strokes with the active brush.", + hotkey_action: "brushTool", params: &[], } } diff --git a/crates/darkly/src/tools/colorpicker.rs b/crates/darkly/src/tools/colorpicker.rs index 749c7aaf..54f76ad9 100644 --- a/crates/darkly/src/tools/colorpicker.rs +++ b/crates/darkly/src/tools/colorpicker.rs @@ -4,6 +4,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "colorpicker", display_name: "Color Picker", + icon: "fa6-solid:eye-dropper", + description: "Sample a color from the canvas into the foreground swatch.", + hotkey_action: "colorPickerTool", params: &[], } } diff --git a/crates/darkly/src/tools/ellipse_select.rs b/crates/darkly/src/tools/ellipse_select.rs index d5888a03..7e950468 100644 --- a/crates/darkly/src/tools/ellipse_select.rs +++ b/crates/darkly/src/tools/ellipse_select.rs @@ -6,6 +6,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "ellipse_select", display_name: "Ellipse Select", + icon: "lucide:circle-dashed", + description: "Select an elliptical region.", + hotkey_action: "ellipseSelectTool", params: &[], } } diff --git a/crates/darkly/src/tools/fill.rs b/crates/darkly/src/tools/fill.rs index c302f709..6677c829 100644 --- a/crates/darkly/src/tools/fill.rs +++ b/crates/darkly/src/tools/fill.rs @@ -4,6 +4,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "fill", display_name: "Fill", + icon: "fa6-solid:fill-drip", + description: "Flood-fill a contiguous region with the foreground color.", + hotkey_action: "fillTool", params: &[], } } diff --git a/crates/darkly/src/tools/gradient.rs b/crates/darkly/src/tools/gradient.rs index 2d6dc249..7beade14 100644 --- a/crates/darkly/src/tools/gradient.rs +++ b/crates/darkly/src/tools/gradient.rs @@ -4,6 +4,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "gradient", display_name: "Gradient", + icon: "boxicons:gradient", + description: "Drag out a smooth ramp between two or more colors.", + hotkey_action: "gradientTool", params: &[], } } diff --git a/crates/darkly/src/tools/lasso_select.rs b/crates/darkly/src/tools/lasso_select.rs index bffa283d..d12fcf47 100644 --- a/crates/darkly/src/tools/lasso_select.rs +++ b/crates/darkly/src/tools/lasso_select.rs @@ -6,6 +6,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "lasso_select", display_name: "Lasso Select", + icon: "tabler:lasso", + description: "Select a region by drawing its outline freehand.", + hotkey_action: "lassoSelectTool", params: &[], } } diff --git a/crates/darkly/src/tools/magic_wand.rs b/crates/darkly/src/tools/magic_wand.rs index 019b0e26..1844639e 100644 --- a/crates/darkly/src/tools/magic_wand.rs +++ b/crates/darkly/src/tools/magic_wand.rs @@ -4,6 +4,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "magic_wand", display_name: "Magic Wand", + icon: "fa6-solid:wand-magic-sparkles", + description: "Select a contiguous region of similar color.", + hotkey_action: "magicWandTool", params: &[], } } diff --git a/crates/darkly/src/tools/polygon_select.rs b/crates/darkly/src/tools/polygon_select.rs index 50b996f9..9bb6af78 100644 --- a/crates/darkly/src/tools/polygon_select.rs +++ b/crates/darkly/src/tools/polygon_select.rs @@ -4,6 +4,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "polygon_select", display_name: "Polygon Select", + icon: "lucide:triangle-dashed", + description: "Select a region by clicking its corners one at a time.", + hotkey_action: "polygonSelectTool", params: &[], } } diff --git a/crates/darkly/src/tools/rect_select.rs b/crates/darkly/src/tools/rect_select.rs index 5dd6e939..d8d7255b 100644 --- a/crates/darkly/src/tools/rect_select.rs +++ b/crates/darkly/src/tools/rect_select.rs @@ -6,6 +6,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "rect_select", display_name: "Rectangle Select", + icon: "boxicons:square-dashed", + description: "Select a rectangular region.", + hotkey_action: "rectSelectTool", params: &[], } } diff --git a/crates/darkly/src/tools/text.rs b/crates/darkly/src/tools/text.rs index 23bf5ecb..e199f71e 100644 --- a/crates/darkly/src/tools/text.rs +++ b/crates/darkly/src/tools/text.rs @@ -4,6 +4,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "text", display_name: "Text", + icon: "at-icons:text", + description: "Place and edit editable text on a vector layer.", + hotkey_action: "textTool", // Font size / color / alignment are driven from the frontend options // panel and passed per-request, so the tool itself declares no params. params: &[], diff --git a/crates/darkly/src/tools/transform.rs b/crates/darkly/src/tools/transform.rs index fc8f0973..05f200cd 100644 --- a/crates/darkly/src/tools/transform.rs +++ b/crates/darkly/src/tools/transform.rs @@ -4,6 +4,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "transform", display_name: "Transform", + icon: "fa6-solid:up-down-left-right", + description: "Move, scale and rotate the active layer or selection.", + hotkey_action: "transformTool", params: &[], } } diff --git a/crates/darkly/src/tools/transform_perspective.rs b/crates/darkly/src/tools/transform_perspective.rs index a9ee22d5..a99259a3 100644 --- a/crates/darkly/src/tools/transform_perspective.rs +++ b/crates/darkly/src/tools/transform_perspective.rs @@ -8,6 +8,9 @@ pub fn register() -> ToolRegistration { ToolRegistration { type_id: "transform_perspective", display_name: "Perspective Transform", + icon: "tabler:perspective", + description: "Reshape the active layer by dragging its four corners independently.", + hotkey_action: "transformPerspectiveTool", params: &[], } } diff --git a/crates/darkly/src/units.rs b/crates/darkly/src/units.rs new file mode 100644 index 00000000..1f7aabdd --- /dev/null +++ b/crates/darkly/src/units.rs @@ -0,0 +1,112 @@ +//! Display units for numeric values. +//! +//! Module-generic infrastructure: node-graph ports and effect parameters both +//! declare one, and both render through the same conversion + suffix table. It +//! lives at the crate root rather than under `nodegraph` because neither owns +//! it. + +use serde::{Deserialize, Serialize}; + +/// Display unit for a numeric value. +/// +/// Defines how a stored value is converted for display in the UI. The +/// conversion methods use `f32` math — any numeric type (Scalar, Int) can +/// round-trip through them. Non-numeric types (Bool, Color) ignore this +/// field. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +pub enum UnitType { + /// Identity — display and internal are both raw values (shown as `0.50`). + #[default] + Normalized, + /// Display as percentage: `display = value × 100`, suffix `%`. + Percent, + /// Wire unit is radians; display in degrees. `display = value × 180/π`, suffix `°`. + Degrees, + /// Identity with no suffix — useful for dimensionless multipliers. + Raw, + /// Identity with `px` suffix — value is in canvas pixels. + Pixels, +} + +impl UnitType { + /// Convert from port-space to display-space. + pub fn to_display(self, value: f32) -> f32 { + match self { + Self::Normalized | Self::Raw | Self::Pixels => value, + Self::Percent => value * 100.0, + Self::Degrees => value * (180.0 / std::f32::consts::PI), + } + } + + /// Convert from display-space back to port-space. + pub fn from_display(self, display: f32) -> f32 { + match self { + Self::Normalized | Self::Raw | Self::Pixels => display, + Self::Percent => display / 100.0, + Self::Degrees => display * (std::f32::consts::PI / 180.0), + } + } + + /// Suffix string for display formatting. + pub fn suffix(self) -> &'static str { + match self { + Self::Normalized => "", + Self::Percent => "%", + Self::Degrees => "°", + Self::Raw => "", + Self::Pixels => "px", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unit_type_conversion_round_trip() { + for unit in [ + UnitType::Normalized, + UnitType::Percent, + UnitType::Degrees, + UnitType::Raw, + ] { + for &val in &[0.0, 0.25, 0.5, 0.75, 1.0] { + let display = unit.to_display(val); + let back = unit.from_display(display); + assert!( + (back - val).abs() < 1e-6, + "{:?}: to_display({}) = {}, from_display({}) = {} (expected {})", + unit, + val, + display, + display, + back, + val, + ); + } + } + } + + #[test] + fn unit_type_display_values() { + use std::f32::consts::PI; + assert!((UnitType::Percent.to_display(0.5) - 50.0).abs() < 1e-6); + // Degrees: wire unit is radians, display is degrees. + assert!((UnitType::Degrees.to_display(PI) - 180.0).abs() < 1e-4); + assert!((UnitType::Degrees.to_display(PI / 2.0) - 90.0).abs() < 1e-4); + assert!((UnitType::Degrees.to_display(0.0) - 0.0).abs() < 1e-6); + assert!((UnitType::Degrees.from_display(90.0) - PI / 2.0).abs() < 1e-4); + assert!((UnitType::Normalized.to_display(0.5) - 0.5).abs() < 1e-6); + assert!((UnitType::Raw.to_display(0.5) - 0.5).abs() < 1e-6); + } + + #[test] + fn unit_type_suffix() { + assert_eq!(UnitType::Percent.suffix(), "%"); + assert_eq!(UnitType::Degrees.suffix(), "°"); + assert_eq!(UnitType::Normalized.suffix(), ""); + assert_eq!(UnitType::Raw.suffix(), ""); + } +} diff --git a/crates/darkly/tests/blur.rs b/crates/darkly/tests/blur.rs index 2a22c084..1d8933b5 100644 --- a/crates/darkly/tests/blur.rs +++ b/crates/darkly/tests/blur.rs @@ -96,7 +96,13 @@ fn render_blur_dabs(size: f32, strength: f32, opacity: f32, dabs: &[[f32; 2]]) - &queue, &darkly::gpu::selection::selection_mask_bgl(&device), ); - let mut stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines); + let mut stroke_buffer = StrokeBuffer::new( + &device, + CANVAS, + CANVAS, + &pipelines, + darkly::brush::node::COLOR_SCRATCH_FORMAT, + ); let pre_stroke = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture( &layer_texture, diff --git a/crates/darkly/tests/brush_begin_stroke_lifecycle.rs b/crates/darkly/tests/brush_begin_stroke_lifecycle.rs index 162d47fa..77edfdb8 100644 --- a/crates/darkly/tests/brush_begin_stroke_lifecycle.rs +++ b/crates/darkly/tests/brush_begin_stroke_lifecycle.rs @@ -100,7 +100,10 @@ fn run_begin_stroke(graph: &Graph, setup: Setup) -> Vec { // which is the exact bundle `StrokeResources` wants. Whichever lifecycle // the terminal declares, only one of the two textures is read — the // other is harmlessly present. - let mut stroke_buffer = StrokeBuffer::new(&device, W, H, &pipelines); + // Compile first: the terminal's declared scratch format decides how + // the scratch is allocated, and a warp terminal's is not colour. + let mut runner: BrushGraphRunner = compile_graph(graph).expect("brush compiles"); + let mut stroke_buffer = StrokeBuffer::new(&device, W, H, &pipelines, runner.scratch_format()); // Dummy paint target — `apply_lifecycle` never reads it, but the new // `StrokeResources` shape requires it. Reuse the pre-stroke texture as // a stand-in (same RGBA8 / W×H format). @@ -133,7 +136,6 @@ fn run_begin_stroke(graph: &Graph, setup: Setup) -> Vec { } }; - let mut runner: BrushGraphRunner = compile_graph(graph).expect("brush compiles"); let encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("begin-stroke"), }); @@ -173,7 +175,7 @@ fn run_begin_stroke(graph: &Graph, setup: Setup) -> Vec { &device, &queue, stroke_buffer.scratch().write_texture(), - wgpu::TextureFormat::Rgba8Unorm, + stroke_buffer.scratch().format(), W, H, ) @@ -248,8 +250,34 @@ fn smudge_terminal_seeds_scratch_from_pre_stroke() { assert_all(&rgba, SENTINEL_RGBA); } +/// Liquify's scratch holds a displacement field, not colour, so its +/// prologue clears rather than seeds — a transparent clear *is* a zero +/// field, and a zero field resolves to the pre-stroke image unchanged. +/// Seeding colour into it would be meaningless (and would decode as +/// enormous bogus displacements). +/// +/// Pre-fill with a non-zero field first, so a missed clear is visible: +/// stale displacement surviving into a new stroke would warp the image +/// before the user has moved the pen. #[test] -fn liquify_terminal_seeds_scratch_from_pre_stroke() { - let rgba = run_begin_stroke(&builtin_graph("Liquify"), Setup::PreStrokeWithSentinel); - assert_all(&rgba, SENTINEL_RGBA); +fn liquify_terminal_clears_scratch_to_zero_field() { + let raw = run_begin_stroke( + &builtin_graph("Liquify"), + Setup::ScratchPrefilled(wgpu::Color { + r: 37.0, + g: -19.0, + b: 0.0, + a: 0.0, + }), + ); + let field: Vec = raw + .chunks_exact(4) + .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])) + .collect(); + let worst = field.iter().cloned().fold(0.0_f32, |a, b| a.max(b.abs())); + assert_eq!( + worst, 0.0, + "begin_stroke must leave liquify's warp field at zero; largest \ + surviving displacement was {worst} px", + ); } diff --git a/crates/darkly/tests/brush_editor_preview.rs b/crates/darkly/tests/brush_editor_preview.rs index 4e8bfb7f..9e8b3b57 100644 --- a/crates/darkly/tests/brush_editor_preview.rs +++ b/crates/darkly/tests/brush_editor_preview.rs @@ -10,6 +10,7 @@ use darkly::brush::{ pipeline::BrushPipelines, preview_renderer::{synthesize_stroke_path, BrushStrokePreviewRenderer}, }; +use darkly::gpu::preview::PreviewBackdrop; use darkly::gpu::test_utils::{readback_texture, test_device}; #[test] @@ -32,7 +33,17 @@ fn renders_s_curve_over_black_background() { let texture = renderer .render_stroke( - &device, &queue, &pipelines, &graph, &path, fg, bg, width, height, None, + &device, + &queue, + &pipelines, + &graph, + &path, + fg, + bg, + PreviewBackdrop::Flat, + width, + height, + None, ) .expect("render_stroke should return a texture for the default graph"); @@ -114,6 +125,7 @@ fn renderer_reuses_target_across_renders_of_same_size() { &path, [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0], + PreviewBackdrop::Flat, 320, 120, None, @@ -129,6 +141,7 @@ fn renderer_reuses_target_across_renders_of_same_size() { &path, [1.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0], + PreviewBackdrop::Flat, 320, 120, None, @@ -368,7 +381,7 @@ fn airbrush_endpoint_dabs_not_clipped_against_cache_border() { /// 100 ms while the user drags the slider (~1 GB/s of GPU work for no /// visible effect). /// -/// The fix declares stabilize via `with_preview_value(0.0)` and routes +/// The fix declares stabilize via `preview_irrelevant_scrub()` and routes /// scrubs on any preview-irrelevant port through /// `ChangeKind::PreviewIrrelevantScrub`, which skips the version bump. /// Asserted against the public `brush_graph_version()` getter, with a @@ -440,8 +453,8 @@ fn stabilize_scrub_does_not_bump_editor_preview_version() { ); } -/// Regression: scrubbing `paint.size` (or any port flagged with -/// `preview_value`) must not invalidate the editor-preview cache. +/// Regression: scrubbing `brush_settings.size` — or any port flagged with +/// `preview_value` — must not invalidate the editor-preview cache. /// /// The previous "continued charcoal debugging" attempt deleted the /// caller-side `graph.apply_preview_overrides()` on the stroke-preview @@ -453,13 +466,22 @@ fn stabilize_scrub_does_not_bump_editor_preview_version() { /// stroke preview now mutated visibly on every size scrub. /// /// Both previews share the same intent: brush identity, not momentary -/// scrub state. This test pins the restored behavior: a size scrub -/// on the active brush must not bump `brush_graph_version` (which +/// scrub state. This test pins the restored behavior: a scrub of a pinned +/// port on the active brush must not bump `brush_graph_version` (which /// is what gates the editor preview cache) — because the renderer /// neutralizes `preview_value` ports before rendering, so the output /// is identical anyway. +/// +/// `blur.strength` is covered alongside `brush_settings.size` because the +/// two pins cost different things and only one of them is free. A size +/// scrub was never legible in a preview rendered at a canonical size, so +/// nothing was lost; a strength scrub *was* visible before the port was +/// pinned, and freezing it is a deliberate trade — a stroke preview says +/// what kind of brush this is, not what one parameter is currently set to. +/// Asserting it here is what records that as intended rather than +/// accidental. #[test] -fn size_scrub_does_not_bump_editor_preview_version() { +fn pinned_ports_are_pinned_in_previews() { use darkly::engine::DarklyEngine; use darkly::gpu::context::GpuContext; @@ -467,32 +489,35 @@ fn size_scrub_does_not_bump_editor_preview_version() { let gpu = GpuContext::new_headless(device, queue); let mut engine = DarklyEngine::new(gpu, 1024, 768); - engine.brush_load("Ink Pen").expect("Ink Pen built-in"); - let _ = engine.brush_stroke_preview(); - engine.test_flush_readbacks(); - let v_before = engine.brush_graph_version(); - - let size = engine - .brush_exposed_ports() - .into_iter() - .find(|p| p.port_name == "size") - .expect("Ink Pen exposes a `size` port"); - engine - .brush_set_exposed_port(&size.node_id, "size", 90.0) - .expect("scrub set"); - - assert_eq!( - engine.brush_graph_version(), - v_before, - "scrubbing `size` must not bump brush_graph_version. \ - `paint.size` is flagged `preview_value` (= 0.1), and the \ - stroke-preview render path applies `apply_preview_overrides` \ - to neutralize it before rendering — so the editor preview's \ - output cannot change in response to the scrub, and \ - invalidating its cache would just trigger a wasted \ - full-stroke re-render and a visible blink as the new size \ - briefly shows." - ); + for (brush, port, scrub_to) in [("Ink Pen", "size", 90.0), ("Blur", "strength", 90.0)] { + engine + .brush_load(brush) + .unwrap_or_else(|e| panic!("'{brush}' is a built-in brush: {e}")); + let _ = engine.brush_stroke_preview(); + engine.test_flush_readbacks(); + let v_before = engine.brush_graph_version(); + + let exposed = engine + .brush_exposed_ports() + .into_iter() + .find(|p| p.port_name == port) + .unwrap_or_else(|| panic!("'{brush}' exposes a `{port}` port")); + engine + .brush_set_exposed_port(&exposed.node_id, port, scrub_to) + .expect("scrub set"); + + assert_eq!( + engine.brush_graph_version(), + v_before, + "scrubbing '{brush}' `{port}` must not bump brush_graph_version. \ + The port is flagged `preview_value`, and the stroke-preview render \ + path applies `apply_preview_overrides` to neutralize it before \ + rendering — so the editor preview's output cannot change in \ + response to the scrub, and invalidating its cache would just \ + trigger a wasted full-stroke re-render and a visible blink as the \ + new value briefly shows." + ); + } } /// Assert no pixel on the outermost border row/column carries ink — i.e. the @@ -621,6 +646,7 @@ fn empty_path_returns_none() { &[], [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0], + PreviewBackdrop::Flat, 320, 120, None, diff --git a/crates/darkly/tests/brush_picker_backend.rs b/crates/darkly/tests/brush_picker_backend.rs index e115111d..64bb7dd0 100644 --- a/crates/darkly/tests/brush_picker_backend.rs +++ b/crates/darkly/tests/brush_picker_backend.rs @@ -389,8 +389,8 @@ fn set_node_comment_does_not_advance_topology_version() { /// The active-brush capabilities drive two frontend behaviours: the /// erase toggle in the brush-tool options bar (`supports_erase`) and /// the live preview strips' iconify fallback (`preview_fallback_icon`, -/// shown instead of the baked thumbnails for content-dependent -/// brushes). One engine across all three loads to avoid extra wgpu +/// shown in the dab slot instead of a baked thumbnail for +/// content-dependent brushes). One engine across all three loads to avoid extra wgpu /// devices in parallel — see /// `size_scrub_does_not_change_active_dab_pixels`. #[test] @@ -403,6 +403,11 @@ fn brush_active_capabilities_reflect_loaded_brush() { let caps = engine.brush_active_capabilities(); assert!(caps.supports_erase, "Clone's paint terminal honours erase"); assert_eq!(caps.preview_fallback_icon, Some("fa6-solid:clone")); + assert_eq!( + caps.preview_backdrop, + darkly::gpu::preview::PreviewBackdrop::Stripes, + "Clone's stroke preview is staged over a field it can transport" + ); engine .brush_load("Smudge") @@ -423,6 +428,11 @@ fn brush_active_capabilities_reflect_loaded_brush() { caps.preview_fallback_icon, None, "content-free brushes keep the baked previews" ); + assert_eq!( + caps.preview_backdrop, + darkly::gpu::preview::PreviewBackdrop::Flat, + "a brush that deposits pigment needs nothing staged under it" + ); } #[test] diff --git a/crates/darkly/tests/brush_preserves_pixels_outside_dab.rs b/crates/darkly/tests/brush_preserves_pixels_outside_dab.rs index 95cb1054..460a1e04 100644 --- a/crates/darkly/tests/brush_preserves_pixels_outside_dab.rs +++ b/crates/darkly/tests/brush_preserves_pixels_outside_dab.rs @@ -65,7 +65,13 @@ fn render_one_dab(brush_name: &str, color: [f32; 4], canvas: &[u8]) -> Vec { &queue, &darkly::gpu::selection::selection_mask_bgl(&device), ); - let mut stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines); + // Compile before allocating — the terminal owns the scratch format, + // and a warp terminal's scratch holds a displacement field, not + // colour. Hardcoding a colour format here silently gives liquify the + // wrong surface and wipes the layer. + let mut runner: BrushGraphRunner = compile_graph(&graph).expect("brush compiles"); + let mut stroke_buffer = + StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines, runner.scratch_format()); let pre_stroke = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture( &layer_texture, @@ -79,7 +85,6 @@ fn render_one_dab(brush_name: &str, color: [f32; 4], canvas: &[u8]) -> Vec { stroke_buffer.save_pre_stroke(&device, &mut enc, &pipelines, &pre_stroke); queue.submit([enc.finish()]); - let mut runner: BrushGraphRunner = compile_graph(&graph).expect("brush compiles"); macro_rules! make_ctx { ($label:expr) => {{ let (scratch, pre_stroke_tex, pre_stroke_bg, source_override) = diff --git a/crates/darkly/tests/brush_preview_staging.rs b/crates/darkly/tests/brush_preview_staging.rs new file mode 100644 index 00000000..895ee7f8 --- /dev/null +++ b/crates/darkly/tests/brush_preview_staging.rs @@ -0,0 +1,375 @@ +//! A brush whose output depends on canvas content it did not write must still +//! show a stroke in its preview. +//! +//! Four shipped brushes — Liquify, Smudge, Blur, Clone — transport the +//! destination rather than writing to it. Over the flat preview background they +//! transported a constant and their baked stroke thumbnails were, pixel for +//! pixel, that same constant. The fix is a field declared by the node and +//! painted under the stroke; these tests are what holds it. + +use darkly::brush::builtin_brushes; +use darkly::engine::DarklyEngine; +use darkly::gpu::context::GpuContext; +use darkly::gpu::preview::{pixel_centre, PreviewBackdrop}; +use darkly::gpu::test_utils::test_device; + +/// Brushes whose graphs sample the canvas, and the glyph each declares. +const STAGED: [(&str, &str); 4] = [ + ("Liquify", "tabler:ripple"), + ("Smudge", "mdi:gesture-swipe"), + ("Blur", "mdi:blur"), + ("Clone", "fa6-solid:clone"), +]; + +const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; + +fn fresh_engine() -> DarklyEngine { + let (device, queue) = test_device(); + let gpu = GpuContext::new_headless(device, queue); + let mut engine = DarklyEngine::new(gpu, 1024, 768); + engine.set_preview_theme(WHITE, BLACK); + engine +} + +/// One brush's framed stroke thumbnail, decoded to RGBA8. +fn stroke_thumbnail(engine: &mut DarklyEngine, name: &str) -> (Vec, u32, u32) { + let _ = engine.brush_thumbnail(name); + engine.test_flush_readbacks(); + let png = engine.brush_thumbnail(name); + assert!(!png.is_empty(), "no thumbnail baked for '{name}'"); + let img = image::load_from_memory(&png) + .unwrap_or_else(|e| panic!("'{name}' thumbnail is not a valid PNG: {e}")) + .to_rgba8(); + let (w, h) = img.dimensions(); + (img.into_raw(), w, h) +} + +fn luminance(px: &[u8]) -> f32 { + 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32 +} + +/// Standard deviation of luminance. `0.0` means every pixel is the same colour. +fn luminance_sd(pixels: &[u8]) -> f32 { + let lums: Vec = pixels.chunks_exact(4).map(luminance).collect(); + let mean = lums.iter().sum::() / lums.len() as f32; + (lums.iter().map(|l| (l - mean).powi(2)).sum::() / lums.len() as f32).sqrt() +} + +/// Comfortably above readback and resize noise, and far below the ~34 levels of +/// standard deviation the staged bands alone carry. +const VISIBLE: f32 = 12.0; + +/// What the stroke did, isolated from what it was staged over. +/// +/// The **raw** render canvas, before the framer crops it, compared pixel by +/// pixel against a CPU evaluation of the backdrop the render was staged over — +/// the same `sample()` the framer itself compares against, at the same +/// tolerance. Every texel the stroke did not touch round-trips through +/// `write_texture` → `save_pre_stroke` → `color_output::commit` bit-identically, +/// so a pixel outside the tolerance here is the stroke and nothing else. +/// +/// That is what makes this backdrop-agnostic: a preview that shows nothing but +/// its own backdrop scores exactly zero whatever the backdrop is, and swapping +/// the field for the next one moves the numbers without invalidating the idea. +struct Stroke { + /// Pixels the stroke changed, as a fraction of the render canvas. + fraction: f32, + /// Their bounding box, in render pixels. + bbox: (u32, u32), +} + +fn measure_stroke(engine: &mut DarklyEngine, backdrop: PreviewBackdrop) -> Stroke { + /// The framer's own tolerance — accommodates premultiplied-alpha rounding. + const TOLERANCE: i32 = 12; + let (pixels, w, h) = engine.test_render_stroke_preview_canvas(); + let mut changed = 0usize; + let (mut min_x, mut min_y, mut max_x, mut max_y) = (u32::MAX, u32::MAX, 0u32, 0u32); + for y in 0..h { + for x in 0..w { + let i = ((y * w + x) * 4) as usize; + let (u, v) = pixel_centre(x, y, w, h); + let want = backdrop.sample(u, v, WHITE, BLACK); + let differs = (0..3).any(|c| { + let want = (want[c].clamp(0.0, 1.0) * 255.0).round() as i32; + (pixels[i + c] as i32 - want).abs() > TOLERANCE + }); + if differs { + changed += 1; + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + } + Stroke { + fraction: changed as f32 / (w * h) as f32, + bbox: if changed == 0 { + (0, 0) + } else { + (max_x - min_x + 1, max_y - min_y + 1) + }, + } +} + +/// Floor on how much of the render canvas the stroke must change. +/// +/// Measured over the shipped backdrop: Blur — the weakest of the four, and the +/// one this floor exists for — changes 0.121 %, clearing it by 2.4×, and +/// Liquify, Smudge and Clone clear it by 8.8× / 15.9× / 29.6×. Blur *without* +/// its preview pin changes 0.022 %, less than half the floor. +/// +/// A stripe field only responds where an operator's action crosses a band edge, +/// so these numbers are much closer together than they would be over a field +/// carrying every spatial frequency. That is the accepted cost of a backdrop +/// whose *rendered* strokes read better; see [`PreviewBackdrop::Stripes`]. +const MIN_CHANGED_FRACTION: f32 = 0.0005; + +/// Floor on the stroke's bounding box, in render pixels. +/// +/// The framer crops to this box, so it is the only machine check left on what +/// the thumbnail is a picture *of*: a stroke that only registers in patches +/// crops to a fragment blown up to fill the tile, which is how the original bug +/// looked once it stopped being invisible — unpinned Blur's 194 × 35 becomes a +/// tile showing two stripes and no stroke. Unlike the fraction above it cannot +/// carry a large margin: the S-curve's own extent is the ceiling, and the +/// weakest brush measures 323 × 49 against Liquify's 397 × 97. +const MIN_BBOX: (u32, u32) = (256, 40); + +/// **The regression.** Every content-dependent brush's stroke preview shows a +/// stroke: measured against its own backdrop, on the canvas the framer reads, +/// there is one and it spans the path. +#[test] +fn content_dependent_brushes_render_a_visible_stroke() { + let mut engine = fresh_engine(); + for (name, _) in STAGED { + engine + .brush_load(name) + .unwrap_or_else(|e| panic!("'{name}' is a built-in brush: {e}")); + let backdrop = + darkly::brush::graph_capabilities(&engine.active_brush_graph()).preview_backdrop; + let stroke = measure_stroke(&mut engine, backdrop); + assert!( + stroke.fraction > MIN_CHANGED_FRACTION, + "'{name}' changed {:.4}% of its preview canvas — its stroke is its \ + own backdrop showing through", + stroke.fraction * 100.0, + ); + assert!( + stroke.bbox.0 >= MIN_BBOX.0 && stroke.bbox.1 >= MIN_BBOX.1, + "'{name}' changed only a {}x{} patch, so the framer crops a \ + fragment rather than the stroke", + stroke.bbox.0, + stroke.bbox.1, + ); + } +} + +/// **Part of the same regression, and the feature test for the preview pin.** +/// Blur's shipped strength puts a sub-pixel kernel against a ~36 px preview dab, +/// so the stroke registers only in patches and the framer crops one of them. +/// The port declares a `preview_value` so the preview renders at a strength that +/// marks the whole S-curve. Without it Blur fails both thresholds above. +#[test] +fn the_preview_pin_is_what_makes_blur_read() { + let mut engine = fresh_engine(); + engine.brush_load("Blur").expect("Blur is a built-in brush"); + + let pinned = darkly::brush::registry() + .get("blur") + .expect("the blur node is registered") + .node + .ports + .iter() + .find(|p| p.name == "strength") + .expect("blur declares a strength port") + .preview_value; + let pinned = pinned.expect("blur.strength is pinned for previews"); + assert!( + pinned > 0.05, + "a pin at or below the shipped default would render the same \ + sub-pixel kernel the preview cannot show" + ); + + let stroke = measure_stroke(&mut engine, PreviewBackdrop::Stripes); + assert!( + stroke.fraction > 2.0 * MIN_CHANGED_FRACTION, + "Blur's pinned preview changed {:.4}% of the canvas — the pin is \ + supposed to leave the floor twice as much headroom as it needs", + stroke.fraction * 100.0, + ); + assert!( + stroke.bbox.0 >= 320 && stroke.bbox.1 >= 45, + "Blur's pinned preview marks a {}x{} box — the pin exists to grow it \ + past the point where the framer crops a fragment, which the strength \ + sweep puts at ~0.15", + stroke.bbox.0, + stroke.bbox.1, + ); +} + +/// Every brush that deposits pigment keeps the flat clear, so nothing about its +/// preview changes. Ten of the fourteen shipped brushes. +#[test] +fn depositing_brushes_stage_nothing() { + let staged: Vec<&str> = STAGED.iter().map(|(n, _)| *n).collect(); + let mut flat = 0; + for brush in builtin_brushes::all() { + let caps = darkly::brush::graph_capabilities(&brush.metadata.graph); + let name = &brush.metadata.name; + if staged.contains(&name.as_str()) { + assert_eq!( + caps.preview_backdrop, + PreviewBackdrop::Stripes, + "'{name}' samples the canvas and must be staged" + ); + } else { + assert_eq!( + caps.preview_backdrop, + PreviewBackdrop::Flat, + "'{name}' deposits pigment and must keep the flat clear" + ); + flat += 1; + } + } + assert_eq!(flat, 10, "ten shipped brushes deposit pigment"); +} + +/// A `Flat` backdrop is the theme background at every position — which is what +/// makes the fast path bit-identical to the clear it replaced, and what lets the +/// framer evaluate `sample()` unconditionally instead of branching. +#[test] +fn flat_is_the_background_everywhere() { + let fg = [1.0, 1.0, 1.0, 1.0]; + let bg = [0.1, 0.2, 0.3, 1.0]; + for i in 0..32 { + let (u, v) = (i as f32 / 32.0, (31 - i) as f32 / 32.0); + assert_eq!(PreviewBackdrop::Flat.sample(u, v, fg, bg), bg); + } +} + +/// The dab slot is the glyph. A single stationary sample has no motion for a +/// displacement to reveal, so these four brushes show their declared icon there +/// rather than a bake — and the icon is what `BrushInfo` projects to the picker. +#[test] +fn the_dab_slot_belongs_to_the_icon() { + let brushes = builtin_brushes::all(); + for (name, icon) in STAGED { + let brush = brushes + .iter() + .find(|b| b.metadata.name == name) + .unwrap_or_else(|| panic!("built-in brush '{name}' must exist")); + assert_eq!( + darkly::brush::graph_capabilities(&brush.metadata.graph).preview_fallback_icon, + Some(icon), + ); + assert_eq!( + darkly::brush::library::BrushInfo::from(&brush.metadata).icon, + Some(icon), + "'{name}' projects its glyph to the picker" + ); + } +} + +/// The backdrop is a function of the theme poles, not of hard-coded greys. +/// Inverting the theme must invert the staging with it — `set_preview_theme` +/// already drops every cached thumbnail, so nothing else has to invalidate. +#[test] +fn the_backdrop_follows_the_theme() { + let mut engine = fresh_engine(); + engine.set_preview_theme(WHITE, BLACK); + let (light_on_dark, _, _) = stroke_thumbnail(&mut engine, "Liquify"); + + engine.set_preview_theme(BLACK, WHITE); + let (dark_on_light, _, _) = stroke_thumbnail(&mut engine, "Liquify"); + + assert!(luminance_sd(&light_on_dark) > VISIBLE); + assert!(luminance_sd(&dark_on_light) > VISIBLE); + assert_ne!( + light_on_dark, dark_on_light, + "the staged backdrop ignores the theme" + ); +} + +/// A node that transports pixels from elsewhere needs an offset that *escapes* +/// the field it is transporting: a whole number of stripe periods reproduces the +/// backdrop exactly and the clone stays invisible. Half a period is the furthest +/// from that, and there is no vertical component because the field has no +/// vertical structure to escape. +/// +/// A pure unit test, deliberately — this is the property that a GPU render +/// cannot distinguish from a working clone until someone looks at the PNG. +#[test] +fn the_clone_offset_escapes_the_stripes() { + let [du, dv] = PreviewBackdrop::Stripes.source_offset(); + assert_eq!(dv, 0.0, "a vertical offset over vertical bands is a no-op"); + + // Stated against the field rather than against its period, so it holds + // whatever `BANDS` is set to: shifting by the offset must land on the other + // tone *everywhere*, which is what "exactly out of phase" means and what an + // offset of a whole period fails at every position. + for i in 0..64 { + let u = i as f32 / 64.0; + assert_ne!( + PreviewBackdrop::Stripes.sample(u, 0.5, WHITE, BLACK), + PreviewBackdrop::Stripes.sample(u + du, 0.5, WHITE, BLACK), + "at u = {u} the offset {du} lands back on the same band, so a clone \ + of the backdrop is the backdrop" + ); + } + + assert_eq!( + PreviewBackdrop::Flat.source_offset(), + [0.0, 0.0], + "a flat field has nothing to escape" + ); +} + +/// A node that needs staging needs both halves of it. Nearly tautological now +/// that they are one struct — which is the point: it records why they are one, +/// and it is the check that would have been load-bearing had they stayed two +/// fields that could drift apart. +#[test] +fn every_declaring_node_declares_both_halves() { + for reg in darkly::brush::registry().types() { + let Some(staging) = reg.node.preview_staging else { + continue; + }; + assert!( + !staging.icon.is_empty(), + "'{}' declares staging with no glyph for the dab slot", + reg.node.type_id + ); + assert_ne!( + staging.backdrop, + PreviewBackdrop::Flat, + "'{}' declares staging that stages nothing", + reg.node.type_id + ); + } +} + +/// A preview is a picture of a brush, not of one stroke of it. Five shipped +/// brushes contain `random`/`noise` nodes, and until the stroke seed became the +/// caller's to choose they rendered differently every time — which would have +/// made a cached thumbnail differ from its own re-bake and a documentation asset +/// churn on every rebuild. +#[test] +fn previews_are_reproducible() { + let mut engine = fresh_engine(); + for name in [ + "Rough Ink", + "Rough Watercolor", + "Smooth Watercolor", + "Round", + "Clone", + ] { + let (first, _, _) = stroke_thumbnail(&mut engine, name); + // Drop the bake and take it again from scratch. + engine.set_preview_theme([0.5, 0.5, 0.5, 1.0], [0.25, 0.25, 0.25, 1.0]); + engine.set_preview_theme(WHITE, BLACK); + let (second, _, _) = stroke_thumbnail(&mut engine, name); + assert_eq!(first, second, "'{name}' renders differently every bake"); + } +} diff --git a/crates/darkly/tests/chromatic_aberration.rs b/crates/darkly/tests/chromatic_aberration.rs index ed345d68..db6a103d 100644 --- a/crates/darkly/tests/chromatic_aberration.rs +++ b/crates/darkly/tests/chromatic_aberration.rs @@ -189,7 +189,7 @@ fn veil_produces_non_identity_output() { let mut registry = VeilRegistry::new(); let params = ca_params(vec![entry([4.0, 0.0], 1.0, [1.0, 1.0, 1.0], 0.0)]); - let veil = registry.create_veil("chromatic_aberration", ¶ms, &device, format); + let mut veil = registry.create_veil("chromatic_aberration", ¶ms, &device, format); let cache = veil.create_cache(&device, &queue, &[view0, view1], &sampler, w, h); let (dst, dst_view) = create_test_texture(&device, &queue, w, h, &[]); diff --git a/crates/darkly/tests/clone.rs b/crates/darkly/tests/clone.rs index 1cbc7efd..2bfae48a 100644 --- a/crates/darkly/tests/clone.rs +++ b/crates/darkly/tests/clone.rs @@ -101,7 +101,13 @@ fn render_clone(p: &CloneParams) -> Vec { &queue, &darkly::gpu::selection::selection_mask_bgl(&device), ); - let mut stroke_buffer = StrokeBuffer::new(&device, W, W, &pipelines); + let mut stroke_buffer = StrokeBuffer::new( + &device, + W, + W, + &pipelines, + darkly::brush::node::COLOR_SCRATCH_FORMAT, + ); let layer_rect = CanvasRect::from_xywh(p.origin[0], p.origin[1], W, W); let pre_stroke = GpuPaintTarget::from_canvas_texture( diff --git a/crates/darkly/tests/docs_export.rs b/crates/darkly/tests/docs_export.rs new file mode 100644 index 00000000..34526b6c --- /dev/null +++ b/crates/darkly/tests/docs_export.rs @@ -0,0 +1,521 @@ +//! The metadata export is a *faithful projection* of the registries. +//! +//! The gap this file exists to close: nothing previously asserted that the +//! export described what the registries actually hold — only that it matched +//! its own generator, which proves nothing about a registry silently dropped or +//! a field silently mis-copied. +//! +//! Every test here runs the real `export-docs` binary and reads the file it +//! wrote, so what is checked is the artifact a caller gets, not a re-derivation +//! of it. + +use std::collections::BTreeSet; +use std::process::Command; + +use serde_json::Value; + +/// Run the exporter into a temp path and parse what it wrote. +fn export() -> (Value, usize) { + let dir = std::env::temp_dir().join(format!("darkly-docs-test-{}", std::process::id())); + let out = dir.join("metadata.json"); + let _ = std::fs::remove_dir_all(&dir); + + let status = Command::new(env!("CARGO_BIN_EXE_export-docs")) + .arg("--out") + .arg(&out) + .status() + .expect("failed to run export-docs"); + assert!(status.success(), "export-docs exited with {status}"); + + let text = std::fs::read_to_string(&out).expect("export-docs wrote no file"); + let json = serde_json::from_str(&text).expect("export-docs wrote invalid JSON"); + let len = text.len(); + let _ = std::fs::remove_dir_all(&dir); + (json, len) +} + +fn catalogs_of(json: &Value) -> &Vec { + json["catalogs"] + .as_array() + .expect("catalogs is not an array") +} + +fn catalog<'a>(json: &'a Value, id: &str) -> &'a Value { + catalogs_of(json) + .iter() + .find(|c| c["id"] == id) + .unwrap_or_else(|| panic!("no catalog `{id}` in the export")) +} + +/// Every registry directory `build.rs` scanned produced exactly one catalog. +/// +/// This compares the export against what the build *found on disk*, not against +/// a second hand-written list. Adding an eighth registry directory therefore +/// cannot be forgotten in two places at once: the same generated list feeds +/// `catalogs()` and this assertion. +#[test] +fn every_catalog_source_is_exported() { + let (json, _) = export(); + let exported: BTreeSet<&str> = catalogs_of(&json) + .iter() + .filter_map(|c| c["id"].as_str()) + .filter(|id| !id.starts_with("settings.")) + .collect(); + + let scanned: Vec<_> = darkly::catalog::catalog_sources(); + assert!(!scanned.is_empty(), "build.rs scanned no catalog sources"); + + for source in &scanned { + assert!( + exported.contains(source.id), + "registry directory `{}` produces catalog `{}`, which the export omits", + source.dir, + source.id + ); + } + assert_eq!( + exported.len(), + scanned.len(), + "the export carries a registry catalog no scanned directory produces: {exported:?} vs {:?}", + scanned.iter().map(|s| s.id).collect::>() + ); +} + +/// Every entry's fields equal the registration's, field by field. +/// +/// A type-id *set* comparison would pass a `catalog_entry()` that returned +/// `display_name` in `description`; this would not. +#[test] +fn export_is_a_faithful_projection() { + let (json, _) = export(); + + /// Assert one catalog's entries against `(type_id, display_name, icon, + /// description, category, hotkey_action)` read off the registrations. + type Row = ( + &'static str, + &'static str, + Option<&'static str>, + Option<&'static str>, + Option<&'static str>, + Option<&'static str>, + ); + fn check(json: &Value, id: &str, want: Vec) { + let cat = catalog(json, id); + let entries = cat["entries"].as_array().unwrap(); + assert_eq!( + entries.len(), + want.len(), + "catalog `{id}` exports {} entries, the registry holds {}", + entries.len(), + want.len() + ); + for (e, (type_id, display_name, icon, description, category, hotkey)) in + entries.iter().zip(want) + { + let f = |k: &str| e[k].as_str(); + assert_eq!(f("type"), Some(type_id), "`{id}` type mismatch"); + assert_eq!( + f("displayName"), + Some(display_name), + "`{id}/{type_id}` displayName" + ); + assert_eq!(f("icon"), icon, "`{id}/{type_id}` icon"); + assert_eq!( + f("description"), + description, + "`{id}/{type_id}` description" + ); + assert_eq!(f("category"), category, "`{id}/{type_id}` category"); + assert_eq!(f("hotkeyAction"), hotkey, "`{id}/{type_id}` hotkeyAction"); + } + } + + let some = |s: &'static str| (!s.is_empty()).then_some(s); + + check( + &json, + "filters", + darkly::gpu::filter::FilterPipelineRegistry::new() + .types() + .into_iter() + .map(|r| { + ( + r.type_id, + r.display_name, + some(r.icon), + some(r.description), + None, + some(r.hotkey_action), + ) + }) + .collect(), + ); + + check( + &json, + "veils", + darkly::gpu::veil::VeilRegistry::new() + .types() + .into_iter() + .map(|r| { + ( + r.type_id, + r.display_name, + None, + some(r.description), + None, + None, + ) + }) + .collect(), + ); + + check( + &json, + "voids", + darkly::gpu::void::VoidRegistry::new() + .types() + .into_iter() + .map(|r| { + ( + r.type_id, + r.display_name, + some(r.icon), + some(r.description), + None, + None, + ) + }) + .collect(), + ); + + check( + &json, + "blendModes", + darkly::gpu::blend_mode::registry() + .all() + .into_iter() + .map(|r| { + ( + r.type_id, + r.display_name, + None, + some(r.description), + some(r.category), + None, + ) + }) + .collect(), + ); + + check( + &json, + "tools", + darkly::tool::registry() + .types() + .into_iter() + .map(|r| { + ( + r.type_id, + r.display_name, + some(r.icon), + some(r.description), + None, + some(r.hotkey_action), + ) + }) + .collect(), + ); + + check( + &json, + "layerKinds", + darkly::document::layer_kind::registry() + .all() + .into_iter() + .map(|r| { + ( + r.type_id, + r.display_name, + some(r.icon), + some(r.description), + None, + None, + ) + }) + .collect(), + ); + + check( + &json, + "layerFilters", + darkly::document::filter::registry() + .all() + .into_iter() + .map(|r| { + ( + r.type_id, + r.display_name, + some(r.icon), + some(r.description), + None, + None, + ) + }) + .collect(), + ); + + check( + &json, + "brushes", + darkly::brush::builtin_brushes::docs() + .iter() + .map(|(stem, info)| { + ( + *stem, + info.name.as_str(), + info.icon, + some(info.description.as_str()), + some(info.category.as_str()), + None, + ) + }) + .collect(), + ); + + check( + &json, + "brushNodes", + darkly::brush::registry() + .types() + .into_iter() + .map(|r| { + ( + r.node.type_id, + r.node.display_name, + // Ports are not parameters and a preview-fallback glyph is + // not a palette icon — both deliberately absent. + None, + some(r.node.description), + some(r.node.category), + None, + ) + }) + .collect(), + ); + + // Actions carry their own id in `hotkeyAction` as well as in `type`: the + // docs table joins `bindings` to a row through that one field, whichever + // catalog the row came from. + check( + &json, + "actions", + darkly::actions::registrations() + .iter() + .flat_map(|cat| { + cat.actions.iter().map(move |a| { + ( + a.id, + a.display_name, + some(a.icon), + some(a.description), + some(cat.id), + some(a.id), + ) + }) + }) + .collect(), + ); + + // The capture kind is voids' alone, and it rides beside the previewability + // every catalog now answers. + for r in darkly::gpu::void::VoidRegistry::new().types() { + let e = catalog(&json, "voids")["entries"] + .as_array() + .unwrap() + .iter() + .find(|e| e["type"] == r.type_id) + .unwrap(); + let want_capture = r.capture_kind.map(|k| serde_json::to_value(k).unwrap()); + let got = e["captureKind"].clone(); + let got = (!got.is_null()).then_some(got); + assert_eq!(got, want_capture, "`voids/{}` captureKind", r.type_id); + } + + // Previewability is one question every catalog answers, and the artifact + // must carry the same answer the registry gives. A consumer pairing this + // JSON with a directory of rendered assets reads exactly this flag to know + // which entries it should find one for. + let mut previewable = 0usize; + for cat in catalogs_of(&json) { + for e in cat["entries"].as_array().unwrap() { + let got = e["supportsPreview"].as_bool(); + assert!( + got.is_some(), + "`{}/{}` has no supportsPreview", + cat["id"], + e["type"] + ); + previewable += usize::from(got == Some(true)); + } + } + assert_eq!( + previewable, 48, + "7 filters + 10 veils + 1 void + 16 blend modes + 14 brushes declare a \ + preview recipe" + ); + + // Brushes carry no `preview` field of their own — the recipe lives on the + // catalog — so the exported flag must be the same question put to the same + // authority the catalog itself asks, per entry rather than a blanket claim. + for e in catalog(&json, darkly::brush::builtin_brushes::CATALOG_ID)["entries"] + .as_array() + .unwrap() + { + let stem = e["type"].as_str().unwrap(); + assert_eq!( + e["supportsPreview"].as_bool(), + Some(darkly::brush::builtin_brushes::preview(stem).is_some()), + "`brushes/{stem}` supportsPreview disagrees with its declared preview" + ); + } + + // Settings ride on the same footing, against the section schema minus the + // prefs the UI does not treat as settings. + for section in darkly::config::sections::registrations() { + let cat = catalog(&json, &format!("settings.{}", section.id)); + assert_eq!(cat["title"].as_str(), Some(section.display_name)); + assert_eq!(cat["order"].as_i64(), Some(section.order as i64)); + let params = cat["entries"][0]["params"].as_array().unwrap(); + let want: Vec<_> = section + .prefs + .iter() + .filter(|p| !matches!(p.widget, darkly::config::schema::WidgetHint::Hidden)) + .collect(); + assert_eq!( + params.len(), + want.len(), + "settings.{} exports {} prefs, the section declares {} visible", + section.id, + params.len(), + want.len() + ); + for (got, pref) in params.iter().zip(want) { + assert_eq!( + got["name"].as_str(), + Some(pref.key), + "settings.{}", + section.id + ); + assert_eq!(got["label"].as_str(), Some(pref.display_name)); + assert_eq!(got["description"].as_str(), pref.description); + assert_ne!(got["widget"].as_str(), Some("hidden")); + } + } +} + +/// Every entry's exported `params` equals `ParamInfo` over the registration's +/// own `&'static [ParamDef]`, in order — so a parameter cannot be dropped, +/// reordered, or re-derived at the exporter. +#[test] +fn params_match_the_registration_slice() { + let (json, _) = export(); + + fn check( + json: &Value, + cat_id: &str, + type_id: &str, + defs: &'static [darkly::gpu::params::ParamDef], + ) { + let entry = catalog(json, cat_id)["entries"] + .as_array() + .unwrap() + .iter() + .find(|e| e["type"] == type_id) + .unwrap_or_else(|| panic!("no `{cat_id}/{type_id}` in the export")); + let got = entry["params"].as_array().unwrap(); + // Round-trip the expectation through a JSON *string* as well: an `f32` + // default reaches `Value` as its widened `f64` (0.299 → 0.29899999…) + // unless it goes through the same textual formatting the file did. + let want: Value = serde_json::from_str( + &serde_json::to_string( + &defs + .iter() + .map(|d| darkly::engine::ParamInfo::from_def(d, None)) + .collect::>(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + &Value::Array(got.clone()), + &want, + "`{cat_id}/{type_id}` params differ from its registration slice" + ); + } + + let mut checked = 0; + for r in darkly::gpu::filter::FilterPipelineRegistry::new().types() { + check(&json, "filters", r.type_id, r.params); + checked += 1; + } + for r in darkly::gpu::veil::VeilRegistry::new().types() { + check(&json, "veils", r.type_id, r.params); + checked += 1; + } + for r in darkly::gpu::void::VoidRegistry::new().types() { + check(&json, "voids", r.type_id, r.params); + checked += 1; + } + for r in darkly::tool::registry().types() { + check(&json, "tools", r.type_id, r.params); + checked += 1; + } + assert!(checked > 0, "no parameterized registries were checked"); +} + +/// The artifact carries values, not bytes. A rendered preview belongs in the +/// asset directory a sibling binary writes, keyed by the same `version`. +#[test] +fn manifest_carries_no_binary_payload() { + let (json, _) = export(); + + fn walk(v: &Value, path: &str) { + match v { + Value::String(s) => { + assert!( + !s.starts_with("data:"), + "{path} carries a data: URI — the artifact must not embed bytes" + ); + // Anything this long in a metadata field is a payload, not a + // label or a sentence. + assert!( + s.len() < 4096, + "{path} carries a {}-byte string — too long to be metadata", + s.len() + ); + } + Value::Array(a) => { + for (i, x) in a.iter().enumerate() { + walk(x, &format!("{path}[{i}]")); + } + } + Value::Object(o) => { + for (k, x) in o { + walk(x, &format!("{path}.{k}")); + } + } + _ => {} + } + } + walk(&json, "$"); +} + +#[test] +fn manifest_under_256_kb() { + let (_, len) = export(); + assert!( + len < 256 * 1024, + "the manifest is {len} bytes; over 256 KB means something is riding along that should not be" + ); +} diff --git a/crates/darkly/tests/docs_render.rs b/crates/darkly/tests/docs_render.rs new file mode 100644 index 00000000..eca2d418 --- /dev/null +++ b/crates/darkly/tests/docs_render.rs @@ -0,0 +1,751 @@ +//! The rendered documentation assets: that every previewable entry declares a +//! preview, that what it renders moves, and that what lands on disk is what the +//! catalogs said it would be. +//! +//! Two groups live here. The first is GPU-free and reads the registries: what +//! an entry *declares* — that it has a preview, how long it runs, whether it +//! closes — is data, and can be checked before a device is touched. +//! +//! The second group renders. Motion itself is a method rather than a +//! declaration, so there is nothing left to inspect statically and the pixels +//! are the only witness; `tests/picker_preview.rs` holds the finer-grained half +//! of that, over the same driver. Those tests share one fixture that runs the +//! whole walk once for the test binary rather than once per test, and tests that +//! only need a single entry call `render_entry` directly and skip the PNG +//! round-trip entirely. +//! +//! Run with: `cargo test -p darkly --features testing --test docs_render -- --test-threads=1` + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use darkly::catalog::{catalogs, preview_mechanisms}; +use darkly::docs_render::{self, Gpu, Manifest, Rendered}; +use darkly::gpu::params::ParamValue; +use darkly::gpu::preview::{frame_t, PreviewAnim}; + +// --------------------------------------------------------------------------- +// Shared enumeration — one source for every test below +// --------------------------------------------------------------------------- + +/// One previewable entry: where it lives and how long its preview runs. +struct Previewable { + catalog: &'static str, + type_id: &'static str, + anim: PreviewAnim, +} + +/// Every entry the walk will reach, resolved through the same generated table +/// the walk and the renderers use, plus the one catalog rendered through a +/// document. Nothing here carries a list of catalogs or of entries. +fn previewable() -> Vec { + let mechanisms = preview_mechanisms(); + let mut out = Vec::new(); + for cat in catalogs() { + let previewable_entries = || cat.entries.iter().filter(|e| e.supports_preview); + if let Some((id, mech)) = mechanisms.iter().find(|(id, _)| *id == cat.id) { + for e in previewable_entries() { + let entry = mech.resolve(e.type_id).unwrap_or_else(|| { + panic!( + "`{id}/{}` is previewable but resolves to nothing", + e.type_id + ) + }); + out.push(Previewable { + catalog: id, + type_id: entry.type_id, + anim: entry.anim, + }); + } + continue; + } + if cat.id == darkly::gpu::blend_mode::CATALOG_ID { + for e in previewable_entries() { + out.push(Previewable { + catalog: cat.id, + type_id: e.type_id, + anim: darkly::gpu::blend_mode::registry() + .preview(e.type_id) + .expect("a registered mode inherits the catalog preview"), + }); + } + continue; + } + if cat.id == darkly::brush::builtin_brushes::CATALOG_ID { + for e in previewable_entries() { + out.push(Previewable { + catalog: cat.id, + type_id: e.type_id, + anim: darkly::brush::builtin_brushes::preview(e.type_id) + .expect("a shipped brush inherits the catalog preview"), + }); + } + continue; + } + assert!( + previewable_entries().next().is_none(), + "catalog `{}` has previewable entries and no renderer", + cat.id + ); + } + assert!(!out.is_empty(), "no previewable entries found at all"); + out +} + +// --------------------------------------------------------------------------- +// The declarations — GPU-free +// --------------------------------------------------------------------------- + +/// Every filter, every veil, every blend mode and `noise` declares a preview. +/// +/// Driven off the four **registries** rather than a hand-written list, so +/// adding a filter without a preview fails here — which is the whole point of +/// putting the declaration on the registration. +#[test] +fn every_previewable_entry_declares_a_preview() { + let filters = darkly::gpu::filter::FilterPipelineRegistry::new(); + for reg in filters.types() { + assert!( + filters.preview(reg.type_id).is_some(), + "filter `{}` declares no preview", + reg.type_id + ); + } + let veils = darkly::gpu::veil::VeilRegistry::new(); + for reg in veils.types() { + assert!( + veils.preview(reg.type_id).is_some(), + "veil `{}` declares no preview", + reg.type_id + ); + } + for reg in darkly::gpu::blend_mode::registry().all() { + assert!( + darkly::gpu::blend_mode::registry() + .preview(reg.type_id) + .is_some(), + "blend mode `{}` declares no preview", + reg.type_id + ); + } + assert!(darkly::gpu::void::VoidRegistry::new() + .preview("noise") + .is_some()); +} + +/// The two catalogs with previewable entries and no `src → out` mechanism. +/// +/// A blend mode is a relation between two images rather than an effect over +/// one; a brush is a stroke driven through the brush engine rather than an +/// effect over one image. Neither has a pass to open, so each is rendered by its +/// own arm in `render_entry` — as a further *caller* of `PreviewAnim`, not a +/// further preview system. +const MECHANISMLESS: [&str; 2] = [ + darkly::gpu::blend_mode::CATALOG_ID, + darkly::brush::builtin_brushes::CATALOG_ID, +]; + +/// Every previewable catalog has a mechanism, or is one of the documented +/// exceptions. +/// +/// The generated table is what both consumers dispatch through, so an entry it +/// cannot reach has no picker preview however much it declares. +#[test] +fn every_previewable_catalog_has_a_mechanism_or_is_the_exception() { + let mechanisms = preview_mechanisms(); + for (id, _) in &mechanisms { + assert!( + catalogs().iter().any(|c| c.id == *id), + "`{id}` is not a catalog id" + ); + } + for cat in catalogs() { + if !cat.entries.iter().any(|e| e.supports_preview) { + continue; + } + let has = mechanisms.iter().any(|(id, _)| *id == cat.id); + assert_eq!( + has, + !MECHANISMLESS.contains(&cat.id), + "`{}` previewability and mechanism disagree", + cat.id + ); + } +} + +/// The two effects with two surfaces declare one preview and sweep one set of +/// values, from the module they share rather than a copy in each. +/// +/// The filter half is checked structurally — the registration's swept values +/// *are* the shared function's. The veil half calls the same function from its +/// `preview_at`, which only pixels can witness; `every_asset_has_real_motion` +/// and `preview_at_is_absolute` cover it there. +#[test] +fn shared_effects_share_one_preview() { + let filters = darkly::gpu::filter::FilterPipelineRegistry::new(); + let veils = darkly::gpu::veil::VeilRegistry::new(); + for shared in ["black_and_white", "chromatic_aberration"] { + assert_eq!( + filters.preview(shared), + veils.preview(shared), + "`{shared}`'s filter and veil declare different previews" + ); + } + for i in 0..8 { + let t = i as f32 / 8.0; + assert_eq!( + filters.preview_params("black_and_white", t), + darkly::gpu::black_and_white::preview_params(t), + ); + assert_eq!( + filters.preview_params("chromatic_aberration", t), + darkly::gpu::filters::chromatic_aberration::preview_params(t), + ); + } +} + +/// A void's previewability *is* its declaration — one fact, not two that can +/// drift. +#[test] +fn void_previewability_is_the_declaration() { + let voids = darkly::gpu::void::VoidRegistry::new(); + let entries = catalogs() + .into_iter() + .find(|c| c.id == darkly::gpu::void::CATALOG_ID) + .expect("the voids catalog") + .entries; + assert_eq!(entries.len(), 4); + for e in &entries { + assert_eq!( + e.supports_preview, + voids.preview(e.type_id).is_some(), + "`voids/{}` answers previewability two different ways", + e.type_id + ); + assert_eq!( + e.supports_preview, + e.type_id == "noise", + "`voids/{}` previewability", + e.type_id + ); + } +} + +/// Every declared animation is playable: a positive frame count and a positive +/// rate. A zero of either would divide by zero on the playback clock. +#[test] +fn every_declared_animation_is_playable() { + for p in previewable() { + assert!( + p.anim.frames >= 1, + "`{}/{}` declares no frames", + p.catalog, + p.type_id + ); + assert!( + p.anim.fps >= 1, + "`{}/{}` declares a zero playback rate", + p.catalog, + p.type_id + ); + } +} + +// --------------------------------------------------------------------------- +// The rendered assets +// --------------------------------------------------------------------------- + +/// Where the shared fixture writes. A **fixed** name, not one keyed by pid, so a +/// run that panicked or was killed reclaims its predecessor's space instead of +/// adding to it. Safe because `--test-threads=1` is mandatory for GPU tests and +/// this is the only test binary that writes here. +fn assets_dir() -> PathBuf { + std::env::temp_dir().join("darkly-docs-render-assets") +} + +/// The whole walk, run once for the test binary. +fn assets() -> &'static (PathBuf, Manifest) { + static ASSETS: OnceLock<(PathBuf, Manifest)> = OnceLock::new(); + ASSETS.get_or_init(|| { + let dir = assets_dir(); + let _ = std::fs::remove_dir_all(&dir); + let manifest = docs_render::render_all(&dir).expect("render_all"); + (dir, manifest) + }) +} + +/// One entry rendered on its own, without the PNG round-trip. +/// +/// Darkly's GPU state is deliberately not `Send` — the engine is single-threaded +/// everywhere — so the device is an ordinary local rather than a shared static. +/// Each test that renders holds one for the whole of its own work, which is also +/// what lets the tests that care about cross-asset leakage drive several entries +/// through the same documents. +fn render_one(gpu: &mut Gpu, catalog: &str, type_id: &str) -> Rendered { + docs_render::render_entry(gpu, catalog, type_id).expect("render_entry") +} + +/// Every `(catalog, entry)` directory actually present under `root`. +fn dirs_on_disk(root: &Path) -> BTreeSet<(String, String)> { + let mut out = BTreeSet::new(); + for cat in std::fs::read_dir(root).expect("the output directory") { + let cat = cat.unwrap().path(); + if !cat.is_dir() { + continue; + } + let cat_name = cat.file_name().unwrap().to_string_lossy().to_string(); + for entry in std::fs::read_dir(&cat).unwrap() { + let entry = entry.unwrap().path(); + if entry.is_dir() { + out.insert(( + cat_name.clone(), + entry.file_name().unwrap().to_string_lossy().to_string(), + )); + } + } + } + out +} + +/// The full walk succeeds — no previewable entry was missed by the renderer +/// table. This is what fails the day a new previewable registry is added. +#[test] +fn every_previewable_entry_has_a_renderer() { + let (_, manifest) = assets(); + assert!(!manifest.assets.is_empty()); +} + +/// Seven filters, ten veils, one void, sixteen blend modes and fourteen brushes +/// — counted **per catalog**. A bare total of forty-eight would not notice a +/// whole catalog dropping out and another gaining entries. +#[test] +fn all_forty_eight_assets_land() { + let (_, manifest) = assets(); + let counts: BTreeMap<&str, usize> = manifest + .assets + .iter() + .map(|(k, v)| (k.as_str(), v.len())) + .collect(); + assert_eq!( + counts, + BTreeMap::from([ + ("filters", 7), + ("veils", 10), + ("voids", 1), + ("blendModes", 16), + ("brushes", 14), + ]) + ); + assert_eq!(counts.values().sum::(), 48); +} + +/// The set of directories **found by walking the output** equals the previewable +/// set the catalogs declare, in both directions — and the index agrees with the +/// directory. +/// +/// Scanning the tree rather than trusting the manifest is the difference between +/// testing the artifact and testing the binary's own claim about it. +/// +/// Its honest limit: the expectation and the walk share `catalogs()`, so this +/// catches an entry the walker skipped and an asset nothing declares, but not +/// `catalogs()` itself under-reporting a registry. `every_previewable_entry_declares_a_recipe` +/// closes the half this file can, by reading the registries directly. +#[test] +fn assets_on_disk_match_the_previewable_set() { + let (dir, manifest) = assets(); + + let declared: BTreeSet<(String, String)> = catalogs() + .iter() + .flat_map(|c| { + c.entries + .iter() + .filter(|e| e.supports_preview) + .map(|e| (c.id.to_string(), e.type_id.to_string())) + }) + .collect(); + assert_eq!(dirs_on_disk(dir), declared); + + let indexed: BTreeSet<(String, String)> = manifest + .assets + .iter() + .flat_map(|(c, es)| es.keys().map(move |t| (c.clone(), t.clone()))) + .collect(); + assert_eq!(indexed, declared); +} + +/// Every written PNG decodes to the size its index entry declares, and every +/// effect asset is the fixed square subject. +/// +/// Pins the coincidence the layout depends on: the offscreen veil and void +/// renderers are hard-wired to fit into the picker's preview box, and the +/// document path is sized to match it. If that constant ever moved, veil and +/// void frames would silently diverge in size from the rest. A brush stroke is +/// the one asset that is deliberately not square — it is a left-to-right line +/// framed to the picker strip's own shape, so it is checked against that +/// constant instead. +#[test] +fn every_frame_is_the_size_its_entry_declares() { + let (dir, manifest) = assets(); + let dim = darkly::docs_render::subject::DOCS_SUBJECT_DIM; + + let mut checked = 0usize; + for (catalog, entries) in &manifest.assets { + let expected = if catalog == darkly::brush::builtin_brushes::CATALOG_ID { + darkly::engine::BRUSH_THUMBNAIL_SIZE + } else { + (dim, dim) + }; + for asset in entries.values() { + assert_eq!((asset.width, asset.height), expected, "{}", asset.dir); + let path = dir.join(&asset.dir).join("000.png"); + let img = image::open(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + assert_eq!( + (img.width(), img.height()), + expected, + "{} decodes to a different size than it declares", + asset.dir + ); + checked += 1; + } + } + assert_eq!(checked, 48); +} + +/// For every asset the PNG count equals the declared frame count, and the +/// index's `frames` / `fps` / `loop` are the entry's own — which is what makes +/// `loop` in the artifact something a consumer can rely on rather than a claim. +#[test] +fn manifest_frames_fps_and_loop_match_the_declaration() { + let (dir, manifest) = assets(); + let mut non_looping = BTreeSet::new(); + + for p in previewable() { + let asset = &manifest.assets[p.catalog][p.type_id]; + assert_eq!(asset.frames, p.anim.frames, "{}/{}", p.catalog, p.type_id); + assert_eq!(asset.fps, p.anim.fps, "{}/{}", p.catalog, p.type_id); + assert_eq!(asset.loops, p.anim.loops, "{}/{}", p.catalog, p.type_id); + assert_eq!( + asset.still, + p.anim.still_frame(), + "{}/{}", + p.catalog, + p.type_id + ); + assert!( + asset.still < asset.frames, + "`{}/{}`'s poster frame falls outside its own sequence", + p.catalog, + p.type_id + ); + assert!( + dir.join(&asset.dir) + .join(format!("{:03}.png", asset.still)) + .exists(), + "`{}/{}`'s poster frame names no file", + p.catalog, + p.type_id + ); + + let written = std::fs::read_dir(dir.join(&asset.dir)).unwrap().count(); + assert_eq!( + written as u32, p.anim.frames, + "`{}/{}` wrote {written} frames", + p.catalog, p.type_id + ); + if !asset.loops { + non_looping.insert(format!("{}/{}", p.catalog, p.type_id)); + } + } + + // The three time-driven veils integrate their clocks forward and are + // recorded honestly rather than being made to loop by a shader change. + assert_eq!( + non_looping, + BTreeSet::from([ + "veils/grain".to_string(), + "veils/rainy_glass".to_string(), + "veils/vhs".to_string(), + ]) + ); + + // Alphabetically the last of the six tests that read the fixture, and under + // the mandatory single test thread that makes it the last to run — so the + // 65–160 MB of frames come back here rather than being left on every + // developer machine and every CI run. The fixture also clears any earlier + // tree before writing, so a run that panicked or was killed reclaims its + // predecessor's space rather than adding to it. + std::fs::remove_dir_all(dir).expect("the fixture cleans up after itself"); +} + +/// Every asset that declares more than one frame renders at least two distinct +/// images; one that declares a still writes exactly one file. +/// +/// Motion is a method now, so there is no declaration left to inspect — the +/// pixels are the whole of the evidence, and this is the floor. +/// `tests/picker_preview.rs` carries the finer-grained assertions over the same +/// driver. +#[test] +fn every_asset_has_real_motion() { + let (dir, manifest) = assets(); + for entries in manifest.assets.values() { + for (type_id, asset) in entries { + let frame = |i: u32| std::fs::read(dir.join(&asset.dir).join(format!("{i:03}.png"))); + if asset.frames == 1 { + assert!( + frame(1).is_err(), + "`{type_id}` declares a still and wrote more" + ); + continue; + } + let first = frame(0).unwrap(); + let moved = (1..asset.frames).any(|i| frame(i).map(|f| f != first).unwrap_or(false)); + assert!( + moved, + "`{}` rendered {} identical frames", + type_id, asset.frames + ); + } + } +} + +/// Rendering an entry twice through the same `Gpu` produces the same pixels. +/// +/// Determinism across a reused device is what lets forty-eight assets share one +/// `Gpu`, and it is where a renderer that left state behind shows up. One entry +/// per catalog rather than all forty-eight, because the cost is two full +/// sequences each and the failure mode is per-renderer, not per-entry — except +/// for brushes, which get +/// [`every_brush_renders_the_same_bytes_twice`] over the whole catalog. +#[test] +fn rendering_an_entry_twice_is_deterministic() { + let mut gpu = Gpu::new(); + for (catalog, type_id) in [ + ("filters", "hsv"), + ("veils", "frozen"), + ("voids", "noise"), + ("blendModes", "multiply"), + ("brushes", "round"), + ] { + let first = render_one(&mut gpu, catalog, type_id); + let again = render_one(&mut gpu, catalog, type_id); + assert_eq!( + first.frames, again.frames, + "`{catalog}/{type_id}` rendered two different sequences" + ); + } +} + +/// Value-pinned, not merely "it differs": `invert`'s single frame is exactly +/// `255 - c` per RGB channel of the source the target loaded. +/// +/// Compared against the target's *loaded* source rather than `subject_rgba`, +/// because the offscreen path area-averages the 2× subject before the filter +/// sees it — pinning the raw subject would be pinning the resample. Rendered +/// after every other filter through the same session, so it also pins that none +/// of them left state behind. +#[test] +fn invert_is_the_exact_inverse_of_the_source_it_was_given() { + let mut gpu = Gpu::new(); + let mut rendered = None; + for reg in darkly::gpu::filter::FilterPipelineRegistry::new().types() { + let r = render_one(&mut gpu, "filters", reg.type_id); + if reg.type_id == "invert" { + rendered = Some(r); + } + } + let rendered = rendered.expect("invert is registered"); + assert_eq!(rendered.frames.len(), 1, "invert declares a still"); + + let source = docs_render::test_source_pixels(&mut gpu); + let frame = &rendered.frames[0]; + assert_eq!(frame.len(), source.len()); + for (i, (out, src)) in frame + .chunks_exact(4) + .zip(source.chunks_exact(4)) + .enumerate() + { + assert_eq!( + [out[0], out[1], out[2], out[3]], + [255 - src[0], 255 - src[1], 255 - src[2], src[3]], + "pixel {i} is not the exact inverse of the source" + ); + } +} + +/// At the frame where the tint reads zero, every pixel of the black-and-white +/// veil is neutral grey — so the offscreen veil path applied the veil rather +/// than writing the subject through. +/// +/// Deliberately a *relational* pin rather than an absolute grey value: the veil +/// path's subject is the area-averaged 2× field, so pinning a fixed number would +/// be pinning the resample rather than the veil. +#[test] +fn black_and_white_veil_frame_is_neutral_gray() { + // Frame 0 is where the shared sweep rests: no tint, so the result is the + // bare desaturation. + let defs = darkly::gpu::veil::VeilRegistry::new().param_defs("black_and_white"); + let tint = defs + .iter() + .position(|d| d.name == "tint_strength") + .expect("the shared schema declares a tint strength"); + assert_eq!( + darkly::gpu::black_and_white::preview_params(0.0)[tint], + ParamValue::Float(0.0), + "the shared sweep starts untinted" + ); + + let rendered = render_one(&mut Gpu::new(), "veils", "black_and_white"); + let frame = &rendered.frames[0]; + for (i, px) in frame.chunks_exact(4).enumerate() { + assert!( + px[0] == px[1] && px[1] == px[2], + "pixel {i} is {:?}, not neutral grey", + &px[..3] + ); + } +} + +/// Every noise frame holds more than one distinct value. A void that failed to +/// render — or whose aux texture was still a placeholder — produces a flat +/// image, which is exactly the failure the stream voids opt out of preview to +/// avoid, checked here on the one void that opts in. +#[test] +fn noise_void_frames_are_not_uniform() { + let rendered = render_one(&mut Gpu::new(), "voids", "noise"); + for (i, frame) in rendered.frames.iter().enumerate() { + let first: &[u8] = &frame[..4]; + assert!( + frame.chunks_exact(4).any(|px| px != first), + "noise frame {i} is a flat colour" + ); + } +} + +/// At the frame where the shared opacity track reads 1.0, no two of the sixteen +/// modes render the same image. +/// +/// Sixteen identical assets is the failure this whole path exists to avoid. It +/// doubles as the guard on the blend source's colour choice and on the shared +/// blend-mode document — a mode that leaked its predecessor's shader value would +/// show up here as a duplicate. Deliberately *not* asserted at frame 0, where +/// the top layer is invisible and all sixteen are correctly identical. +#[test] +fn blend_mode_frames_at_full_opacity_are_pairwise_distinct() { + let modes: Vec<&str> = darkly::gpu::blend_mode::registry() + .all() + .into_iter() + .map(|r| r.type_id) + .collect(); + assert_eq!(modes.len(), 16); + + // Half way through the sweep, where the blended layer fully covers the + // backdrop. Read off the same closure the renderer drives, so the index and + // the motion cannot disagree. + let anim = darkly::gpu::blend_mode::registry() + .preview("normal") + .unwrap(); + let full = (0..anim.frames) + .max_by(|a, b| { + let at = |i: u32| docs_render::blend_opacity_at(frame_t(i, anim.frames)); + at(*a).total_cmp(&at(*b)) + }) + .expect("the sweep has frames"); + + let mut gpu = Gpu::new(); + let mut seen: Vec<(&str, Vec)> = Vec::new(); + for mode in modes { + let frame = render_one(&mut gpu, "blendModes", mode).frames[full as usize].clone(); + if let Some((other, _)) = seen.iter().find(|(_, f)| *f == frame) { + panic!("blend modes `{other}` and `{mode}` render the same image"); + } + seen.push((mode, frame)); + } + + // The same frame index is correctly identical across modes at zero opacity, + // which is why the assertion above is taken at full opacity instead. + let a = render_one(&mut gpu, "blendModes", "multiply").frames[0].clone(); + let b = render_one(&mut gpu, "blendModes", "screen").frames[0].clone(); + assert_eq!(a, b, "at zero opacity every mode is the bare backdrop"); +} + +// --------------------------------------------------------------------------- +// Arguments +// --------------------------------------------------------------------------- + +/// The binary's only logic. It lives in the library because coverage tooling +/// runs test targets and never executes a `[[bin]]` — a `parse_args` left inside +/// `fn main` would be permanently uncovered. +#[test] +fn parse_args_reads_out() { + let args = docs_render::parse_args(["--out".to_string(), "/tmp/x".to_string()].into_iter()) + .expect("--out parses"); + assert_eq!(args.out, Some(PathBuf::from("/tmp/x"))); + + // `--help` is not an error, and it names no work to do. + let args = docs_render::parse_args(["--help".to_string()].into_iter()).unwrap(); + assert_eq!(args.out, None); +} + +#[test] +fn parse_args_rejects_a_missing_out() { + assert!(docs_render::parse_args(std::iter::empty()).is_err()); + assert!(docs_render::parse_args(["--out".to_string()].into_iter()).is_err()); + assert!(docs_render::parse_args(["--wat".to_string()].into_iter()).is_err()); +} + +/// **Every** brush renders the same bytes twice — all fourteen, not a sample. +/// +/// Unlike the other catalogs the failure mode here *is* per-entry: `rough_ink`, +/// `rough_watercolor` and `smooth_watercolor` contain `random`/`noise` nodes and +/// are the only entries in the artifact that can fail this, so a test naming one +/// of the other eleven would go green while the property was false for three. +/// A documentation artifact that rewrites bytes with no change of meaning churns +/// on every rebuild, which is what the preview stroke seed being a constant +/// rather than a clock read prevents. +#[test] +fn every_brush_renders_the_same_bytes_twice() { + let mut gpu = Gpu::new(); + let catalog = darkly::brush::builtin_brushes::CATALOG_ID; + let mut checked = 0; + for entry in darkly::brush::builtin_brushes::catalog().entries { + let first = render_one(&mut gpu, catalog, entry.type_id); + let again = render_one(&mut gpu, catalog, entry.type_id); + assert_eq!( + first.frames, again.frames, + "`{}` renders differently every time", + entry.type_id + ); + checked += 1; + } + assert_eq!(checked, 14, "the shipped brush set"); +} + +/// Every brush asset is one frame, and that frame shows a stroke. +/// +/// The documentation counterpart of `brush_preview_staging.rs`'s regression: the +/// four content-dependent brushes baked a flat rectangle before the backdrop was +/// staged under them, and a flat rectangle is not documentation. +#[test] +fn every_brush_asset_shows_a_stroke() { + let (dir, manifest) = assets(); + let entries = &manifest.assets[darkly::brush::builtin_brushes::CATALOG_ID]; + assert_eq!(entries.len(), 14); + + for (type_id, asset) in entries { + assert_eq!(asset.frames, 1, "`{type_id}` is a still"); + let img = image::open(dir.join(&asset.dir).join("000.png")) + .unwrap_or_else(|e| panic!("{}: {e}", asset.dir)) + .to_rgba8(); + let lums: Vec = img + .pixels() + .map(|p| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) + .collect(); + let mean = lums.iter().sum::() / lums.len() as f32; + let sd = (lums.iter().map(|l| (l - mean).powi(2)).sum::() / lums.len() as f32).sqrt(); + assert!( + sd > 12.0, + "`{type_id}` documents a flat rectangle (SD {sd:.2})" + ); + } +} diff --git a/crates/darkly/tests/liquify.rs b/crates/darkly/tests/liquify.rs index 33907e9d..8b3cf92a 100644 --- a/crates/darkly/tests/liquify.rs +++ b/crates/darkly/tests/liquify.rs @@ -15,7 +15,7 @@ use std::sync::{Arc, OnceLock}; use darkly::brush::compile_graph; use darkly::brush::eval::BrushGraphRunner; use darkly::brush::gpu_context::{BrushGpuContext, BrushPerfCounters, DabBatch, StrokeResources}; -use darkly::brush::nodes::liquify::LIQUIFY_SPACING_PX; +use darkly::brush::nodes::liquify::LIQUIFY_SPACING_RATIO; use darkly::brush::paint_info::PaintInformation; use darkly::brush::pipeline::BrushPipelines; use darkly::brush::stroke_buffer::StrokeBuffer; @@ -23,6 +23,12 @@ use darkly::gpu::test_utils::{create_test_texture, readback_texture, test_device const CANVAS: u32 = 128; +/// Dab step these tests hand-place at, in canvas pixels. Independent of +/// the brush's configured spacing: the harness places dabs at explicit +/// positions and synthesises the matching `pen.motion`, so this is the +/// tests' own geometry, not a copy of the brush's. +const TEST_DAB_STEP_PX: f32 = 4.0; + fn shared_device() -> (Arc, Arc) { static HANDLES: OnceLock<(Arc, Arc)> = OnceLock::new(); HANDLES @@ -50,6 +56,23 @@ fn two_tone_canvas(red_x_threshold: u32) -> Vec { out } +/// 4 px-period vertical stripes: maximum frequency along the drag axis, +/// and exactly two tones — so any intermediate red value in the output is +/// resampling loss, not content. +fn stripe_canvas() -> Vec { + let mut out = vec![0u8; (CANVAS * CANVAS * 4) as usize]; + for y in 0..CANVAS { + for x in 0..CANVAS { + let idx = ((y * CANVAS + x) * 4) as usize; + if (x / 2) % 2 == 1 { + out[idx] = 255; + } + out[idx + 3] = 255; + } + } + out +} + fn pixel(rgba: &[u8], x: u32, y: u32) -> [u8; 4] { let idx = ((y * CANVAS + x) * 4) as usize; [rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]] @@ -58,6 +81,14 @@ fn pixel(rgba: &[u8], x: u32, y: u32) -> [u8; 4] { /// One `(pos, direction_rad, distance)` per dab. `distance > 0.5` so /// the per-dab first-dab gate doesn't fire. fn render_liquify_dabs(size_override: f32, dabs: &[([f32; 2], f32, f32)]) -> Vec { + render_liquify_dabs_on(&two_tone_canvas(36), size_override, dabs) +} + +fn render_liquify_dabs_on( + canvas: &[u8], + size_override: f32, + dabs: &[([f32; 2], f32, f32)], +) -> Vec { let brush = darkly::brush::builtin_brushes::all() .into_iter() .find(|b| b.metadata.name == "Liquify") @@ -76,14 +107,17 @@ fn render_liquify_dabs(size_override: f32, dabs: &[([f32; 2], f32, f32)]) -> Vec graph.set_port_default(&term_id, "strength", 1.0).unwrap(); let (device, queue) = shared_device(); - let (layer_texture, layer_view) = - create_test_texture(&device, &queue, CANVAS, CANVAS, &two_tone_canvas(36)); + let (layer_texture, layer_view) = create_test_texture(&device, &queue, CANVAS, CANVAS, canvas); let pipelines = BrushPipelines::new( &device, &queue, &darkly::gpu::selection::selection_mask_bgl(&device), ); - let mut stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines); + // Compile before allocating: the terminal decides the scratch format, + // and liquify's is a warp field rather than colour. + let mut runner: BrushGraphRunner = compile_graph(&graph).expect("brush compiles"); + let mut stroke_buffer = + StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines, runner.scratch_format()); let pre_stroke = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture( &layer_texture, @@ -97,7 +131,6 @@ fn render_liquify_dabs(size_override: f32, dabs: &[([f32; 2], f32, f32)]) -> Vec stroke_buffer.save_pre_stroke(&device, &mut enc, &pipelines, &pre_stroke); queue.submit([enc.finish()]); - let mut runner: BrushGraphRunner = compile_graph(&graph).expect("brush compiles"); macro_rules! make_ctx { ($label:expr) => {{ let (scratch, pre_stroke_tex, pre_stroke_bg, source_override) = @@ -143,14 +176,10 @@ fn render_liquify_dabs(size_override: f32, dabs: &[([f32; 2], f32, f32)]) -> Vec let mut ctx = make_ctx!("liquify-test-flush"); for (i, (pos, dir, dist)) in dabs.iter().enumerate() { // Simulate a real stroke's per-dab motion: in a live - // stroke the engine places dabs `LIQUIFY_SPACING_PX` - // apart along the cursor's path, so `pen.motion` per - // dab has magnitude ≈ `LIQUIFY_SPACING_PX` along the - // drawing angle. - let motion = [ - LIQUIFY_SPACING_PX * dir.cos(), - LIQUIFY_SPACING_PX * dir.sin(), - ]; + // stroke the engine places dabs a spacing apart along + // the cursor's path, so `pen.motion` per dab has that + // magnitude along the drawing angle. + let motion = [TEST_DAB_STEP_PX * dir.cos(), TEST_DAB_STEP_PX * dir.sin()]; let info = PaintInformation { pos: *pos, drawing_angle: *dir, @@ -178,9 +207,108 @@ fn render_liquify_dabs(size_override: f32, dabs: &[([f32; 2], f32, f32)]) -> Vec ) } +/// **Regression test for the ghosting bug.** Liquify used to resample the +/// *image* once per dab: each dab snapshotted the scratch, sampled it at a +/// displaced UV and wrote the colour straight back. With 4 px dab spacing +/// and a 30.7 px radius a material point passed through ~15 bilinear +/// filters per swipe, and their composition is not a bilinear filter — it +/// is a low-pass cascade. Detail decayed monotonically with dab count, +/// which is what "liquify ghosts everything" meant. +/// +/// Accumulating a displacement field and resampling the pre-stroke image +/// exactly once holds detail constant no matter how many dabs pass over a +/// pixel. +/// +/// Two assertions, and the pairing is the point — neither alone is +/// sufficient: +/// +/// * **A (sharpness)** is what fails under the bug, but is satisfied +/// perfectly by a liquify that displaces nothing (pristine stripes). +/// * **B (displacement)** proves content actually moved, and passes both +/// before and after the fix. +/// +/// Only an implementation that moves content *and* keeps it sharp passes +/// both. +#[test] +fn liquify_scrubbing_preserves_high_frequency_detail() { + // 4 back-and-forth passes between x=40 and x=90 at y=64, 4 px apart. + let mut dabs: Vec<([f32; 2], f32, f32)> = Vec::new(); + let mut distance = 4.0_f32; + let mut x = 40.0_f32; + for pass in 0..4 { + let dir = if pass % 2 == 0 { + 0.0 + } else { + std::f32::consts::PI + }; + let step = if pass % 2 == 0 { + TEST_DAB_STEP_PX + } else { + -TEST_DAB_STEP_PX + }; + for _ in 0..12 { + x += step; + distance += TEST_DAB_STEP_PX; + dabs.push(([x, 64.0], dir, distance)); + } + } + + let source = stripe_canvas(); + // size 0.12 → radius = 0.12 * DAB_REFERENCE_SIZE * 0.5 = 30.72 px. + let rgba = render_liquify_dabs_on(&source, 0.12, &dabs); + + // A — sharpness. Every row crossing the stroke must still contain both + // tones. Source scores 255; the pre-fix implementation scored 0–42. + let mut worst = (255u8, 0u32); + for y in 44..86 { + let (mut lo, mut hi) = (255u8, 0u8); + for x in 45..90 { + let r = pixel(&rgba, x, y)[0]; + lo = lo.min(r); + hi = hi.max(r); + } + let ptp = hi - lo; + if ptp < worst.0 { + worst = (ptp, y); + } + } + assert!( + worst.0 >= 150, + "liquify must not low-pass the image it warps: row {} has red \ + peak-to-peak {} (need >= 150). The source is a two-tone stripe \ + pattern, so anything in between is resampling loss — this is the \ + ghosting bug, and it means liquify is resampling the picture per \ + dab instead of accumulating a displacement field.", + worst.1, + worst.0, + ); + + // B — displacement. A liquify that does nothing would sail through A. + let mut moved = 0u32; + let mut total = 0u32; + for y in 44..86 { + for x in 45..90 { + let before = pixel(&source, x, y)[0] as i32; + let after = pixel(&rgba, x, y)[0] as i32; + total += 1; + if (after - before).abs() >= 100 { + moved += 1; + } + } + } + let fraction = moved as f32 / total as f32; + assert!( + fraction >= 0.25, + "liquify must actually displace content: only {:.1}% of the \ + stroked region changed by >= 100 (need >= 25%). A no-op liquify \ + scores 0% here while still passing the sharpness assertion.", + fraction * 100.0, + ); +} + /// Confidence test: a single liquify dab at (38, 64) pulling /// rightward (direction = 0, strength = 1) lifts red into the dab -/// centre. With `|motion| = LIQUIFY_SPACING_PX = 4`, displacement at +/// centre. With `|motion| = TEST_DAB_STEP_PX = 4`, displacement at /// strength=1 is 4 px, so the centre fragment sources from (34, 64) /// — inside the red bar at `x < 36`. (Size is irrelevant to the /// per-dab displacement now — kept at 0.3 only so the disc actually @@ -208,7 +336,7 @@ fn single_liquify_dab_warps_red_into_center() { /// pre-stroke at x = 38 is past the red bar at x < 36). #[test] fn liquify_dab2_reads_dab1_deposit_not_pre_stroke() { - // `|motion| = LIQUIFY_SPACING_PX = 4` → displacement at strength=1 + // `|motion| = TEST_DAB_STEP_PX = 4` → displacement at strength=1 // is 4 px, independent of brush size. let rgba = render_liquify_dabs( 0.3, @@ -243,7 +371,7 @@ fn liquify_dab2_reads_dab1_deposit_not_pre_stroke() { /// *intensity*. /// /// Both runs: one eastward dab at (38, 64) with strength=1 and -/// `|motion| = LIQUIFY_SPACING_PX = 4`. The pre-stroke red bar lives +/// `|motion| = TEST_DAB_STEP_PX = 4`. The pre-stroke red bar lives /// at `x < 36`. With the (now-fixed) formula `displacement = strength /// × |motion| = 4 px`, a fragment at (42, 64) samples from (38, 64) /// — background. The brush centre at (38, 64) samples from (34, 64) @@ -294,3 +422,136 @@ fn warp_magnitude_is_size_invariant() { {large_at_42:?}" ); } + +// ============================================================================ +// End-to-end: the whole engine, including the checkpoint ring +// ============================================================================ + +/// **Guards the format-aware checkpoint ring.** Liquify's scratch holds a +/// float displacement field, and `CheckpointRing::save` snapshots the +/// scratch with `copy_texture_to_texture`, which rejects a format +/// mismatch. Before `CheckpointSlot` took its format from the source +/// texture, any liquify stroke long enough to check-point died on a wgpu +/// validation error. +/// +/// It also closes the loop on the ghosting bug at the level the user +/// actually meets it: a real stroke through `DarklyEngine`, with +/// stabilization, dab scheduling, checkpointing and mid-stroke commits +/// all live — not the hand-driven dab harness the tests above use. +#[test] +fn liquify_stroke_through_engine_preserves_detail() { + use darkly::engine::types::StrokeOp; + use darkly::engine::DarklyEngine; + use darkly::gpu::context::GpuContext; + + const SIZE: u32 = 128; + let (device, queue) = darkly::gpu::test_utils::test_device(); + let mut engine = DarklyEngine::new(GpuContext::new_headless(device, queue), SIZE, SIZE); + // Paste the stripes in as a layer, so the input is exactly two tones + // rather than something painted (and therefore anti-aliased). + let layer_id = engine.paste_image(SIZE, SIZE, &stripe_canvas(), 0, 0, None); + + let liquify_yaml = darkly::brush::builtin_brushes::BUILTIN_BRUSHES_YAML + .iter() + .find(|(name, _)| *name == "liquify.yaml") + .expect("liquify brush is shipped") + .1; + engine + .set_brush_graph_yaml(liquify_yaml) + .expect("liquify brush loads"); + + // A long drag: enough events to cross several checkpoint intervals. + engine.begin_stroke(layer_id); + for step in 0..=60 { + let t = step as f32 / 60.0; + engine.stroke_to(StrokeOp::BrushStroke { + x: 30.0 + t * 70.0, + y: 64.0, + pressure: 1.0, + x_tilt: 0.0, + y_tilt: 0.0, + rotation: 0.0, + tangential_pressure: 0.0, + time_ms: step as f64 * 16.0, + cr: 1.0, + cg: 0.0, + cb: 0.0, + ca: 1.0, + }); + } + engine.end_stroke(); + engine.render(0.0); + + let pixels = engine.test_readback_layer(layer_id); + let source = stripe_canvas(); + + // The stroke must have moved something... + let moved = (0..SIZE) + .flat_map(|y| (0..SIZE).map(move |x| (x, y))) + .filter(|&(x, y)| { + let a = pixel(&pixels, x, y)[0] as i32; + let b = pixel(&source, x, y)[0] as i32; + (a - b).abs() >= 100 + }) + .count(); + assert!( + moved > 200, + "liquify stroke should have displaced content; only {moved} pixels changed", + ); + + // ...without dissolving the stripes into mush anywhere it touched. + for y in 40..90 { + let (mut lo, mut hi) = (255u8, 0u8); + for x in 35..100 { + let r = pixel(&pixels, x, y)[0]; + lo = lo.min(r); + hi = hi.max(r); + } + assert!( + hi - lo >= 150, + "row {y}: red peak-to-peak {} after a full engine-driven \ + liquify stroke (need >= 150) — detail was destroyed", + hi - lo, + ); + } +} + +/// The brush's configured spacing and [`LIQUIFY_SPACING_RATIO`] are the +/// same decision written in two files — the YAML the engine actually +/// reads, and the Rust constant whose doc comment carries the reasoning +/// (why 0.05, and what banding measurement bounds it). Pin them together +/// so neither can drift silently. +/// +/// Spacing is not cosmetic here: pinning it flat in pixels, as this brush +/// used to, makes per-travel cost `O(radius²)` because the dab count stops +/// falling as the disc grows. +#[test] +fn shipped_liquify_spacing_matches_the_declared_ratio() { + let graph = darkly::brush::builtin_brushes::all() + .into_iter() + .find(|b| b.metadata.name == "Liquify") + .expect("Liquify is shipped") + .metadata + .graph; + let spacing = darkly::brush::nodes::brush_settings::spacing_config(&graph); + + assert!( + (spacing.ratio - LIQUIFY_SPACING_RATIO).abs() < 1e-6, + "brushes/liquify.yaml sets spacing {} but LIQUIFY_SPACING_RATIO is {}", + spacing.ratio, + LIQUIFY_SPACING_RATIO, + ); + assert!( + spacing.ratio > 0.0, + "liquify spacing must stay proportional to dab size; a zero ratio \ + falls back to the pixel floor and restores O(radius²) cost", + ); + + // A large brush must actually get large steps — the whole point. + let big_diameter = 1000.0; + assert!( + spacing.distance(big_diameter) >= 40.0, + "a {big_diameter}px-diameter liquify brush should step >= 40px, got {}", + spacing.distance(big_diameter), + ); +} diff --git a/crates/darkly/tests/paint_basic.rs b/crates/darkly/tests/paint_basic.rs index 033478f9..8b5a1ed9 100644 --- a/crates/darkly/tests/paint_basic.rs +++ b/crates/darkly/tests/paint_basic.rs @@ -78,7 +78,13 @@ fn render_single_dab_with_pressure( &queue, &darkly::gpu::selection::selection_mask_bgl(&device), ); - let mut stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines); + let mut stroke_buffer = StrokeBuffer::new( + &device, + CANVAS, + CANVAS, + &pipelines, + darkly::brush::node::COLOR_SCRATCH_FORMAT, + ); let pre_stroke = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture( &layer_texture, diff --git a/crates/darkly/tests/picker_preview.rs b/crates/darkly/tests/picker_preview.rs new file mode 100644 index 00000000..fa523cbb --- /dev/null +++ b/crates/darkly/tests/picker_preview.rs @@ -0,0 +1,685 @@ +//! Picker preview generation, over every previewable catalog. +//! +//! The engine half: that a request enqueues, that generation is paced across +//! ticks, that the frames move, that polling hands the job over, and that none +//! of it touches the document. Plus the property the whole design rests on — +//! `preview_at` is absolute, so a sequence reaches the same state at `t` +//! however it got there. +//! +//! Uses the blocking readback flush (`test_flush_readbacks`) — native-only; the +//! wasm path drains the same `ReadbackScheduler` from the rAF render loop. +//! +//! Run with: `cargo test -p darkly --features testing --test picker_preview -- --test-threads=1` + +use darkly::catalog::preview_mechanisms; +use darkly::engine::preview::PREVIEW_FRAMES_PER_TICK; +use darkly::engine::DarklyEngine; +use darkly::gpu::context::GpuContext; +use darkly::gpu::preview::{ + drive, fit_preview_dims, PreviewRegistries, PreviewSequence, PreviewTarget, PreviewVariant, + PREVIEW_FORMAT, +}; +use darkly::gpu::test_utils::{readback_texture, test_device}; + +/// A `w × h` RGBA gradient. Deliberately not a flat fill: an effect that +/// redistributes colour — a refraction, a blur, a pixelation — leaves a solid +/// canvas exactly as it found it, so a flat subject would make every motion +/// assertion below vacuous. +fn gradient(w: u32, h: u32) -> Vec { + let mut pixels = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + let i = ((y * w + x) * 4) as usize; + pixels[i..i + 4].copy_from_slice(&[ + (x * 255 / w.max(1)) as u8, + (y * 255 / h.max(1)) as u8, + ((x + y) * 127 / (w + h).max(1)) as u8, + 255, + ]); + } + } + pixels +} + +/// Headless engine whose canvas holds real content, so the composite a +/// source-reading preview samples has something to act on. +fn headless_engine(w: u32, h: u32) -> DarklyEngine { + let (device, queue) = test_device(); + let gpu = GpuContext::new_headless(device, queue); + let mut engine = DarklyEngine::new(gpu, w, h); + engine.paste_image(w, h, &gradient(w, h), 0, 0, None); + engine +} + +/// Run the engine's own frame loop until the preview completes, or give up +/// after a generous bound. Each tick pumps at most `PREVIEW_FRAMES_PER_TICK` +/// frames and drains whatever landed, which is exactly what the browser does. +/// Returns `(width, height, fps, frames)`. +fn drain( + engine: &mut DarklyEngine, + catalog: &str, + type_id: &str, + variant: PreviewVariant, +) -> (u32, u32, u32, Vec>) { + for _ in 0..512 { + if let Some(result) = engine.poll_preview(catalog, type_id, variant) { + return result; + } + engine.render(0.0); + engine.test_flush_readbacks(); + } + panic!("{variant:?} preview for {catalog}/{type_id} never completed"); +} + +/// Request one variant and drain it — the shape every engine-level test uses. +fn preview( + engine: &mut DarklyEngine, + catalog: &str, + type_id: &str, + variant: PreviewVariant, +) -> (u32, u32, u32, Vec>) { + engine.start_preview(catalog, type_id, variant); + drain(engine, catalog, type_id, variant) +} + +/// One preview target loaded with a flat source, plus the registries a session +/// opens against — the pieces both the engine and the documentation binary +/// assemble, here without either. +struct Offscreen { + gpu: (wgpu::Device, wgpu::Queue), + target: PreviewTarget, + veils: darkly::gpu::veil::VeilRegistry, + voids: darkly::gpu::void::VoidRegistry, + filters: darkly::gpu::filter::FilterPipelineRegistry, + _source: wgpu::Texture, +} + +impl Offscreen { + fn new() -> Self { + const DIM: u32 = 64; + let (device, queue) = test_device(); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("picker-preview-test-source"), + size: wgpu::Extent3d { + width: DIM, + height: DIM, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: PREVIEW_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + texture.as_image_copy(), + &gradient(DIM, DIM), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(DIM * 4), + rows_per_image: Some(DIM), + }, + wgpu::Extent3d { + width: DIM, + height: DIM, + depth_or_array_layers: 1, + }, + ); + + let mut target = PreviewTarget::new(); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + target.load_source(&device, &queue, &view, DIM, DIM); + + Offscreen { + gpu: (device, queue), + target, + veils: darkly::gpu::veil::VeilRegistry::new(), + voids: darkly::gpu::void::VoidRegistry::new(), + filters: darkly::gpu::filter::FilterPipelineRegistry::new(), + _source: texture, + } + } + + /// Every frame of one entry's animation, driven through the blocking sink. + fn render(&mut self, catalog: &str, type_id: &str) -> Vec> { + self.render_range(catalog, type_id, PreviewVariant::Animated, 0) + } + + /// The one frame an entry's still variant produces. + fn render_still(&mut self, catalog: &str, type_id: &str) -> Vec> { + self.render_range(catalog, type_id, PreviewVariant::Still, 0) + } + + /// The source an effect reading one was handed — the downscaled subject, at + /// the same size as the frames it produces. + fn source(&self) -> Vec { + let (w, h) = self.target.size(); + readback_texture( + &self.gpu.0, + &self.gpu.1, + self.target.source_texture(), + PREVIEW_FORMAT, + w, + h, + ) + } + + /// One variant's frames from `start` onward, so a test can compare a + /// sequence resumed mid-run against one run from the beginning. + fn render_range( + &mut self, + catalog: &str, + type_id: &str, + variant: PreviewVariant, + start: u32, + ) -> Vec> { + let (_, mech) = preview_mechanisms() + .into_iter() + .find(|(id, _)| *id == catalog) + .unwrap_or_else(|| panic!("`{catalog}` has a preview mechanism")); + if mech.reads_source() { + // Already loaded in `new`. + } else { + let (device, queue) = &self.gpu; + self.target.clear_source(device, queue, 64, 64); + } + let (w, h) = self.target.size(); + let (device, queue) = (self.gpu.0.clone(), self.gpu.1.clone()); + let Offscreen { + target, + veils, + voids, + filters, + .. + } = self; + let regs = PreviewRegistries { + veils, + voids, + filters, + }; + let mut seq = PreviewSequence::open(mech, regs, type_id, variant) + .unwrap_or_else(|| panic!("`{catalog}/{type_id}` opens")); + seq.seek(start); + let mut frames = Vec::new(); + drive( + &mut seq, + &device, + &queue, + target, + |encoder, output, _, _| { + queue.submit([encoder.finish()]); + frames.push(readback_texture( + &device, + &queue, + output, + PREVIEW_FORMAT, + w, + h, + )); + }, + ); + frames + } +} + +/// One entry's declared animation, read through the same mechanism table the +/// consumers dispatch through. +fn still_point(catalog: &str, type_id: &str) -> darkly::gpu::preview::PreviewAnim { + preview_mechanisms() + .into_iter() + .find(|(id, _)| *id == catalog) + .and_then(|(_, mech)| mech.resolve(type_id)) + .unwrap_or_else(|| panic!("`{catalog}/{type_id}` resolves")) + .anim +} + +/// Every previewable entry of every offscreen catalog, as `(catalog, type_id)`. +/// Reads the generated mechanism table, so a new previewable catalog is covered +/// by every test below without one of them being edited. +fn offscreen_entries() -> Vec<(&'static str, &'static str)> { + let mut out = Vec::new(); + for cat in darkly::catalog::catalogs() { + let Some((id, mech)) = preview_mechanisms() + .into_iter() + .find(|(id, _)| *id == cat.id) + else { + continue; + }; + for e in cat.entries.iter().filter(|e| e.supports_preview) { + let entry = mech + .resolve(e.type_id) + .unwrap_or_else(|| panic!("`{id}/{}` resolves", e.type_id)); + out.push((id, entry.type_id)); + } + } + assert!(!out.is_empty(), "no offscreen previewable entries at all"); + out +} + +// --------------------------------------------------------------------------- +// The property the design rests on +// --------------------------------------------------------------------------- + +/// A sequence resumed mid-run renders exactly what an uninterrupted one would. +/// +/// This is what replaces the whole rebuild-and-replay apparatus a delta-driven +/// preview needs: because `preview_at(t)` puts an instance into a state that +/// depends on `t` and nothing else, the engine can drop a sequence at the end of +/// a tick and re-open it on the next one without replaying a single frame. Every +/// previewable entry is held to it — including the two that rebuild their cache +/// mid-sweep, where the property is least obvious. +#[test] +fn preview_at_is_absolute() { + let mut off = Offscreen::new(); + for (catalog, type_id) in offscreen_entries() { + let whole = off.render(catalog, type_id); + if whole.len() < 4 { + continue; + } + let start = (whole.len() / 2) as u32; + let resumed = off.render_range(catalog, type_id, PreviewVariant::Animated, start); + assert_eq!( + resumed.len(), + whole.len() - start as usize, + "`{catalog}/{type_id}` resumed with the wrong frame count" + ); + for (i, frame) in resumed.iter().enumerate() { + assert_eq!( + frame, + &whole[start as usize + i], + "`{catalog}/{type_id}` frame {} differs when the sequence is resumed \ + rather than run from the start", + start as usize + i + ); + } + } +} + +/// An entry's still *is* its animation's frame at the declared still point — +/// byte for byte, for every previewable entry. +/// +/// This is what makes the hover hand-off invisible: a card shows the still, the +/// pointer arrives, the sequence starts playing, and the frame it starts from is +/// the frame already on screen. It is also why a still costs one frame rather +/// than forty-eight — `preview_at` is absolute, so the sequence can be sampled +/// at one point without running the rest. +#[test] +fn a_still_is_the_animations_frame_at_its_still_point() { + let mut off = Offscreen::new(); + for (catalog, type_id) in offscreen_entries() { + let anim = still_point(catalog, type_id); + let whole = off.render(catalog, type_id); + let still = off.render_still(catalog, type_id); + + assert_eq!( + still.len(), + 1, + "`{catalog}/{type_id}`'s still is not one frame" + ); + assert!( + anim.still_frame() < whole.len() as u32, + "`{catalog}/{type_id}`'s still point falls outside its own sequence" + ); + assert_eq!( + still[0], + whole[anim.still_frame() as usize], + "`{catalog}/{type_id}`'s still is not frame {} of its animation", + anim.still_frame() + ); + } +} + +/// Every still shows the effect actually doing something to the image. +/// +/// The one thing a card at rest must not be is the untouched canvas — a still +/// that matched its own source would read as the effect being off, which is +/// what `still_at` exists to steer away from. Stated against the *source* +/// rather than against frame 0 on purpose: `black_and_white` is fully applied at +/// rest and animates a secondary control, so its still is deliberately its +/// resting frame, and a test that forbade that would be encoding a rule the +/// tree does not follow. +/// +/// Source-reading mechanisms only. A void is handed a cleared texture and +/// generates its own content, so "differs from its source" says nothing about +/// it; `noise_void_frames_are_not_uniform` is where that catalog is held. +#[test] +fn a_still_shows_the_effect_doing_something() { + let mut off = Offscreen::new(); + let source = off.source(); + for (catalog, type_id) in offscreen_entries() { + let (_, mech) = preview_mechanisms() + .into_iter() + .find(|(id, _)| *id == catalog) + .unwrap(); + if !mech.reads_source() { + continue; + } + let still = off.render_still(catalog, type_id); + assert_ne!( + still[0], source, + "`{catalog}/{type_id}`'s still is its own untouched source, which reads \ + as the effect being off" + ); + } +} + +/// Two runs of the same entry through the same target produce the same pixels. +/// +/// Determinism is what lets the engine hand a half-finished job across ticks and +/// what lets the documentation renderer reuse one device for thirty-four assets. +/// A session that left state behind — in its instance, its cache, or the shared +/// target — shows up here. +#[test] +fn rendering_an_entry_twice_produces_the_same_frames() { + let mut off = Offscreen::new(); + for (catalog, type_id) in offscreen_entries() { + let first = off.render(catalog, type_id); + let again = off.render(catalog, type_id); + assert_eq!( + first, again, + "`{catalog}/{type_id}` rendered two different sequences" + ); + } +} + +/// Every entry declaring more than one frame renders at least two distinct +/// images, and an entry declaring one renders exactly one. +/// +/// The defect this whole change exists to fix: `frozen` and `pixelate` shipped +/// as single stills at their schema defaults because the live path never read +/// their declared motion. +#[test] +fn every_animated_entry_actually_moves() { + let mut off = Offscreen::new(); + for (catalog, type_id) in offscreen_entries() { + let frames = off.render(catalog, type_id); + assert!(!frames.is_empty(), "`{catalog}/{type_id}` rendered nothing"); + if frames.len() == 1 { + continue; + } + assert!( + frames.windows(2).any(|pair| pair[0] != pair[1]), + "`{catalog}/{type_id}` rendered {} identical frames", + frames.len() + ); + } +} + +/// One sequence over one target, run through the blocking sink and through a +/// recording stand-in for the engine's asynchronous one, produces the same +/// frames. +/// +/// The test that says there is one system: the two consumers differ in how they +/// *capture*, and in nothing else. Comparing two consumers over two +/// differently-loaded subjects would be asserting something else — the engine +/// loads the user's composite and the binary loads its own field. +#[test] +fn the_driver_is_sink_agnostic() { + let mut off = Offscreen::new(); + let blocking = off.render("veils", "frozen"); + + // The recording sink defers the readback the way the engine defers it to a + // scheduler: finish and submit inside the capture, read back afterwards. + let (device, queue) = (off.gpu.0.clone(), off.gpu.1.clone()); + let (w, h) = off.target.size(); + let mut outputs: Vec = Vec::new(); + { + let Offscreen { + target, + veils, + voids, + filters, + .. + } = &mut off; + let regs = PreviewRegistries { + veils, + voids, + filters, + }; + let (_, mech) = preview_mechanisms() + .into_iter() + .find(|(id, _)| *id == "veils") + .unwrap(); + let mut seq = + PreviewSequence::open(mech, regs, "frozen", PreviewVariant::Animated).unwrap(); + drive( + &mut seq, + &device, + &queue, + target, + |encoder, output, _, _| { + // Copy the frame aside inside the same submission that produced it, + // which is the guarantee the engine's readback request relies on. + let mut encoder = encoder; + let (copy, _) = darkly::gpu::create_texture_with_view( + &device, + w, + h, + PREVIEW_FORMAT, + "sink-agnostic-copy", + wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC, + ); + darkly::gpu::blit_region(&mut encoder, output, (0, 0), ©, (0, 0), w, h); + queue.submit([encoder.finish()]); + outputs.push(copy); + }, + ); + } + let deferred: Vec> = outputs + .iter() + .map(|t| readback_texture(&device, &queue, t, PREVIEW_FORMAT, w, h)) + .collect(); + + assert_eq!( + blocking, deferred, + "the same sequence produced different frames through two sinks" + ); +} + +// --------------------------------------------------------------------------- +// The live consumer +// --------------------------------------------------------------------------- + +/// `frozen` yields its full declared frame count with visible motion — the exact +/// defect of the shipped path, which showed one still at `strength = 0.04`. +#[test] +fn picker_preview_runs_the_animation_not_the_defaults() { + let mut engine = headless_engine(256, 256); + let anim = darkly::gpu::veil::VeilRegistry::new() + .preview("frozen") + .expect("frozen declares a preview"); + + let (w, h, fps, frames) = preview(&mut engine, "veils", "frozen", PreviewVariant::Animated); + + assert_eq!(frames.len(), anim.frames as usize); + assert_eq!(fps, anim.fps, "the wire fps is the entry's own"); + assert!(w > 0 && h > 0); + for (i, f) in frames.iter().enumerate() { + assert_eq!( + f.len(), + (w * h * 4) as usize, + "frame {i} is not packed RGBA8" + ); + } + assert!( + frames.windows(2).any(|pair| pair[0] != pair[1]), + "the frozen preview is still a single state repeated" + ); +} + +/// A filter previews through the same offscreen path as a veil — a capability +/// the picker did not have at all before, and the second invocation contract +/// the mechanism trait has to satisfy. +#[test] +fn filter_picker_preview_renders_through_the_offscreen_path() { + let mut engine = headless_engine(256, 256); + let (w, h, _, frames) = preview(&mut engine, "filters", "hsv", PreviewVariant::Animated); + + let anim = darkly::gpu::filter::FilterPipelineRegistry::new() + .preview("hsv") + .unwrap(); + assert_eq!(frames.len(), anim.frames as usize); + assert_eq!(frames[0].len(), (w * h * 4) as usize); + assert!(frames.windows(2).any(|pair| pair[0] != pair[1])); +} + +/// `invert` takes no parameters, declares one frame, and yields exactly one. +#[test] +fn a_still_preview_is_one_frame() { + let mut engine = headless_engine(256, 256); + let (w, h, _, frames) = preview(&mut engine, "filters", "invert", PreviewVariant::Animated); + assert_eq!(frames.len(), 1, "invert declares a still"); + assert_eq!(frames[0].len(), (w * h * 4) as usize); +} + +/// A void generates its own content at the canvas's aspect-fit preview size. +#[test] +fn a_void_previews_from_scratch_at_the_canvas_aspect() { + let mut engine = headless_engine(800, 400); + let (w, h, _, frames) = preview(&mut engine, "voids", "noise", PreviewVariant::Animated); + + assert_eq!((w, h), fit_preview_dims(800, 400)); + assert!(w > h, "a wide canvas yields a wide preview"); + // Real content, not a blank buffer — and not a flat colour either. + let first: &[u8] = &frames[0][..4]; + assert!(frames[0].chunks_exact(4).any(|px| px != first)); +} + +/// Generation is paced: `start_preview` produces nothing on its own, and no +/// single tick encodes more than the per-tick budget. +/// +/// Frames in flight are unpooled `MAP_READ` staging buffers, so the budget is +/// what bounds the memory a picker open costs. +#[test] +fn a_preview_completes_across_ticks() { + let mut engine = headless_engine(256, 256); + engine.start_preview("veils", "frozen", PreviewVariant::Animated); + assert!( + engine + .poll_preview("veils", "frozen", PreviewVariant::Animated) + .is_none(), + "start_preview must enqueue, not generate" + ); + + let total = darkly::gpu::veil::VeilRegistry::new() + .preview("frozen") + .unwrap() + .frames; + // One tick cannot finish a sequence longer than the budget. + assert!(total > PREVIEW_FRAMES_PER_TICK); + engine.render(0.0); + engine.test_flush_readbacks(); + assert!( + engine + .poll_preview("veils", "frozen", PreviewVariant::Animated) + .is_none(), + "one tick encoded more than PREVIEW_FRAMES_PER_TICK frames" + ); + + let (_, _, _, frames) = drain(&mut engine, "veils", "frozen", PreviewVariant::Animated); + assert_eq!(frames.len(), total as usize); +} + +/// A card asks for a still and gets exactly one frame; hovering asks for the +/// animation and gets the whole sequence. The two are keyed apart, so the +/// animation landing never discards the still already on screen. +#[test] +fn the_two_variants_are_separate_jobs() { + let mut engine = headless_engine(256, 256); + let anim = darkly::gpu::veil::VeilRegistry::new() + .preview("frozen") + .unwrap(); + + // What a picker card costs on mount: one frame. + engine.start_preview("veils", "frozen", PreviewVariant::Still); + // And what hovering it costs, requested while the still is still in flight — + // the order a real card produces. + engine.start_preview("veils", "frozen", PreviewVariant::Animated); + + let (sw, sh, _, still) = drain(&mut engine, "veils", "frozen", PreviewVariant::Still); + assert_eq!(still.len(), 1, "a still is one frame"); + + let (aw, ah, _, animated) = drain(&mut engine, "veils", "frozen", PreviewVariant::Animated); + assert_eq!(animated.len(), anim.frames as usize); + assert_eq!((sw, sh), (aw, ah), "both variants share the target's size"); + + // The still is the frame the animation starts playing from, so the hand-off + // on hover shows no jump. + assert_eq!( + still[0], + animated[anim.still_frame() as usize], + "the still is not the animation's own frame at its still point" + ); +} + +/// Polling hands the job over rather than cloning it: a second poll answers +/// `None`, and the next open regenerates against the canvas as it then stands. +#[test] +fn polling_a_completed_preview_releases_it() { + let mut engine = headless_engine(128, 128); + let v = PreviewVariant::Animated; + assert_eq!(preview(&mut engine, "filters", "invert", v).3.len(), 1); + assert!( + engine.poll_preview("filters", "invert", v).is_none(), + "a completed preview must be handed over once" + ); + + assert_eq!(preview(&mut engine, "filters", "invert", v).3.len(), 1); +} + +/// A request naming a catalog or a type the binary does not ship is a no-op — +/// the wire carries arbitrary strings, and there is nothing to render. +#[test] +fn an_unknown_catalog_or_type_is_a_no_op() { + let mut engine = headless_engine(64, 64); + for (catalog, type_id) in [ + ("nope", "frozen"), + ("veils", "does_not_exist"), + ("voids", "does_not_exist"), + ("filters", "does_not_exist"), + // A catalog with no offscreen mechanism: previewable as a documentation + // asset, with nothing for a picker to open. + ("blendModes", "multiply"), + ] { + for variant in [PreviewVariant::Still, PreviewVariant::Animated] { + engine.start_preview(catalog, type_id, variant); + engine.render(0.0); + engine.test_flush_readbacks(); + assert!( + engine.poll_preview(catalog, type_id, variant).is_none(), + "`{catalog}/{type_id}` produced a {variant:?} preview" + ); + } + } +} + +/// Generating a preview of any catalog leaves the document exactly as it was. +/// +/// The isolation the whole offscreen path exists for: a preview builds its own +/// instance against its own textures, so the live veil chain, layer stack and +/// active layer are never touched. +#[test] +fn preview_generation_never_mutates_the_document() { + let mut engine = headless_engine(128, 128); + let before_layers = engine.layer_tree().len(); + let before_veils = engine.veil_list().len(); + + for (catalog, type_id) in [ + ("veils", "black_and_white"), + ("voids", "noise"), + ("filters", "invert"), + ] { + preview(&mut engine, catalog, type_id, PreviewVariant::Animated); + assert_eq!( + engine.layer_tree().len(), + before_layers, + "`{catalog}/{type_id}` changed the layer tree" + ); + assert_eq!( + engine.veil_list().len(), + before_veils, + "`{catalog}/{type_id}` changed the veil chain" + ); + } +} diff --git a/crates/darkly/tests/port_ranges.rs b/crates/darkly/tests/port_ranges.rs new file mode 100644 index 00000000..271cd0c0 --- /dev/null +++ b/crates/darkly/tests/port_ranges.rs @@ -0,0 +1,207 @@ +//! Author-declared slider ranges — per-instance `PortDef::min`/`max`. +//! +//! A brush author can re-range any input port for one brush, so a control +//! whose registration range is a poor fit (a math node's hardcoded `0..1` +//! standing in for a bipolar knob, or a useful band occupying a sliver of +//! the declared range) becomes usable without a helper node in the graph +//! doing the arithmetic. The range lives on the instance port, so the +//! brush bar and the node editor both see it. + +use darkly::brush::builtin_brushes; +use darkly::engine::{DarklyEngine, ExposedValue}; +use darkly::gpu::context::GpuContext; +use darkly::gpu::test_utils::test_device; +use darkly::nodegraph::{NodeId, PortDir}; + +fn fresh_engine() -> DarklyEngine { + let (device, queue) = test_device(); + let gpu = GpuContext::new_headless(device, queue); + DarklyEngine::new(gpu, 256, 256) +} + +/// Read the `(min, max, value)` the brush bar would render for an exposed +/// scalar control, by label. +fn scalar_control(engine: &DarklyEngine, label: &str) -> (f32, f32, f32) { + let info = engine + .brush_exposed_ports() + .into_iter() + .find(|p| p.label == label) + .unwrap_or_else(|| panic!("no exposed control labelled '{label}'")); + match info.data { + ExposedValue::Scalar { + value, min, max, .. + } => (min, max, value), + other => panic!("'{label}' is not a scalar control: {other:?}"), + } +} + +/// The end-to-end path the feature exists for: a range set through the +/// engine handler reaches the brush bar's reported bounds, and the port's +/// authored value is left alone by the re-range. +#[test] +fn declared_range_reaches_the_brush_bar() { + let mut engine = fresh_engine(); + engine.brush_load("Hair").expect("Hair builtin loads"); + + let (min, max, _) = scalar_control(&engine, "Twirl"); + assert_eq!( + (min, max), + (-1.0, 1.0), + "Hair's Twirl declares a bipolar range in its yaml" + ); + + // Re-range it through the handler and confirm the brush bar follows. + engine + .brush_graph_set_port_range("multiply_2", "a", -4.0, 4.0) + .expect("re-range succeeds"); + let (min, max, value) = scalar_control(&engine, "Twirl"); + assert_eq!((min, max), (-4.0, 4.0)); + assert!( + (value - 0.5).abs() < 1e-6, + "re-ranging must not disturb the authored value, got {value}" + ); +} + +/// Bounds are UI hints; a degenerate or inverted one breaks the normalize +/// and clamp arithmetic every slider does, so the handler rejects them +/// rather than letting a broken control reach the bar. +#[test] +fn engine_rejects_degenerate_and_inverted_ranges() { + let mut engine = fresh_engine(); + engine.brush_load("Hair").expect("Hair builtin loads"); + + for (min, max) in [(1.0_f32, 1.0_f32), (1.0, -1.0)] { + assert!( + engine + .brush_graph_set_port_range("multiply_2", "a", min, max) + .is_err(), + "({min}, {max}) should be rejected" + ); + } + // The original range survived every rejection. + assert_eq!(scalar_control(&engine, "Twirl").0, -1.0); +} + +/// The handler's numbers are display-space, the storage is raw. Without the +/// conversion a `Percent` port's declared range drifts by 100× on every +/// save/reload cycle, which is invisible until a brush is reopened. +#[test] +fn percent_port_range_round_trips_through_display_space() { + let mut engine = fresh_engine(); + engine.brush_load("Hair").expect("Hair builtin loads"); + + // `brush_settings.stabilize` is declared `UnitType::Percent`, so a + // display range of 0-50% must land as a raw 0.0-0.5. + let json = engine + .brush_graph_set_port_range("brush_settings", "stabilize", 0.0, 50.0) + .expect("re-range succeeds"); + + let graph: serde_json::Value = serde_json::from_str(&json).expect("graph json"); + let port = graph["nodes"]["brush_settings"]["ports"] + .as_array() + .expect("ports array") + .iter() + .find(|p| p["name"] == "stabilize") + .expect("stabilize port"); + assert_eq!(port["min"].as_f64().unwrap(), 0.0); + assert_eq!( + port["max"].as_f64().unwrap(), + 0.5, + "display 50% must store as raw 0.5" + ); + + // And it comes back out in the space it went in. + let (min, max, _) = scalar_control(&engine, "Stabilize"); + assert_eq!((min, max), (0.0, 50.0)); +} + +/// The Hair conversion: both controls that used to need a helper node are +/// now plain exposed ports carrying a declared range. +/// +/// The Twirl assertion is the real invariant of the conversion. Rotation is +/// `(distance/size) × multiply.b × multiply_2.a`, so replacing the +/// `subtract`-recentered `0..1` control with a bipolar one required halving +/// `multiply.b`. The product of the two is what must be conserved — check +/// it, not the two literals separately. +#[test] +fn hair_expresses_both_controls_without_helper_nodes() { + let hair = builtin_brushes::all() + .into_iter() + .find(|b| b.metadata.name == "Hair") + .expect("Hair builtin exists"); + let graph = &hair.metadata.graph; + + let port = |node: &str, port: &str| { + graph + .nodes() + .get(&NodeId(node.into())) + .unwrap_or_else(|| panic!("Hair has a '{node}' node")) + .ports + .iter() + .find(|p| p.name == port && p.dir == PortDir::Input) + .unwrap_or_else(|| panic!("'{node}' has an input '{port}'")) + }; + + // Twirl: bipolar control, and the rotation coefficient is conserved + // against the pre-conversion `0.25 × 5.12`. + let twirl = port("multiply_2", "a"); + assert_eq!((twirl.min, twirl.max), (-1.0, 1.0)); + let coefficient = twirl.value.as_f32() * port("multiply", "b").value.as_f32(); + assert!( + (coefficient - 1.28).abs() < 1e-5, + "twirl coefficient drifted: {coefficient}" + ); + + // Hair Thickness: the slider spans the usable band directly, and its + // stored value is the midpoint the curve node used to produce. + let thickness = port("multiply_3", "b"); + assert!((thickness.min - 0.03296951).abs() < 1e-7); + assert!((thickness.max - 0.21059628).abs() < 1e-7); + assert!((thickness.value.as_f32() - 0.1217829).abs() < 1e-6); + + // Neither control routes through a helper node any more: `multiply_3.b` + // and `multiply_2.a` are unwired, which is also what keeps them + // user-scrubbable at all. + for (node, port_name) in [("multiply_3", "b"), ("multiply_2", "a")] { + assert!( + !graph + .connections + .iter() + .any(|c| c.to.node.0 == node && c.to.port == port_name), + "{node}.{port_name} should be driven by the user, not a wire" + ); + } + + // And the two workaround nodes are gone, not merely bypassed. + assert_eq!( + graph + .nodes() + .values() + .filter(|n| n.type_id == "curve" || n.type_id == "subtract") + .count(), + 2, + "Hair should keep only its pressure curve and its noise subtract" + ); +} + +/// Every builtin's declared ranges are well-formed. This is the guard that +/// makes the yaml key safe to hand to brush authors: a typo'd range fails +/// the suite instead of shipping a control that can't be dragged. +#[test] +fn every_builtin_declares_sane_ranges() { + for brush in builtin_brushes::all() { + let name = &brush.metadata.name; + for node in brush.metadata.graph.nodes().values() { + for port in node.ports.iter().filter(|p| p.dir == PortDir::Input) { + assert!( + port.min.is_finite() && port.max.is_finite() && port.min < port.max, + "{name}: {}.{} has range ({}, {})", + node.type_id, + port.name, + port.min, + port.max + ); + } + } + } +} diff --git a/crates/darkly/tests/rough_ink.rs b/crates/darkly/tests/rough_ink.rs index 2fe70ca1..5208cdfc 100644 --- a/crates/darkly/tests/rough_ink.rs +++ b/crates/darkly/tests/rough_ink.rs @@ -139,7 +139,13 @@ fn harness(initial: &[u8], graph: Graph) -> Harness { &queue, &darkly::gpu::selection::selection_mask_bgl(&device), ); - let stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines); + let stroke_buffer = StrokeBuffer::new( + &device, + CANVAS, + CANVAS, + &pipelines, + darkly::brush::node::COLOR_SCRATCH_FORMAT, + ); let pre_stroke_paint_target = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture( &layer_texture, @@ -360,7 +366,13 @@ fn builtin_rough_ink_brush_renders_within_declared_bbox() { &queue, &darkly::gpu::selection::selection_mask_bgl(&device), ); - let stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines); + let stroke_buffer = StrokeBuffer::new( + &device, + CANVAS, + CANVAS, + &pipelines, + darkly::brush::node::COLOR_SCRATCH_FORMAT, + ); let pre_stroke_paint_target = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture( &layer_texture, &layer_view, @@ -486,26 +498,11 @@ fn rough_ink_overlapping_dabs_render_without_truncation() { 0.15, ) .unwrap(); - // Replace the builtin's pressure-shaping curve (a monotone Hermite - // spline through `(0,0), (0.4,0.7), (1,1)`) with the identity curve - // so this test's `r_a` / `r_b` math (radius ∝ pressure) lines up - // with what the CPU side packs into the dab record. The QUAD_R_MAX- - // vs-radius divergence we're guarding against is independent of the - // curve shape. - let curve_id = graph - .nodes() - .iter() - .find(|(_, n)| n.type_id == darkly::brush::nodes::curve::TYPE_ID) - .map(|(id, _)| id.clone()) - .unwrap(); - graph - .set_port_value( - &curve_id, - "curve", - InputValue::Curve(vec![[0.0, 0.0], [1.0, 1.0]]), - ) - .unwrap(); - + // Rough Ink wires `pen_input.pressure` straight into `paint.size`, so + // radius is already ∝ pressure and the `r_a` / `r_b` math below lines + // up with what the CPU side packs into the dab record without any + // shaping to undo. The QUAD_R_MAX-vs-radius divergence this guards + // against is independent of how pressure is shaped anyway. let mut h = harness(&black_canvas(), graph); let compiled = h.runner.compiled_brush().expect("compiled brush attached"); h.begin_stroke(); @@ -530,8 +527,8 @@ fn rough_ink_overlapping_dabs_render_without_truncation() { let mut dab_a_pixels = 0; let mut dab_b_pixels = 0; let dab_size = 0.15 * darkly::brush::DAB_REFERENCE_SIZE as f32 * 0.5; - // Per-dab effective_radius differs only through the curve(pressure) - // wire; the brush's curve is identity-shape so radius ∝ pressure. + // Per-dab effective_radius differs only through the pressure → size + // wire, which the brush drives directly, so radius ∝ pressure. let r_a = (dab_size * 0.5 * bbox_factor) + 1.0; let r_b = (dab_size * 1.0 * bbox_factor) + 1.0; for y in 0..CANVAS { diff --git a/crates/darkly/tests/smudge.rs b/crates/darkly/tests/smudge.rs index 638c5086..87108a85 100644 --- a/crates/darkly/tests/smudge.rs +++ b/crates/darkly/tests/smudge.rs @@ -89,7 +89,13 @@ fn render_smudge_dabs(size_override: f32, dabs: &[([f32; 2], [f32; 2])]) -> Vec< &queue, &darkly::gpu::selection::selection_mask_bgl(&device), ); - let mut stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines); + let mut stroke_buffer = StrokeBuffer::new( + &device, + CANVAS, + CANVAS, + &pipelines, + darkly::brush::node::COLOR_SCRATCH_FORMAT, + ); let pre_stroke = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture( &layer_texture, diff --git a/crates/darkly/tests/veil_preview.rs b/crates/darkly/tests/veil_preview.rs deleted file mode 100644 index 6000d23b..00000000 --- a/crates/darkly/tests/veil_preview.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Veil picker preview generation. -//! -//! Verifies the offscreen veil preview renderer (`gpu::veil_preview`) and its -//! engine wiring: the preview is rendered over the *current canvas*, animated -//! veils yield a multi-frame loop with visible motion, static veils yield a -//! single frame, generation regenerates on each call (no cross-open cache), and -//! it never mutates the live veil chain. Uses the blocking readback flush -//! (`test_flush_readbacks`) — native-only; the wasm path drains the same -//! `ReadbackScheduler` via the rAF render loop. - -use darkly::engine::{DarklyEngine, PreviewKind}; -use darkly::gpu::context::GpuContext; -use darkly::gpu::preview::ANIMATED_FRAMES; -use darkly::gpu::test_utils::test_device; - -/// Headless engine with a solid-filled canvas so the composite the preview -/// samples has real content. -fn headless_engine() -> DarklyEngine { - let (device, queue) = test_device(); - let gpu = GpuContext::new_headless(device, queue); - let mut engine = DarklyEngine::new(gpu, 256, 256); - let layer = engine.add_raster_layer(None); - engine.fill_background_color(layer, [120, 60, 200, 255]); - engine -} - -/// Drive blocking readback flushes until the preview completes, or give up -/// after a generous bound (each animated preview is `ANIMATED_FRAMES` tiny -/// readbacks, all submitted up front). Returns `(width, height, frames)`. -fn drain_preview(engine: &mut DarklyEngine, type_id: &str) -> (u32, u32, Vec>) { - for _ in 0..256 { - if let Some(result) = engine.poll_preview(PreviewKind::Veil, type_id) { - return result; - } - engine.test_flush_readbacks(); - } - panic!("veil preview for {type_id} never completed"); -} - -#[test] -fn animated_veil_preview_generates_distinct_frames() { - let mut engine = headless_engine(); - - engine.start_veil_preview("vhs"); - let (w, h, frames) = drain_preview(&mut engine, "vhs"); - - assert_eq!( - frames.len(), - ANIMATED_FRAMES as usize, - "animated veil should produce a full frame loop" - ); - let expected_len = (w * h * 4) as usize; - assert!(w > 0 && h > 0, "preview should have non-zero dimensions"); - for (i, f) in frames.iter().enumerate() { - assert_eq!(f.len(), expected_len, "frame {i} has wrong byte length"); - } - - // Time is advancing between frames → at least one consecutive pair differs. - let any_motion = frames.windows(2).any(|pair| pair[0] != pair[1]); - assert!(any_motion, "animated veil frames should differ (motion)"); - - // The live veil chain is untouched: no veil was added to the document. - assert!( - engine.veil_list().is_empty(), - "preview generation must not mutate the live veil chain" - ); -} - -#[test] -fn static_veil_preview_is_single_frame() { - let mut engine = headless_engine(); - - engine.start_veil_preview("black_and_white"); - let (w, h, frames) = drain_preview(&mut engine, "black_and_white"); - - assert_eq!( - frames.len(), - 1, - "non-animated veil should produce exactly one frame" - ); - assert_eq!(frames[0].len(), (w * h * 4) as usize); - - // Pin the veil-side formula to the same shared core the filter pins in - // `tests/filters.rs`: default params → Lightness gray of the - // [120,60,200] canvas fill, (200+60)/2 = 130, neutral across RGB. - let center = ((h / 2) * w + w / 2) as usize * 4; - let p = &frames[0][center..center + 4]; - assert!( - p[0] == p[1] && p[1] == p[2], - "black_and_white veil must produce neutral gray, got {p:?}" - ); - assert!( - (p[0] as i32 - 130).abs() <= 1, - "default (Lightness) gray of [120,60,200] is ~130, got {p:?}" - ); -} - -#[test] -fn veil_preview_regenerates_on_each_open() { - let mut engine = headless_engine(); - - engine.start_veil_preview("black_and_white"); - let first = drain_preview(&mut engine, "black_and_white"); - - // No caching: a second start after completion re-renders against the live - // canvas and produces a fresh, valid preview. - engine.start_veil_preview("black_and_white"); - let second = drain_preview(&mut engine, "black_and_white"); - assert_eq!(first.0, second.0); - assert_eq!(first.1, second.1); - assert_eq!(first.2.len(), second.2.len()); -} - -#[test] -fn unknown_veil_type_is_ignored() { - let mut engine = headless_engine(); - engine.start_veil_preview("does_not_exist"); - assert!(engine - .poll_preview(PreviewKind::Veil, "does_not_exist") - .is_none()); -} diff --git a/crates/darkly/tests/void_layer.rs b/crates/darkly/tests/void_layer.rs index 4d4996b4..fc6ae3ae 100644 --- a/crates/darkly/tests/void_layer.rs +++ b/crates/darkly/tests/void_layer.rs @@ -25,8 +25,11 @@ fn noise_defaults(engine: &DarklyEngine) -> Vec { #[test] fn noise_void_is_registered() { - let engine = test_engine(64, 64); - let types: Vec<_> = engine.void_types().into_iter().map(|t| t.type_id).collect(); + let types: Vec<_> = darkly::gpu::void::catalog() + .entries + .into_iter() + .map(|e| e.type_id) + .collect(); assert!( types.contains(&"noise"), "noise void must be auto-registered by build.rs; got {types:?}", @@ -353,8 +356,8 @@ fn set_int_param(params: &mut [ParamValue], name: &str, value: i32) { let defs = engine.void_param_defs("noise"); let idx = defs .iter() - .position(|d| match d { - darkly::gpu::params::ParamDef::Int { name: n, .. } => *n == name, + .position(|d| match d.kind { + darkly::gpu::params::ParamKind::Int { .. } => d.name == name, _ => false, }) .unwrap_or_else(|| panic!("noise void has no int param '{name}'")); @@ -369,8 +372,8 @@ fn set_float_param(params: &mut [ParamValue], name: &str, value: f32) { let defs = engine.void_param_defs("noise"); let idx = defs .iter() - .position(|d| match d { - darkly::gpu::params::ParamDef::Float { name: n, .. } => *n == name, + .position(|d| match d.kind { + darkly::gpu::params::ParamKind::Float { .. } => d.name == name, _ => false, }) .unwrap_or_else(|| panic!("noise void has no float param '{name}'")); diff --git a/crates/darkly/tests/void_preview.rs b/crates/darkly/tests/void_preview.rs deleted file mode 100644 index 8fa5cf9b..00000000 --- a/crates/darkly/tests/void_preview.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Void picker preview generation. -//! -//! Verifies the offscreen void preview renderer (`gpu::void_preview`) and its -//! engine wiring: a void that opts into previews (`noise`) renders a thumbnail -//! from scratch at the canvas's aspect-fit size, captured as readable RGBA -//! frames, generated through the same shared `previews` map / `poll_preview` -//! path as veils. Uses the blocking readback flush (`test_flush_readbacks`) — -//! native-only; the wasm path drains the same `ReadbackScheduler` via the rAF -//! render loop. - -use darkly::engine::{DarklyEngine, PreviewKind}; -use darkly::gpu::context::GpuContext; -use darkly::gpu::preview::fit_preview_dims; -use darkly::gpu::test_utils::test_device; - -fn headless_engine(w: u32, h: u32) -> DarklyEngine { - let (device, queue) = test_device(); - let gpu = GpuContext::new_headless(device, queue); - DarklyEngine::new(gpu, w, h) -} - -/// Drive blocking readback flushes until the preview completes, or give up after -/// a generous bound. Returns `(width, height, frames)`. -fn drain_preview(engine: &mut DarklyEngine, type_id: &str) -> (u32, u32, Vec>) { - for _ in 0..256 { - if let Some(result) = engine.poll_preview(PreviewKind::Void, type_id) { - return result; - } - engine.test_flush_readbacks(); - } - panic!("void preview for {type_id} never completed"); -} - -#[test] -fn noise_void_preview_generates_a_frame() { - let mut engine = headless_engine(256, 256); - - engine.start_void_preview("noise"); - let (w, h, frames) = drain_preview(&mut engine, "noise"); - - // Noise is a static void (default `needs_animation()` is false) → one frame. - assert_eq!( - frames.len(), - 1, - "static void should produce exactly one frame" - ); - - // Dimensions are the canvas aspect-fit thumbnail size. - let (pw, ph) = fit_preview_dims(256, 256); - assert_eq!( - (w, h), - (pw, ph), - "preview dims should aspect-fit the canvas" - ); - assert!(w > 0 && h > 0, "preview should have non-zero dimensions"); - assert_eq!( - frames[0].len(), - (w * h * 4) as usize, - "frame is tightly packed RGBA8" - ); - - // The noise field renders real content — not a blank buffer. - assert!( - frames[0].iter().any(|&b| b != 0), - "noise preview frame should contain rendered (non-zero) pixels" - ); - - // The live layer stack is untouched: no void layer was added to the doc. - assert!( - engine.layer_tree().is_empty(), - "preview generation must not mutate the live layer stack" - ); -} - -#[test] -fn non_square_canvas_preview_fits_aspect() { - // A wide canvas keeps its aspect, capped on the long edge by PREVIEW_MAX_DIM. - let mut engine = headless_engine(800, 400); - engine.start_void_preview("noise"); - let (w, h, _) = drain_preview(&mut engine, "noise"); - let (pw, ph) = fit_preview_dims(800, 400); - assert_eq!((w, h), (pw, ph)); - assert!(w > h, "wide canvas should yield a wide preview"); -} - -#[test] -fn unknown_void_type_is_ignored() { - let mut engine = headless_engine(64, 64); - engine.start_void_preview("does_not_exist"); - assert!(engine - .poll_preview(PreviewKind::Void, "does_not_exist") - .is_none()); -} diff --git a/crates/darkly/tests/warp_field.rs b/crates/darkly/tests/warp_field.rs new file mode 100644 index 00000000..6696c557 --- /dev/null +++ b/crates/darkly/tests/warp_field.rs @@ -0,0 +1,222 @@ +//! Tests for [`darkly::brush::warp_field`] — the displacement field that +//! backs liquify's scratch, and the single resample that turns it into +//! pixels. +//! +//! The load-bearing property is *exactness*. The resolve rewrites the +//! whole layer on every pen event, and almost every pixel of it has a +//! zero displacement. If the resolve were off by half a texel, every one +//! of those pixels would be softened on every commit — which is the very +//! defect the warp field exists to remove, reintroduced one layer down +//! and invisible to a contrast metric. So these assertions are +//! byte-identity, not tolerance: a tolerance would hide the bug. + +use std::sync::{Arc, OnceLock}; + +use wgpu::util::DeviceExt; + +use darkly::brush::pipeline::BrushPipelines; +use darkly::brush::warp_field::{WarpFieldResolve, FIELD_FORMAT, RESOLVE_PIPELINE_ID}; +use darkly::gpu::test_utils::{create_test_texture, readback_texture, test_device}; + +const W: u32 = 64; +const H: u32 = 64; + +fn shared_device() -> (Arc, Arc) { + static HANDLES: OnceLock<(Arc, Arc)> = OnceLock::new(); + HANDLES + .get_or_init(|| { + let (d, q) = test_device(); + (Arc::new(d), Arc::new(q)) + }) + .clone() +} + +/// Deterministic high-frequency source: every pixel differs from its +/// neighbours, so any interpolation error shows up as a changed byte. +fn busy_source() -> Vec { + let mut out = vec![0u8; (W * H * 4) as usize]; + for y in 0..H { + for x in 0..W { + let i = ((y * W + x) * 4) as usize; + out[i] = ((x * 7 + y * 13) % 256) as u8; + out[i + 1] = ((x * 31) % 256) as u8; + out[i + 2] = ((y * 17 + 3) % 256) as u8; + out[i + 3] = 255; + } + } + out +} + +fn pixel(rgba: &[u8], x: u32, y: u32) -> [u8; 4] { + let i = ((y * W + x) * 4) as usize; + [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]] +} + +/// Upload a displacement field, resolve `source` through it onto a fresh +/// destination, and read the destination back. +fn resolve_through(field: &[[f32; 2]], source: &[u8]) -> Vec { + let (device, queue) = shared_device(); + let pipelines = BrushPipelines::new( + &device, + &queue, + &darkly::gpu::selection::selection_mask_bgl(&device), + ); + + let mut bytes = Vec::with_capacity(field.len() * 8); + for texel in field { + bytes.extend_from_slice(&texel[0].to_le_bytes()); + bytes.extend_from_slice(&texel[1].to_le_bytes()); + } + let field_tex = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: Some("test-warp-field"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FIELD_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + &bytes, + ); + let field_view = field_tex.create_view(&wgpu::TextureViewDescriptor::default()); + + let (_src_tex, src_view) = create_test_texture(&device, &queue, W, H, source); + + // Destination starts as a distinct colour, so "the resolve wrote + // nothing" cannot masquerade as a pass. + let dest = vec![7u8; (W * H * 4) as usize]; + let (dest_tex, dest_view) = create_test_texture(&device, &queue, W, H, &dest); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("warp-field-test-resolve"), + }); + pipelines + .get::(RESOLVE_PIPELINE_ID) + .resolve( + &device, + &mut encoder, + &field_view, + &src_view, + &dest_view, + wgpu::TextureFormat::Rgba8Unorm, + (W, H), + ); + queue.submit([encoder.finish()]); + + readback_texture( + &device, + &queue, + &dest_tex, + wgpu::TextureFormat::Rgba8Unorm, + W, + H, + ) +} + +/// **The texel-exactness guard.** A zero field must reproduce the source +/// byte for byte — not approximately. +/// +/// Every pen event resolves the entire layer, and outside the brush disc +/// the field is exactly zero. A half-texel UV error here would low-pass +/// the whole image a little more on every commit: the ghosting bug again, +/// at a different layer, and a contrast metric would never see it. +#[test] +fn warp_field_resolve_is_identity_where_field_is_zero() { + let source = busy_source(); + let zero_field = vec![[0.0_f32, 0.0]; (W * H) as usize]; + let out = resolve_through(&zero_field, &source); + + assert_eq!( + out, source, + "a zero displacement field must reproduce the source exactly; \ + any difference means the resolve's sampling is off by a \ + sub-texel amount and is quietly blurring the layer on every \ + commit", + ); +} + +/// The same guarantee where it is hardest to keep: a field that is zero +/// almost everywhere but non-zero in a disc. The untouched majority must +/// still be byte-identical. +#[test] +fn warp_field_resolve_leaves_untouched_pixels_exact() { + let source = busy_source(); + let centre = (32.0_f32, 32.0_f32); + let radius = 10.0_f32; + let mut field = vec![[0.0_f32, 0.0]; (W * H) as usize]; + for y in 0..H { + for x in 0..W { + let dx = x as f32 + 0.5 - centre.0; + let dy = y as f32 + 0.5 - centre.1; + if (dx * dx + dy * dy).sqrt() < radius { + field[(y * W + x) as usize] = [-3.5, 2.25]; + } + } + } + let out = resolve_through(&field, &source); + + let mut changed_outside = 0; + let mut changed_inside = 0; + for y in 0..H { + for x in 0..W { + let dx = x as f32 + 0.5 - centre.0; + let dy = y as f32 + 0.5 - centre.1; + let inside = (dx * dx + dy * dy).sqrt() < radius; + let differs = pixel(&out, x, y) != pixel(&source, x, y); + if differs && inside { + changed_inside += 1; + } + if differs && !inside { + changed_outside += 1; + } + } + } + assert_eq!( + changed_outside, 0, + "pixels with a zero field must be untouched; {changed_outside} \ + changed outside the displaced disc", + ); + assert!( + changed_inside > 100, + "sanity: the displaced disc should have moved content \ + ({changed_inside} pixels changed inside it)", + ); +} + +/// An integer displacement lands on texel centres, so a correct bilinear +/// fetch returns source texels verbatim — the output is a pure shifted +/// copy with **no** blended values anywhere. +/// +/// This is what "the image is resampled once" means concretely: one +/// resample of an integer shift is lossless, whereas the per-dab image +/// warp this replaced would have produced interpolated mush. +#[test] +fn warp_field_resolve_is_single_resample() { + let source = busy_source(); + let shift = [-4.0_f32, 6.0]; + let field = vec![shift; (W * H) as usize]; + let out = resolve_through(&field, &source); + + // Check away from the edges, where clamping legitimately differs. + for y in 10..(H - 10) { + for x in 10..(W - 10) { + let sx = (x as f32 + shift[0]) as u32; + let sy = (y as f32 + shift[1]) as u32; + assert_eq!( + pixel(&out, x, y), + pixel(&source, sx, sy), + "at ({x}, {y}): an integer displacement must copy source \ + texel ({sx}, {sy}) verbatim — a blended value here means \ + the fetch is misaligned", + ); + } + } +} diff --git a/crates/darkly/tests/watercolor.rs b/crates/darkly/tests/watercolor.rs index 36141d84..be41b991 100644 --- a/crates/darkly/tests/watercolor.rs +++ b/crates/darkly/tests/watercolor.rs @@ -155,7 +155,13 @@ fn render_flush_groups( &queue, &darkly::gpu::selection::selection_mask_bgl(&device), ); - let mut stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines); + let mut stroke_buffer = StrokeBuffer::new( + &device, + CANVAS, + CANVAS, + &pipelines, + darkly::brush::node::COLOR_SCRATCH_FORMAT, + ); let pre_stroke = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture( &layer_texture, diff --git a/docs/manual/index.md b/docs/manual/index.md new file mode 100644 index 00000000..0c6f6026 --- /dev/null +++ b/docs/manual/index.md @@ -0,0 +1,38 @@ +--- +title: Basics +description: Forbidden Editor for Artists +template: doc +--- + +**I hate writing documentation, and you probably hate reading it too. So by design, everything is searchable inside the app.** + +## `CTRL+F` Global Search + +In Darkly, press **`CTRL+F`** to bring up the search bar, and type what you want to do, e.g. 'invert' or 'select'. Press enter to execute. + +
+ + + +## Hotkeys + +Darkly is **desktop-first** which means it works best with a keyboard. Hotkeys are shown everywhere in tooltips and search results. + +![hotkey-tooltips](https://github.com/user-attachments/assets/918a5ed8-572c-4c7d-995e-b5cd06bde726) + +![hotkey-labels](https://github.com/user-attachments/assets/a9ac790e-0457-4fed-9477-7e9161bcc678) + +### Hotkey Presets + +Almost ***everything has a hotkey.*** The first time you start Darkly, you can choose which hotkey preset you want - Photoshop, GIMP, or Krita. You can change this anytime in settings. + + + +Every hotkey is customizable in settings. + +### Hotkey Cheat Sheet + +Darkly has a builtin hotkey cheat sheet, that's useful for printing or putting up on a second screen. + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4ee4e15d..d7b6eba1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,7 +27,7 @@ "jsdom": "^29.1.1", "svelte": "^5.56.7", "svelte-check": "^4.7.3", - "typescript": "^7.0.2", + "typescript": "^6.0.3", "vite": "^8.1.5", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.10" @@ -3174,346 +3174,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", @@ -7358,38 +7018,17 @@ } }, "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "node": ">=14.17" } }, "node_modules/unbox-primitive": { diff --git a/frontend/package.json b/frontend/package.json index 6b2ad074..fd07e55d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,7 +32,7 @@ "jsdom": "^29.1.1", "svelte": "^5.56.7", "svelte-check": "^4.7.3", - "typescript": "^7.0.2", + "typescript": "^6.0.3", "vite": "^8.1.5", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.10" diff --git a/frontend/scripts/export-doc-icons.mjs b/frontend/scripts/export-doc-icons.mjs new file mode 100644 index 00000000..7017a24b --- /dev/null +++ b/frontend/scripts/export-doc-icons.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/* + * Resolve every Iconify name in the documentation metadata to actual SVG, and + * write it beside the metadata as `icons.json`. + * + * This exists because an icon name is not a glyph. `metadata.json` says an + * entry's icon is `fa6-solid:paintbrush`; turning that into artwork means owning + * an icon toolchain and pinning the same icon-set versions this build used. + * Resolving here follows the standing rule for this effort: where a consumer + * would otherwise have to re-derive something, the producer stores it — and it + * keeps the artifact's promise that reading it needs no particular renderer. + * + * It lives beside `gen-icon-bundle.mjs`, which does the same resolution for the + * app's offline icon bundle — its throw-on-missing typo net is mirrored here — + * and, being in `frontend/`, resolves `@iconify/json` and `@iconify/utils` from + * the deps that already declare them. What that generator also does and this + * deliberately does not is the resvg optical shrink-wrap: that matters for icons + * sitting in a toolbar next to each other at 1em, not for table cells. + * + * node frontend/scripts/export-doc-icons.mjs \ + * --metadata out/metadata.json --out out/icons.json + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getIconData } from '@iconify/utils'; + +const FRONTEND = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const JSON_DIR = path.join(FRONTEND, 'node_modules', '@iconify', 'json', 'json'); + +function parseArgs() { + const args = process.argv.slice(2); + const out = {}; + for (let i = 0; i < args.length; i += 2) { + if (args[i] === '--metadata') out.metadata = args[i + 1]; + else if (args[i] === '--out') out.out = args[i + 1]; + else throw new Error(`unrecognized argument \`${args[i]}\``); + } + if (!out.metadata || !out.out) { + throw new Error('usage: export-doc-icons.mjs --metadata --out '); + } + return out; +} + +/** Every icon name the metadata references, catalogs and entries alike. */ +function iconNames(metadata) { + const names = new Set(); + for (const catalog of metadata.catalogs ?? []) { + if (catalog.icon) names.add(catalog.icon); + for (const entry of catalog.entries ?? []) { + if (entry.icon) names.add(entry.icon); + } + } + return [...names].sort(); +} + +function main() { + const { metadata: metaPath, out: outPath } = parseArgs(); + const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + const names = iconNames(metadata); + + if (!fs.existsSync(JSON_DIR)) { + throw new Error(`@iconify/json is not installed — run \`npm ci\` in ${FRONTEND}`); + } + const loaded = new Map(); + const icons = {}; + const missing = []; + + for (const full of names) { + const [prefix, name] = full.split(':'); + if (!loaded.has(prefix)) { + const file = path.join(JSON_DIR, `${prefix}.json`); + loaded.set(prefix, fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null); + } + const collection = loaded.get(prefix); + // getIconData resolves aliases and applies the collection's default + // dimensions, so callers get one uniform shape per icon. + const data = collection && getIconData(collection, name); + if (!data) { + missing.push(full); + continue; + } + icons[full] = { + body: data.body, + left: data.left ?? 0, + top: data.top ?? 0, + width: data.width ?? 16, + height: data.height ?? 16, + }; + } + + // Same hard typo net as the app's bundle: a name that resolves to nothing is + // a bug in a registration, not something to paper over with a blank cell. + if (missing.length) { + throw new Error(`unresolvable icon name(s): ${missing.join(', ')}`); + } + + fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true }); + fs.writeFileSync(outPath, `${JSON.stringify(icons, null, 0)}\n`); + console.log( + `${outPath} — ${names.length} icon(s) across ${new Set(names.map((n) => n.split(':')[0])).size} set(s)`, + ); +} + +main(); diff --git a/frontend/scripts/gen-icon-bundle.mjs b/frontend/scripts/gen-icon-bundle.mjs index f99dcbdd..c63b71a9 100644 --- a/frontend/scripts/gen-icon-bundle.mjs +++ b/frontend/scripts/gen-icon-bundle.mjs @@ -15,7 +15,9 @@ // plugin's buildStart). // // A prefix is treated as an icon set when @iconify/json ships a collection for -// it, plus the synthetic `local` set sourced from src/icons/svg/*.svg. The +// it. There is no bespoke-SVG escape hatch: every icon Darkly names comes from a +// published set, which is what lets any consumer of Darkly's metadata resolve an +// icon name without this repo's help. The // generator THROWS if a referenced name is absent from its collection — the // hard typo safety net that replaces Font Awesome's silent fallback. (A typo'd // *prefix* isn't a known set, so it's skipped here and caught instead by the @@ -29,7 +31,6 @@ import { Resvg } from '@resvg/resvg-js'; const FRONTEND = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const SRC = path.join(FRONTEND, 'src'); -const SVG_DIR = path.join(SRC, 'icons', 'svg'); const JSON_DIR = path.join(FRONTEND, 'node_modules', '@iconify', 'json', 'json'); const OUT = path.join(SRC, 'icons', 'bundle.generated.ts'); // The Rust crate also names icons (settings-section tabs, brush-node icon @@ -56,28 +57,6 @@ function walk(dir, acc = []) { return acc; } -function buildLocal(names) { - const icons = {}; - for (const name of names) { - const f = path.join(SVG_DIR, `${name}.svg`); - if (!fs.existsSync(f)) { - throw new Error(`[gen-icons] local:${name} referenced but ${path.relative(FRONTEND, f)} is missing`); - } - const svg = fs.readFileSync(f, 'utf8'); - const vb = svg.match(/viewBox\s*=\s*"\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*"/); - const width = vb ? Number(vb[3]) : 16; - const height = vb ? Number(vb[4]) : 16; - // Inner body kept verbatim (defs / gradients / ids preserved) — Iconify - // stores it as-is (no SVGO), and uniquifies ids at render time. - const body = svg - .replace(/^[\s\S]*?]*>/, '') - .replace(/<\/svg>\s*$/, '') - .trim(); - icons[name] = { body, width, height }; - } - return { prefix: 'local', icons }; -} - // Icons ship as inline SVGs forced to a 1em square (see Icon.svelte + // ToolCluster's `.tool svg` rule). An icon's on-screen size is therefore how // much of its viewBox the artwork covers — and icon sets bake in wildly @@ -174,7 +153,7 @@ function renderBundle() { const text = fs.readFileSync(file, 'utf8'); for (const m of text.matchAll(NAME_RE)) { const [, prefix, name] = m; - if (prefix !== 'local' && !collectionExists(prefix)) continue; // not an icon set + if (!collectionExists(prefix)) continue; // not an icon set if (!byPrefix.has(prefix)) byPrefix.set(prefix, new Set()); byPrefix.get(prefix).add(name); } @@ -185,10 +164,6 @@ function renderBundle() { for (const [prefix, set] of [...byPrefix].sort(([a], [b]) => a.localeCompare(b))) { const names = [...set].sort(); total += names.length; - if (prefix === 'local') { - collections.push(tightenCollection(buildLocal(names))); - continue; - } const full = JSON.parse(fs.readFileSync(path.join(JSON_DIR, `${prefix}.json`), 'utf8')); const subset = getIcons(full, names); if (!subset) throw new Error(`[gen-icons] failed to read collection "${prefix}"`); diff --git a/frontend/scripts/wasm-watch.mjs b/frontend/scripts/wasm-watch.mjs new file mode 100644 index 00000000..5331cc5c --- /dev/null +++ b/frontend/scripts/wasm-watch.mjs @@ -0,0 +1,143 @@ +// Rebuild the WASM bridge when Rust sources change, and reload the page. +// +// `npm run start` builds the bridge once (`wasm:build-dev`) and then hands off +// to Vite, which watches only `frontend/`. So every edit under `crates/` — +// a shader, a veil's `preview_at`, a registration — needed a manual +// `npm run wasm:build-dev` and a manual reload before it showed up in the +// editor, and a dev server left running silently served stale WASM. This +// closes that: the same watch-and-regenerate shape `iconBundlePlugin` uses for +// the icon bundle, applied to the thing the whole editor is compiled from. +// +// Dev only (`apply: 'serve'`). Production `npm run build` runs `wasm:build` +// ahead of Vite, so there is nothing to watch. + +// @ts-nocheck — plain .mjs build tooling, outside the tsc src scope. +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const FRONTEND = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const WASM_CRATE = path.join(FRONTEND, 'wasm'); +const PKG_ENTRY = path.join(WASM_CRATE, 'pkg', 'darkly_wasm.js'); +const CRATES = path.join(FRONTEND, '..', 'crates'); + +// The bridge's whole input surface. `crates/` covers the engine, its shaders +// (`include_str!`), and its baked resources (`include_bytes!`); `wasm/` covers +// the bridge itself. Debounced coarsely rather than filtered finely — cargo +// decides what actually needs recompiling far better than a glob would. +const WATCH_ROOTS = [CRATES, path.join(WASM_CRATE, 'src'), path.join(WASM_CRATE, 'Cargo.toml')]; + +const SOURCE_RE = /\.(rs|wgsl|toml|yaml|yml|jpg|jpeg|png|webp)$/; + +// `target/` is cargo's output and `pkg/` is wasm-pack's — watching either +// would make every build trigger the next one. +const IGNORED = [`${path.sep}target${path.sep}`, `${path.sep}pkg${path.sep}`]; + +// Coalesce the burst of events a single save (or a `cargo fmt`) produces. +const DEBOUNCE_MS = 150; + +function isSource(file) { + if (!SOURCE_RE.test(file)) return false; + if (IGNORED.some((seg) => file.includes(seg))) return false; + return WATCH_ROOTS.some((root) => file === root || file.startsWith(root + path.sep)); +} + +/// Run the dev bridge build, resolving `{ ok, output }`. Never rejects — a +/// failed build is a normal state during editing, and the watcher has to +/// survive it to pick up the fix. +/// +/// Goes through `npm run wasm:build-dev` rather than invoking `wasm-pack` +/// directly, for two reasons. The flags (`--dev --target web --out-dir pkg`) +/// are already defined once in `package.json` and should not be restated here. +/// And a bare `wasm-pack` spawned from Vite's process fails where the npm +/// script succeeds — `npm run` establishes the environment wasm-pack expects, +/// which spawning it out of a dev-server process does not. +function wasmPack() { + return new Promise((resolve) => { + const child = spawn( + 'npm', + ['run', '--silent', 'wasm:build-dev'], + { cwd: FRONTEND, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let output = ''; + child.stdout.on('data', (d) => { output += d; }); + child.stderr.on('data', (d) => { output += d; }); + child.on('error', (e) => resolve({ ok: false, output: `${e.message}\n${output}` })); + child.on('close', (code) => resolve({ ok: code === 0, output })); + }); +} + +export function wasmWatchPlugin() { + let logger = console; + let building = false; + let queued = false; + + return { + name: 'darkly-wasm-watch', + apply: 'serve', + configResolved(cfg) { + logger = cfg.logger ?? console; + }, + async configureServer(server) { + // The crate lives outside Vite's root — watch it explicitly, the + // same way the icon bundle plugin reaches `crates/darkly/src`. + for (const root of WATCH_ROOTS) server.watcher.add(root); + + const rebuild = async (reason) => { + // One build at a time; a change that lands mid-build queues + // exactly one more rather than piling up a build per keystroke. + if (building) { + queued = true; + return; + } + building = true; + logger.info(`[wasm] ${reason} — rebuilding…`); + const started = Date.now(); + const { ok, output } = await wasmPack(); + building = false; + + if (ok) { + const secs = ((Date.now() - started) / 1000).toFixed(1); + logger.info(`[wasm] rebuilt in ${secs}s — reloading`); + server.ws.send({ type: 'full-reload', path: '*' }); + } else { + logger.error(`[wasm] build failed\n${output}`); + server.ws.send({ + type: 'error', + err: { + message: 'wasm build failed — see the terminal', + stack: output, + plugin: 'darkly-wasm-watch', + }, + }); + } + + if (queued) { + queued = false; + await rebuild('changed during the last build'); + } + }; + + // `npm run start` builds before Vite starts, but `npm run dev` on a + // fresh checkout does not — without this the bridge import fails to + // resolve with nothing pointing at why. + if (!fs.existsSync(PKG_ENTRY)) { + await rebuild('no bridge built yet'); + } + + let timer = null; + const onChange = (file) => { + if (!isSource(file)) return; + clearTimeout(timer); + timer = setTimeout( + () => rebuild(`${path.relative(CRATES, file)} changed`), + DEBOUNCE_MS, + ); + }; + server.watcher.on('change', onChange); + server.watcher.on('add', onChange); + server.watcher.on('unlink', onChange); + }, + }; +} diff --git a/frontend/src/__tests__/iconBundle.test.ts b/frontend/src/__tests__/iconBundle.test.ts index 67f13908..e09fcf2f 100644 --- a/frontend/src/__tests__/iconBundle.test.ts +++ b/frontend/src/__tests__/iconBundle.test.ts @@ -4,12 +4,14 @@ import '../icons/bundle.generated'; import { generateIcon } from '@iconify/svelte/dist/offline-functions.js'; import { registerActions } from '../actions/index'; import { actions } from '../actions/registry'; +import { rustActionDocs } from '../actions/__tests__/rust_action_docs'; import { toolRegistry } from '../tools/registry'; // Register the menu/palette actions. Tools are imported lazily inside the tool // test instead — registering tool-switch actions needs app methods that aren't // stood up in the node test env, exactly as in menu_actions.test.ts. beforeAll(() => { + actions.setDocs(rustActionDocs()); registerActions(); }); @@ -55,6 +57,9 @@ function markupIconNames(): { file: string; name: string }[] { } describe('icon bundle completeness (offline)', () => { + // Action glyphs live in `crates/darkly/src/actions/`, which the generator + // scans along with the rest of the crate — this is what proves that scan + // reaches them. it('bundles every registered action icon', () => { const missing = actions .all() @@ -63,11 +68,16 @@ describe('icon bundle completeness (offline)', () => { expect(missing).toEqual([]); }); - it('bundles every registered tool icon', async () => { + it('bundles every session-dependent tool icon override', async () => { await import('../tools/index'); // side effect: populates toolRegistry const tools = toolRegistry.all(); expect(tools.length).toBeGreaterThan(0); + // A tool's own glyph lives on its Rust registration, and + // `gen-icon-bundle.mjs` scans `crates/darkly/src` for those. What is + // still declared here is the session-dependent override (the brush's + // erase-mode getter), so that is what this asserts. const missing = tools + .map(t => ({ id: t.id, icon: typeof t.icon === 'function' ? t.icon() : t.icon })) .filter(t => t.icon && !resolves(t.icon)) .map(t => `${t.id} -> ${t.icon}`); expect(missing).toEqual([]); @@ -80,8 +90,8 @@ describe('icon bundle completeness (offline)', () => { expect(resolves('fa6-solid:eraser')).toBe(true); }); - it('bundles the custom local:gradient icon', () => { - expect(resolves('local:gradient')).toBe(true); + it('bundles the gradient tool icon', () => { + expect(resolves('boxicons:gradient')).toBe(true); }); it('bundles Rust-originated icons (crate scan works)', () => { diff --git a/frontend/src/actions/__tests__/action_metadata_join.test.ts b/frontend/src/actions/__tests__/action_metadata_join.test.ts new file mode 100644 index 00000000..8405f0df --- /dev/null +++ b/frontend/src/actions/__tests__/action_metadata_join.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { registerActions } from '../index'; +import { actions } from '../registry'; +import { rustActionDocs } from './rust_action_docs'; + +// An action is two halves joined by id: its documentation is authored in Rust +// (`crates/darkly/src/actions/`) and its handler closes over Svelte runes here. +// Nothing at either end can tell that the other half is missing — a handler with +// no metadata renders as a bare id in the menus, and metadata with no handler is +// a palette row and a hotkey that do nothing. This is the test that notices. +// +// Tool selection and filter application are absent from both sides here: their +// documentation lives in the `tools` / `filters` catalogs (each names the action +// that reaches it in `hotkey_action`), and their handlers register from loops +// that need a live WASM handle. The Rust preset test covers those ids. + +describe('Rust action metadata joins to its TypeScript handler', () => { + let handlers: string[]; + let documented: string[]; + + beforeAll(() => { + registerActions(); + handlers = actions.ids().sort(); + documented = Object.keys(rustActionDocs()).sort(); + }); + + it('parses a plausible number of actions out of the Rust tables', () => { + expect(documented.length).toBeGreaterThan(50); + }); + + it('declares metadata for exactly the actions that have a handler', () => { + expect(documented).toEqual(handlers); + }); + + it('resolves every registered action to a name, a category and an icon', () => { + actions.setDocs(rustActionDocs()); + const bare = actions + .all() + .filter(a => a.displayName === a.id || !a.category || !a.icon.includes(':')) + .map(a => a.id); + expect(bare).toEqual([]); + }); +}); diff --git a/frontend/src/actions/__tests__/clipboard.test.ts b/frontend/src/actions/__tests__/clipboard.test.ts index efc76dee..cf3f0964 100644 --- a/frontend/src/actions/__tests__/clipboard.test.ts +++ b/frontend/src/actions/__tests__/clipboard.test.ts @@ -50,12 +50,10 @@ beforeEach(() => { withApi(engine); describe('clipboard action registration', () => { - it('registers copy, cut, paste, and pasteInPlace under the edit category', () => { + it('registers copy, cut, paste, and pasteInPlace', () => { registerClipboardActions(); for (const id of ['copy', 'cut', 'paste', 'pasteInPlace']) { - const action = actions.get(id); - expect(action, id).toBeDefined(); - expect(action!.category).toBe('edit'); + expect(actions.get(id), id).toBeDefined(); } }); }); diff --git a/frontend/src/actions/__tests__/menu_actions.test.ts b/frontend/src/actions/__tests__/menu_actions.test.ts index bdf03e4c..57cc1ad3 100644 --- a/frontend/src/actions/__tests__/menu_actions.test.ts +++ b/frontend/src/actions/__tests__/menu_actions.test.ts @@ -1,14 +1,17 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { registerActions } from '../index'; -import { actions, actionEnablement, parseMenuSegment, type ActionRegistration } from '../registry'; +import { actions, actionEnablement, parseMenuSegment, type Action } from '../registry'; import { buildTopMenus } from '../../ui/menu/menuModel'; import { app } from '../../state/app.svelte'; +import { rustActionDocs } from './rust_action_docs'; // Populate the real registry once. `registerActions` is idempotent enough for // our purposes (re-registering overwrites by id), and tool actions are absent // here because `tools/index` isn't imported — that's fine, we only assert on -// the menu/palette actions this feature owns. +// the menu/palette actions this feature owns. Documentation comes from the Rust +// tables, standing in for the `actions` catalog the editor is handed at init. beforeAll(() => { + actions.setDocs(rustActionDocs()); registerActions(); }); @@ -156,7 +159,7 @@ describe('menu action registrations', () => { { id: 'noOrder1', displayName: 'N1', category: 'file', icon: 'fa6-solid:circle', menuPath: ['X'], handler() {} }, { id: 'a', displayName: 'A', category: 'file', icon: 'fa6-solid:circle', menuPath: ['X:10'], handler() {} }, { id: 'noOrder2', displayName: 'N2', category: 'file', icon: 'fa6-solid:circle', menuPath: ['X'], handler() {} }, - ] as ActionRegistration[]; + ] as Action[]; const x = buildTopMenus(regs).find(m => m.title === 'X'); const ids = x!.entries.map(e => (e as { actionId: string }).actionId); expect(ids).toEqual(['a', 'b', 'noOrder1', 'noOrder2']); diff --git a/frontend/src/actions/__tests__/preset_hotkey_ids.test.ts b/frontend/src/actions/__tests__/preset_hotkey_ids.test.ts deleted file mode 100644 index fc0ef170..00000000 --- a/frontend/src/actions/__tests__/preset_hotkey_ids.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -// Node builtins; the project intentionally omits @types/node (see vite.config.ts -// and woff2_decode.test.ts). Vitest runs under node, so these resolve at runtime. -// @ts-ignore -import { readFileSync, readdirSync } from 'node:fs'; -// @ts-ignore -import { fileURLToPath } from 'node:url'; -// @ts-ignore -import { resolve, dirname } from 'node:path'; -import { registerActions } from '../index'; -import { actions } from '../registry'; - -// Regression guard for the "adjustInvert" bug. A preset can bind a chord to -// any action-id string; the hotkey dispatcher only indexes bindings under -// *registered* action ids (config/hotkeys.svelte.ts), so a binding whose id -// matches no action is silently dropped — the key does nothing and the action -// shows an empty hotkey. Krita/Photoshop bound Ctrl+I to `adjustInvert`, but -// the invert filter registers as `filterInvert`, so Ctrl+I was dead. -// -// This test enforces the general invariant: *every* action id referenced by a -// preset must correspond to a real, registerable action — not just invert. - -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..'); -const PRESETS_DIR = resolve(REPO_ROOT, 'crates/darkly/presets'); -const FILTERS_DIR = resolve(REPO_ROOT, 'crates/darkly/src/gpu/filters'); -const TOOLS_DIR = resolve(REPO_ROOT, 'frontend/src/tools'); -const PRESETS = ['defaults', 'krita', 'photoshop', 'gimp']; - -/** Collect the action ids referenced by a preset's `hotkeys:` and - * `mouse_clicks:` blocks. Both are a single level of ` id: …` keys (the - * value may be an inline chord or a `-` list on following lines — we only - * care about the id). We read a block until the next top-level key. */ -function presetActionIds(preset: string): string[] { - const text = readFileSync(resolve(PRESETS_DIR, `${preset}.yaml`), 'utf8'); - const ids: string[] = []; - let inBlock = false; - for (const line of text.split('\n')) { - if (/^\S/.test(line)) inBlock = /^(hotkeys|mouse_clicks):/.test(line.trimEnd()); - if (!inBlock) continue; - const m = /^ {2}([A-Za-z0-9_]+):/.exec(line); - if (m) ids.push(m[1]); - } - return ids; -} - -/** The action id the dynamic filter registration produces for a filter type — - * mirrors the `filter${Titlecase(type)}` expression in actions/index.ts. */ -const filterActionId = (type: string) => - `filter${type.charAt(0).toUpperCase()}${type.slice(1)}`; - -/** Filter action ids, sourced from the Rust filter registry (the same source - * of truth the presets live beside). These register dynamically at runtime - * from `filter_types()`, so they can't come from the live frontend registry - * in a headless test — but the invariant they satisfy is identical. */ -function dynamicFilterActionIds(): string[] { - const ids: string[] = []; - for (const file of readdirSync(FILTERS_DIR)) { - if (!file.endsWith('.rs') || file === 'mod.rs') continue; - const src = readFileSync(resolve(FILTERS_DIR, file), 'utf8'); - const m = /type_id:\s*"([a-z_]+)"/.exec(src); - if (m) ids.push(filterActionId(m[1])); - } - return ids; -} - -/** Tool-switch action ids, sourced from each tool's `hotkeyAction` literal. - * The per-tool loop in actions/index.ts registers exactly these ids, but the - * registration touches `app` methods that aren't wired up in a headless test, - * so we read the ids from the tool definitions instead. */ -function toolActionIds(): string[] { - const ids: string[] = []; - for (const file of readdirSync(TOOLS_DIR)) { - if (!file.endsWith('.ts')) continue; - const src = readFileSync(resolve(TOOLS_DIR, file), 'utf8'); - for (const m of src.matchAll(/hotkeyAction:\s*'([A-Za-z0-9_]+)'/g)) { - ids.push(m[1]); - } - } - return ids; -} - -describe('preset hotkey ids resolve to real actions', () => { - let validIds: Set; - - beforeAll(() => { - registerActions(); // static + brush-param + sample-color + clipboard actions - validIds = new Set([ - ...actions.all().map(a => a.id), - ...toolActionIds(), - ...dynamicFilterActionIds(), - ]); - }); - - it('every preset binding targets a registerable action id', () => { - const orphans: string[] = []; - for (const preset of PRESETS) { - for (const id of presetActionIds(preset)) { - if (!validIds.has(id)) orphans.push(`${preset}: ${id}`); - } - } - expect(orphans).toEqual([]); - }); - - // Belt-and-suspenders for the specific bug: assert the invert filter's id - // is what the presets bind, and that the dead `adjustInvert` id is gone. - it('binds Ctrl+I to filterInvert in Krita/Photoshop, with no adjustInvert left', () => { - expect(filterActionId('invert')).toBe('filterInvert'); - expect(validIds.has('filterInvert')).toBe(true); - for (const preset of ['krita', 'photoshop']) { - expect(presetActionIds(preset), preset).toContain('filterInvert'); - } - for (const preset of PRESETS) { - expect(presetActionIds(preset), preset).not.toContain('adjustInvert'); - } - }); -}); diff --git a/frontend/src/actions/__tests__/rust_action_docs.ts b/frontend/src/actions/__tests__/rust_action_docs.ts new file mode 100644 index 00000000..544f8023 --- /dev/null +++ b/frontend/src/actions/__tests__/rust_action_docs.ts @@ -0,0 +1,48 @@ +// Node builtins; the project intentionally omits @types/node (see vite.config.ts +// and woff2_decode.test.ts). Vitest runs under node, so these resolve at runtime. +// @ts-ignore +import { readFileSync, readdirSync } from 'node:fs'; +// @ts-ignore +import { fileURLToPath } from 'node:url'; +// @ts-ignore +import { resolve, dirname } from 'node:path'; +import type { ActionDoc } from '../registry'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..'); +const ACTIONS_DIR = resolve(REPO_ROOT, 'crates/darkly/src/actions'); + +const field = (block: string, name: string): string | undefined => + new RegExp(`\\b${name}:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(block)?.[1]; + +/** + * The `actions` catalog as the Rust tables declare it — every action id mapped + * to its documentation, keyed exactly as `actions.setDocs` wants it. + * + * The catalog normally reaches the editor over the WASM bridge, which a node + * test has no handle to. Reading the tables directly is possible because every + * field in them is a plain string literal in a `const ACTIONS: &[ActionDef]`, + * and it means these tests assert against the same authority the running editor + * uses rather than a fixture that could drift from it. + */ +export function rustActionDocs(): Record { + const out: Record = {}; + for (const file of readdirSync(ACTIONS_DIR)) { + if (!file.endsWith('.rs') || file === 'mod.rs') continue; + const src = readFileSync(resolve(ACTIONS_DIR, file), 'utf8'); + const category = /ActionCategory\s*\{[\s\S]*?\bid:\s*"([^"]+)"/.exec(src)?.[1]; + if (!category) throw new Error(`${file} declares no ActionCategory id`); + for (const m of src.matchAll(/ActionDef\s*\{([^}]*)\}/g)) { + const block = m[1]; + const id = field(block, 'id'); + const displayName = field(block, 'display_name'); + if (!id || !displayName) throw new Error(`${file}: malformed ActionDef ${block}`); + out[id] = { + displayName, + category, + description: field(block, 'description'), + icon: field(block, 'icon') ?? '', + }; + } + } + return out; +} diff --git a/frontend/src/actions/__tests__/sample_color.test.ts b/frontend/src/actions/__tests__/sample_color.test.ts index f53fdf77..064cf36f 100644 --- a/frontend/src/actions/__tests__/sample_color.test.ts +++ b/frontend/src/actions/__tests__/sample_color.test.ts @@ -40,11 +40,10 @@ beforeEach(() => { withApi(engine); describe('sampleColor action registration', () => { - it('registers under the id "sampleColor" with the colors category', () => { + it('registers under the id "sampleColor" as a hold action', () => { registerSampleColorAction(); const action = actions.get('sampleColor'); expect(action).toBeDefined(); - expect(action!.category).toBe('colors'); expect(action!.type).toBe('hold'); }); diff --git a/frontend/src/actions/__tests__/triggers_combined.test.ts b/frontend/src/actions/__tests__/triggers_combined.test.ts index ae53e4ba..601e15ec 100644 --- a/frontend/src/actions/__tests__/triggers_combined.test.ts +++ b/frontend/src/actions/__tests__/triggers_combined.test.ts @@ -82,10 +82,9 @@ describe('serializeTriggers', () => { }); it('drops triggers whose chord is empty even if site prefix is present', () => { - // When the action requires a site (no Anywhere option), addTrigger - // seeds the new row with `:` and a blank chord. If the user - // navigates away without capturing, the half-baked binding must - // not be persisted. + // A site prefix with no chord can't dispatch. It isn't reachable + // through the editor, but a hand-edited override can carry one, and it + // must not survive a round-trip through storage. expect(serializeTriggers([ { kind: 'mouse', binding: 'canvas:' }, { kind: 'mouse', binding: 'canvas:alt+click' }, diff --git a/frontend/src/actions/brush_params.ts b/frontend/src/actions/brush_params.ts index bdd86cd5..4b35666e 100644 --- a/frontend/src/actions/brush_params.ts +++ b/frontend/src/actions/brush_params.ts @@ -83,25 +83,16 @@ let sizeDrag: SizeDragState | null = null; export function registerBrushParamActions() { actions.register({ id: 'brushSizeUp', - displayName: 'Increase Brush Size', - category: 'brush', - icon: 'fa6-solid:plus', handler: () => adjustBrushParam('size', +1), }); actions.register({ id: 'brushSizeDown', - displayName: 'Decrease Brush Size', - category: 'brush', - icon: 'fa6-solid:minus', handler: () => adjustBrushParam('size', -1), }); actions.register({ id: 'brushSizeAdjust', - displayName: 'Adjust Brush Size (drag)', - category: 'brush', - icon: 'fa6-solid:up-right-and-down-left-from-center', type: 'hold', handler: (ctx) => { const found = findScalarPort('size'); diff --git a/frontend/src/actions/clipboard.ts b/frontend/src/actions/clipboard.ts index 5518bd50..6362f3ca 100644 --- a/frontend/src/actions/clipboard.ts +++ b/frontend/src/actions/clipboard.ts @@ -26,10 +26,6 @@ function enterTransformTool() { export function registerClipboardActions(): void { actions.register({ id: 'copy', - displayName: 'Copy', - category: 'edit', - description: 'Copy the active layer to the clipboard.', - icon: 'fa6-solid:copy', menuPath: ['Edit:40'], handler: () => { const engine = app.engine; @@ -50,10 +46,6 @@ export function registerClipboardActions(): void { }); actions.register({ id: 'cut', - displayName: 'Cut', - category: 'edit', - description: 'Cut the active layer to the clipboard.', - icon: 'fa6-solid:scissors', menuPath: ['Edit:30'], handler: async () => { const engine = app.engine; @@ -73,10 +65,6 @@ export function registerClipboardActions(): void { }); actions.register({ id: 'paste', - displayName: 'Paste', - category: 'edit', - description: 'Paste an image or layer from the clipboard.', - icon: 'fa6-solid:paste', menuPath: ['Edit:50'], handler: async () => { const engine = app.engine; @@ -158,10 +146,6 @@ export function registerClipboardActions(): void { }); actions.register({ id: 'pasteInPlace', - displayName: 'Paste into Active Layer', - category: 'edit', - description: 'Paste the clipboard into the active layer or mask at its original position.', - icon: 'fa6-solid:clipboard', menuPath: ['Edit:60'], handler: async () => { const engine = app.engine; diff --git a/frontend/src/actions/clone_source_gesture.ts b/frontend/src/actions/clone_source_gesture.ts index faf1abdf..91c47d68 100644 --- a/frontend/src/actions/clone_source_gesture.ts +++ b/frontend/src/actions/clone_source_gesture.ts @@ -16,11 +16,6 @@ import { setCloneSourceAnchor } from '../tools/clone_source_cursor'; export function registerCloneSourceAction(): void { actions.register({ id: 'setCloneSource', - displayName: 'Set Clone Source', - category: 'brush', - description: - 'Hold the modifier and click on the canvas to set the point the Clone brush copies from.', - icon: 'fa6-solid:crosshairs', type: 'hold', handler: (ctx) => { const cx = typeof ctx.x === 'number' ? ctx.x : 0; diff --git a/frontend/src/actions/index.ts b/frontend/src/actions/index.ts index 9e50feb0..5f715d32 100644 --- a/frontend/src/actions/index.ts +++ b/frontend/src/actions/index.ts @@ -7,11 +7,11 @@ import { resizeCanvas } from '../state/resizeCanvas.svelte'; import { imageRescale } from '../state/imageRescale.svelte'; import { selectionModify } from '../state/selectionModify.svelte'; import { filterModal } from '../state/filterModal.svelte'; -import type { FilterParam } from '../ui/filters/filterParams'; +import type { ParamInfo } from '../ui/filters/filterParams'; import { exportTimelapse } from '../state/exportTimelapse.svelte'; import { loadError, parseLoadErrorMessage } from '../state/loadError.svelte'; import { toast } from '../state/toast.svelte'; -import { toolRegistry, type ToolDescriptor } from '../tools/registry'; +import { toolRegistry } from '../tools/registry'; import { brushGraph } from '../state/brush_graph.svelte'; import { brushSession } from '../tools/brush.svelte'; import { registerBrushParamActions } from './brush_params'; @@ -52,13 +52,6 @@ function tabNameFromFile(fileName: string): string { return stripped || 'Untitled'; } -/** The Iconify icon name for a tool-switch action — the tool's own `icon`, - * falling back to a generic glyph for the (now hypothetical) tool that ships - * none. Every tool currently declares one. */ -function glyphFromTool(tool: ToolDescriptor): string { - return tool.icon ?? 'fa6-solid:wrench'; -} - /** Unified Open. Pick any supported file, sniff its kind, and route to * the matching loader. Every Open lands in a new tab — image-as-layer * in the current doc is the drag-drop gesture (`CanvasView`'s drop @@ -250,10 +243,6 @@ export function registerActions() { // -- Edit -- actions.register({ id: 'undo', - displayName: 'Undo', - category: 'edit', - description: 'Undo the last action.', - icon: 'fa6-solid:rotate-left', menuPath: ['Edit:10'], // The layer-tree refresh goes first: it diffs the tree against the // pre-undo shape to find what the operation restored, and any await in @@ -266,10 +255,6 @@ export function registerActions() { }); actions.register({ id: 'redo', - displayName: 'Redo', - category: 'edit', - description: 'Redo the last undone action.', - icon: 'fa6-solid:rotate-right', menuPath: ['Edit:20'], handler: async () => { app.engine?.api.redo(); @@ -281,19 +266,11 @@ export function registerActions() { // -- Colors -- actions.register({ id: 'resetColors', - displayName: 'Reset Colors', - category: 'colors', - description: 'Reset the foreground/background to black and white.', - icon: 'fa6-solid:circle-half-stroke', menuPath: ['Colors:20'], handler: () => app.resetColors(), }); actions.register({ id: 'swapColors', - displayName: 'Swap Colors', - category: 'colors', - description: 'Swap the foreground and background colors.', - icon: 'fa6-solid:right-left', menuPath: ['Colors:10'], handler: () => app.swapColors(), }); @@ -301,28 +278,16 @@ export function registerActions() { // -- Selection -- actions.register({ id: 'selectAll', - displayName: 'Select All', - category: 'selection', - description: 'Select the entire canvas.', - icon: 'fa6-solid:vector-square', menuPath: ['Select:10'], handler: () => app.engine?.api.selectAll(), }); actions.register({ id: 'clearSelection', - displayName: 'Deselect', - category: 'selection', - description: 'Clear the active selection.', - icon: 'fa6-solid:ban', menuPath: ['Select:20'], handler: () => app.engine?.api.clearSelection(), }); actions.register({ id: 'clearSelectionContents', - displayName: 'Clear Selection Contents', - category: 'selection', - description: 'Erase the pixels inside the selection.', - icon: 'fa6-solid:eraser', menuPath: ['Select:40'], handler: () => { if (app.activeLayerId != null) { @@ -332,25 +297,16 @@ export function registerActions() { }); actions.register({ id: 'invertSelection', - displayName: 'Invert Selection', - category: 'selection', - description: 'Invert the current selection.', - icon: 'tabler:flip-horizontal', menuPath: ['Select:30'], handler: () => app.engine?.api.invertSelection(), }); actions.register({ id: 'maskToSelection', - displayName: 'Mask to Selection', - category: 'selection', - description: "Load the active layer's mask as the selection.", - icon: 'radix-icons:mask-off', menuPath: ['Select:35'], // The engine op takes the mask filter's id, not the host's. Both // call sites (the mask context menu and the maskThumb $mod+click // gesture) pass it under `maskId`; the enabled-guard fallback // resolves it from the active node when dispatched keyboard-only. - accepts: ['maskId'], enabled: () => app.activeMaskId != null || 'No mask on the active layer', handler: (ctx) => { const engine = app.engine; @@ -362,60 +318,36 @@ export function registerActions() { }); actions.register({ id: 'growSelection', - displayName: 'Grow Selection', - category: 'selection', - description: 'Expand the selection edge outward by a number of pixels.', - icon: 'fa6-solid:up-right-and-down-left-from-center', menuPath: ['Select:50'], enabled: () => app.engineState?.hasSelection || 'No active selection', handler: () => selectionModify.show('grow'), }); actions.register({ id: 'shrinkSelection', - displayName: 'Shrink Selection', - category: 'selection', - description: 'Contract the selection edge inward by a number of pixels.', - icon: 'fa6-solid:down-left-and-up-right-to-center', menuPath: ['Select:60'], enabled: () => app.engineState?.hasSelection || 'No active selection', handler: () => selectionModify.show('shrink'), }); actions.register({ id: 'borderSelection', - displayName: 'Border Selection', - category: 'selection', - description: 'Replace the selection with a band straddling its edge.', - icon: 'fa6-solid:border-all', menuPath: ['Select:70'], enabled: () => app.engineState?.hasSelection || 'No active selection', handler: () => selectionModify.show('border'), }); actions.register({ id: 'smoothSelection', - displayName: 'Smooth Selection', - category: 'selection', - description: 'Round off jagged edges and remove small specks.', - icon: 'fa6-solid:wand-magic-sparkles', menuPath: ['Select:80'], enabled: () => app.engineState?.hasSelection || 'No active selection', handler: () => app.engine?.api.smoothSelection({ radius: 2 }), }); actions.register({ id: 'featherSelection', - displayName: 'Feather Selection', - category: 'selection', - description: 'Soften the selection edge with a Gaussian blur.', - icon: 'fa6-solid:feather', menuPath: ['Select:90'], enabled: () => app.engineState?.hasSelection || 'No active selection', handler: () => selectionModify.show('feather'), }); actions.register({ id: 'antialiasSelection', - displayName: 'Antialias Selection', - category: 'selection', - description: 'Soften the staircase of a hard-edged selection.', - icon: 'fa6-solid:wand-magic', menuPath: ['Select:100'], enabled: () => app.engineState?.hasSelection || 'No active selection', handler: () => app.engine?.api.antialiasSelection(), @@ -424,10 +356,6 @@ export function registerActions() { // -- Image (canvas) -- actions.register({ id: 'resizeCanvas', - displayName: 'Resize Canvas', - category: 'edit', - description: 'Resize the canvas with a 9-point anchor.', - icon: 'fa6-solid:up-right-and-down-left-from-center', menuPath: ['Image:10'], handler: () => { if (!app.engine) return; @@ -436,10 +364,6 @@ export function registerActions() { }); actions.register({ id: 'rescaleImage', - displayName: 'Scale Image to New Size', - category: 'edit', - description: 'Resize all layers to new document dimensions.', - icon: 'fa6-solid:expand', menuPath: ['Image:11'], handler: () => { if (!app.engine) return; @@ -448,10 +372,6 @@ export function registerActions() { }); actions.register({ id: 'cropToSelection', - displayName: 'Crop to Selection', - category: 'edit', - description: 'Crop the canvas to the current selection bounds.', - icon: 'fa6-solid:crop-simple', menuPath: ['Image:20'], // `enabled` is synchronous and `has_selection` is async, so we gate on // the `engineState` mirror (refreshed from render's snapshot) rather @@ -465,10 +385,6 @@ export function registerActions() { }); actions.register({ id: 'flipCanvasH', - displayName: 'Flip Canvas Horizontally', - category: 'edit', - description: 'Mirror the whole canvas left-to-right.', - icon: 'fa6-solid:arrows-left-right', menuPath: ['Image:30'], handler: async () => { app.engine?.api.flipCanvas({ axis: 'h' }); @@ -478,10 +394,6 @@ export function registerActions() { }); actions.register({ id: 'flipCanvasV', - displayName: 'Flip Canvas Vertically', - category: 'edit', - description: 'Mirror the whole canvas top-to-bottom.', - icon: 'fa6-solid:arrows-up-down', menuPath: ['Image:31'], handler: async () => { app.engine?.api.flipCanvas({ axis: 'v' }); @@ -491,10 +403,6 @@ export function registerActions() { }); actions.register({ id: 'rotateCanvasCW', - displayName: 'Rotate Canvas 90° CW', - category: 'edit', - description: 'Rotate the whole canvas a quarter turn clockwise.', - icon: 'fa6-solid:rotate-right', menuPath: ['Image:40'], handler: async () => { app.engine?.api.rotateCanvas({ dir: 'cw' }); @@ -504,10 +412,6 @@ export function registerActions() { }); actions.register({ id: 'rotateCanvasCCW', - displayName: 'Rotate Canvas 90° CCW', - category: 'edit', - description: 'Rotate the whole canvas a quarter turn counter-clockwise.', - icon: 'fa6-solid:rotate-left', menuPath: ['Image:41'], handler: async () => { app.engine?.api.rotateCanvas({ dir: 'ccw' }); @@ -517,10 +421,6 @@ export function registerActions() { }); actions.register({ id: 'rotateCanvas180', - displayName: 'Rotate Canvas 180°', - category: 'edit', - description: 'Rotate the whole canvas a half turn.', - icon: 'fa6-solid:rotate', menuPath: ['Image:42'], handler: async () => { app.engine?.api.rotateCanvas({ dir: '180' }); @@ -535,13 +435,6 @@ export function registerActions() { // -- File I/O -- actions.register({ id: 'saveDocument', - displayName: 'Save', - category: 'file', - description: - 'Save the current document. Re-saves to the same `.darkly` file after ' + - 'the first Save As; otherwise opens the Save picker (`.darkly`, or ' + - 'PNG / JPEG / WebP to export the canvas).', - icon: 'fa6-solid:floppy-disk', menuPath: ['File:30'], handler: () => { if (!app.engine) return; @@ -550,12 +443,6 @@ export function registerActions() { }); actions.register({ id: 'saveDocumentAs', - displayName: 'Save As', - category: 'file', - description: - 'Save the current document to a new file — `.darkly`, or PNG / JPEG / ' + - 'WebP to export the canvas.', - icon: 'fa6-solid:file-export', menuPath: ['File:40'], handler: () => { if (!app.engine) return; @@ -564,11 +451,6 @@ export function registerActions() { }); actions.register({ id: 'newDocument', - displayName: 'New', - category: 'file', - description: - 'Open a fresh document in a new tab. Prompts for canvas size and background color.', - icon: 'fa6-solid:file', menuPath: ['File:10'], // No default hotkey — `$mod+KeyN` is reserved by every major browser // for "new window" and cannot be intercepted by the page. Users can @@ -579,11 +461,6 @@ export function registerActions() { }); actions.register({ id: 'open', - displayName: 'Open', - category: 'file', - description: - 'Open a `.darkly` document or image (PNG / JPEG / WebP) in a new tab.', - icon: 'fa6-solid:folder-open', menuPath: ['File:20'], handler: () => { void openFlow(); @@ -591,10 +468,6 @@ export function registerActions() { }); actions.register({ id: 'exportTimelapse', - displayName: 'Export Timelapse…', - category: 'file', - description: 'Export the process recording as an MP4 or GIF timelapse.', - icon: 'fa6-solid:video', menuPath: ['File:51'], handler: () => { if (!app.engine) return; @@ -604,9 +477,6 @@ export function registerActions() { // -- Floating content / transform -- actions.register({ id: 'commitFloating', - displayName: 'Commit Floating', - category: 'transform', - icon: 'fa6-solid:check', handler: () => { if (!app.engine) return; app.engine.api.commitFloating(); @@ -615,9 +485,6 @@ export function registerActions() { }); actions.register({ id: 'cancelFloating', - displayName: 'Cancel Floating', - category: 'transform', - icon: 'fa6-solid:xmark', handler: () => { if (!app.engine) return; app.engine.api.cancelFloating(); @@ -627,20 +494,28 @@ export function registerActions() { // -- Tools (generated from registry) -- // Tool key bindings come from the YAML preset layers (defaults.yaml + - // overlay) via `hotkeys.` — actions register without - // any built-in default; the binding is purely configuration. - // Tool display names live in Rust (`ToolRegistration`). Resolve through - // `app.toolDisplayName(id)` which reads the registry map populated by - // `app.loadRegistries(handle)` during editor init — the frontend never - // hardcodes a label. + // overlay) via `hotkeys.` — actions register without any + // built-in default; the binding is purely configuration. + // + // A tool-selecting action's documentation is the tool's own: the id, label, + // glyph and summary all live on its `ToolRegistration` and reach here + // through the `tools` catalog, so it passes an explicit `doc` rather than + // resolving through the `actions` catalog. Only the "Switch to …" phrasing + // and the erase-mode glyph override are this side's. for (const tool of toolRegistry.all()) { - const name = app.toolDisplayName(tool.id); + // A descriptor the core has no registration for would select a tool that + // does not exist, so it gets no action. + const entry = app.entry('tools', tool.id); + if (!entry?.hotkeyAction) continue; + const name = entry.displayName; actions.register({ - id: tool.hotkeyAction, - displayName: name, - category: 'tools', - description: `Switch to ${name} tool`, - icon: glyphFromTool(tool), + id: entry.hotkeyAction, + doc: { + displayName: name, + category: 'tools', + description: `Switch to ${name} tool`, + icon: app.toolGlyph(tool.id), + }, handler: () => { app.activeToolId = tool.id; }, }); } @@ -650,10 +525,6 @@ export function registerActions() { // erase on (matches Krita's "E from anywhere paints with the eraser"). actions.register({ id: 'toggleEraseMode', - displayName: 'Toggle Erase Mode', - category: 'tools', - description: 'Toggle erase mode on the brush tool. Switches to the brush tool first if another tool is active.', - icon: 'fa6-solid:eraser', status: () => (brushSession.eraseMode ? 'fa6-solid:check' : undefined), handler: () => { if (app.activeToolId !== 'brush') { @@ -674,10 +545,6 @@ export function registerActions() { // -- Layers -- actions.register({ id: 'newLayer', - displayName: 'New Layer', - category: 'layers', - description: 'Add a new layer above the active one.', - icon: 'fa6-solid:square-plus', menuPath: ['Layer:10'], handler: async () => { const engine = app.engine; @@ -690,10 +557,6 @@ export function registerActions() { actions.register({ id: 'newGroup', - displayName: 'New Group', - category: 'layers', - description: 'Group the selected layers together, or add an empty group if nothing is selected.', - icon: 'fa6-solid:folder-plus', menuPath: ['Layer:20'], handler: async () => { const engine = app.engine; @@ -717,12 +580,7 @@ export function registerActions() { actions.register({ id: 'toggleVisibility', - displayName: 'Toggle Layer Visibility', - category: 'layers', - description: 'Show or hide the active layer.', - icon: 'fa6-solid:eye', menuPath: ['Layer:70'], - accepts: ['layerId'], handler: (ctx) => { const layerId = ctx.layerId ?? app.activeLayerId; if (layerId == null || !app.engine) return; @@ -735,12 +593,7 @@ export function registerActions() { actions.register({ id: 'toggleLock', - displayName: 'Toggle Layer Lock', - category: 'layers', - description: 'Lock or unlock the active layer.', - icon: 'fa6-solid:lock', menuPath: ['Layer:80'], - accepts: ['layerId'], handler: (ctx) => { const layerId = ctx.layerId ?? app.activeLayerId; if (layerId == null || !app.engine) return; @@ -753,12 +606,7 @@ export function registerActions() { actions.register({ id: 'isolateLayer', - displayName: 'Isolate Layer', - category: 'layers', - description: 'Solo a layer so only it shows in the canvas. Press again to bring everything else back.', - icon: 'fa6-solid:circle-dot', menuPath: ['Layer:90'], - accepts: ['layerId'], handler: (ctx) => { const layerId = ctx.layerId ?? app.activeLayerId; if (layerId == null || !app.engine) return; @@ -768,10 +616,6 @@ export function registerActions() { actions.register({ id: 'deleteLayer', - displayName: 'Delete Layer', - category: 'layers', - description: 'Delete the selected layers.', - icon: 'fa6-solid:trash', menuPath: ['Layer:60'], handler: async () => { const engine = app.engine; @@ -817,10 +661,6 @@ export function registerActions() { actions.register({ id: 'duplicateLayer', - displayName: 'Duplicate Layer', - category: 'layers', - description: 'Make a copy of each selected layer.', - icon: 'fa6-solid:clone', menuPath: ['Layer:30'], handler: async () => { const engine = app.engine; @@ -843,10 +683,6 @@ export function registerActions() { actions.register({ id: 'flipLayerH', - displayName: 'Flip Horizontally', - category: 'layers', - description: 'Mirror the active layer (or selection) left-to-right.', - icon: 'fa6-solid:arrows-left-right', menuPath: ['Layer:40'], enabled: () => app.activeLayerId !== null || 'No active layer', handler: async () => { @@ -858,10 +694,6 @@ export function registerActions() { }); actions.register({ id: 'flipLayerV', - displayName: 'Flip Vertically', - category: 'layers', - description: 'Mirror the active layer (or selection) top-to-bottom.', - icon: 'fa6-solid:arrows-up-down', menuPath: ['Layer:50'], enabled: () => app.activeLayerId !== null || 'No active layer', handler: async () => { @@ -872,27 +704,34 @@ export function registerActions() { }, }); // Destructive color filters (invert, …) are registered dynamically - // from the Rust filter-pipeline registry (fetched into `app.filterTypes` + // from the Rust filter-pipeline registry (the `filters` catalog fetched // during `loadRegistries`), so a new filter in the core surfaces a // Colors-menu entry with no frontend edit. The target is the active *node* // (`activeLayerId` is the mask filter id when a mask is selected), which // is what makes "invert the mask" reachable from the same entry. - for (const flt of app.filterTypes ?? []) { + for (const flt of app.entries?.('filters') ?? []) { const filterType = flt.type; + if (!flt.hotkeyAction) continue; // A parametric filter (curves/levels/hsv) can't apply in one click — its // params must be authored first, so it opens the modal (the same // `FilterParamsEditor` the layer panel uses). Param-free filters (invert) // apply immediately. const parametric = (flt.params?.length ?? 0) > 0; actions.register({ - id: `filter${filterType.charAt(0).toUpperCase()}${filterType.slice(1)}`, - displayName: parametric ? `${flt.displayName}…` : flt.displayName, - category: 'layers', - // Lead with the registry's own summary — the command palette's - // substring search indexes descriptions, so its keywords (e.g. - // "desaturate" for Black and White) keep the filter findable. - description: `${flt.description} Applies to the active layer or mask (respecting any selection).`, - icon: flt.icon, + id: flt.hotkeyAction, + // Like tool selection, the documentation is the filter's own and + // arrives through the `filters` catalog. What this side composes is + // the phrasing: the `…` that marks a filter as opening a dialog, + // and the note about what the filter lands on. + doc: { + displayName: parametric ? `${flt.displayName}…` : flt.displayName, + category: 'layers', + // Lead with the registry's own summary — the command palette's + // substring search indexes descriptions, so its keywords (e.g. + // "desaturate" for Black and White) keep the filter findable. + description: `${flt.description ?? ''} Applies to the active layer or mask (respecting any selection).`.trim(), + icon: flt.icon ?? '', + }, menuPath: ['Colors:10'], enabled: () => app.activeLayerId !== null || 'No active layer', handler: async () => { @@ -903,7 +742,7 @@ export function registerActions() { app.activeLayerId, filterType, flt.displayName, - (flt.params ?? []) as unknown as FilterParam[] + (flt.params ?? []) as unknown as ParamInfo[] ); return; } @@ -919,10 +758,6 @@ export function registerActions() { actions.register({ id: 'mergeDown', - displayName: 'Merge Down', - category: 'layers', - description: 'Merge the active layer into the one below it, or combine multiple selected layers into a single layer.', - icon: 'fa6-solid:arrows-down-to-line', menuPath: ['Layer:110'], handler: async () => { const engine = app.engine; @@ -953,13 +788,7 @@ export function registerActions() { actions.register({ id: 'flatten', - displayName: 'Flatten', - category: 'layers', - description: - 'Bake modifiers into the layer (apply mask), or flatten a group into a single raster that inherits the group’s blend props.', - icon: 'fa6-solid:layer-group', menuPath: ['Layer:120'], - accepts: ['layerId'], handler: async (ctx) => { const engine = app.engine; if (!engine) return; @@ -977,12 +806,7 @@ export function registerActions() { actions.register({ id: 'addMask', - displayName: 'Add Mask', - category: 'layers', - description: 'Add a mask modifier to the active layer or group and activate it for painting.', - icon: 'radix-icons:mask-on', menuPath: ['Layer:100'], - accepts: ['layerId'], handler: async (ctx) => { const engine = app.engine; if (!engine) return; @@ -1003,10 +827,6 @@ export function registerActions() { // -- View -- actions.register({ id: 'openSettings', - displayName: 'Settings', - category: 'view', - description: 'Show the preferences modal.', - icon: 'fa6-solid:gear', // No `menuPath`: surfaced as the gear button on the menu bar and a // root courtesy item in the hamburger, not as a View submenu row. handler: () => { settings.open = true; }, @@ -1014,10 +834,6 @@ export function registerActions() { actions.register({ id: 'mirrorViewH', - displayName: 'Mirror View', - category: 'view', - description: 'Flip the canvas horizontally for fresh-eyes review. View-only — the document is unchanged.', - icon: 'fa6-solid:left-right', menuPath: ['View:10'], status: () => (app.mirrorH ? 'fa6-solid:check' : undefined), handler: () => { @@ -1028,40 +844,24 @@ export function registerActions() { actions.register({ id: 'resetView', - displayName: 'Reset View', - category: 'view', - description: 'Reset rotation, mirror, pan, and zoom-to-fit. View-only — the document is unchanged.', - icon: 'fa6-solid:expand', menuPath: ['View:11'], handler: () => { app.resetView(); }, }); actions.register({ id: 'fitToScreen', - displayName: 'Fit to Screen', - category: 'view', - description: 'Zoom and recenter so the whole canvas fills the viewport, keeping the current rotation and mirror. View-only — the document is unchanged.', - icon: 'fa6-solid:maximize', menuPath: ['View:12'], handler: () => { app.fitToScreen(); }, }); actions.register({ id: 'centerView', - displayName: 'Center View', - category: 'view', - description: 'Recenter the canvas in the viewport without changing zoom, rotation, or mirror. View-only — the document is unchanged.', - icon: 'fa6-solid:crosshairs', menuPath: ['View:13'], handler: () => { app.centerView(); }, }); actions.register({ id: 'commandPalette', - displayName: 'Command Palette', - category: 'view', - description: 'Search and run any command.', - icon: 'fa6-solid:magnifying-glass', // No `menuPath`: surfaced as the prominent "Find" item at the top of // the hamburger / on the menu bar, not as a buried submenu row. handler: () => { commandPalette.open = true; }, @@ -1069,50 +869,30 @@ export function registerActions() { actions.register({ id: 'openCheatsheet', - displayName: 'Hotkey Cheat Sheet', - category: 'view', - description: 'Open a searchable, printable list of every keyboard shortcut.', - icon: 'fa6-solid:keyboard', menuPath: ['Help:10'], handler: () => openCheatsheet(), }); actions.register({ id: 'openDocs', - displayName: 'Documentation', - category: 'view', - description: 'Open the Darkly documentation in a new tab.', - icon: 'fa6-solid:book', menuPath: ['Help:20'], handler: () => openExternal(links.docs), }); actions.register({ id: 'openWebsite', - displayName: 'Website', - category: 'view', - description: 'Open the Darkly website in a new tab.', - icon: 'fa6-solid:globe', menuPath: ['Help:30'], handler: () => openExternal(links.website), }); actions.register({ id: 'openGithub', - displayName: 'GitHub Repository', - category: 'view', - description: 'Open the Darkly source repository on GitHub.', - icon: 'fa6-brands:github', menuPath: ['Help:40'], handler: () => openExternal(links.github), }); actions.register({ id: 'aboutDarkly', - displayName: 'About Darkly', - category: 'view', - description: 'Show version and credits.', - icon: 'fa6-solid:circle-info', menuPath: ['Help:50'], handler: () => { about.open = true; }, }); @@ -1129,10 +909,6 @@ export function registerActions() { // -- Brush builder -- actions.register({ id: 'addBrushNode', - displayName: 'Add Brush Node', - category: 'brush', - description: 'Open the add-node menu at the cursor (brush builder).', - icon: 'fa6-solid:diagram-project', handler: () => { // No-op if the brush builder isn't visible. The actual placement // — at the cursor in canvas coords — happens in NodeCanvas, which diff --git a/frontend/src/actions/registry.ts b/frontend/src/actions/registry.ts index 26352dd8..e6b8edb1 100644 --- a/frontend/src/actions/registry.ts +++ b/frontend/src/actions/registry.ts @@ -1,24 +1,34 @@ +import type { CatalogEntry } from '../engine/protocol_gen'; import { bumpRegistryEpoch } from './registryEpoch.svelte'; export type ActionContext = Record; export type ActionType = 'instant' | 'hold'; -export type ActionCategory = - | 'edit' | 'tools' | 'selection' | 'brush' - | 'layers' | 'view' | 'colors' | 'transform' | 'file'; - -export interface ActionRegistration { - id: string; +/** An action's documentation — the half authored in Rust (`crates/darkly/src/ + * actions/`) and shipped in the `actions` catalog. */ +export interface ActionDoc { displayName: string; - category: ActionCategory; + /** Grouping id, e.g. 'edit'. The cheat sheet renders one section per + * category and the hotkeys tab groups by it. */ + category: string; description?: string; /** Base Iconify icon name for this action (e.g. 'fa6-solid:rotate-left'), * rendered in the menu gutter and command-palette row via ``. The * dynamic `status()` icon, when active, takes precedence over this base * icon in the gutter. */ icon: string; - requires?: string[]; - accepts?: string[]; +} + +/** What a call site hands to `actions.register` — the behavioural half, which + * closes over Svelte runes and so cannot leave the browser. */ +export interface ActionRegistration { + id: string; + /** Documentation for an action whose metadata another catalog owns: + * tool selection reads `tools`, filter application reads `filters`, and + * each composes a phrasing this side owns ("Switch to Brush tool", the + * parametric `…` suffix). Absent for every other action, which resolves + * through the `actions` catalog. */ + doc?: ActionDoc; type?: ActionType; /** Top-level menu this action appears under, e.g. ['Select']. Absent → * not in the click-through menu (still available via hotkey + palette). @@ -52,6 +62,9 @@ export interface ActionRegistration { deactivate?: (ctx: ActionContext) => void; } +/** A registration joined with its documentation — what every consumer reads. */ +export type Action = Omit & ActionDoc; + export interface BindingSiteRegistration { name: string; provides: string[]; @@ -76,7 +89,7 @@ export function parseMenuSegment(segment: string): { title: string; order?: numb * tooltip reason. `enabled` absent or returning `true` → enabled; a string → * disabled with that string as the reason; `false` → disabled, no reason. */ export function actionEnablement( - action: ActionRegistration, + action: Action, ): { enabled: boolean; reason?: string } { const e = action.enabled?.(); if (e === undefined || e === true) return { enabled: true }; @@ -84,54 +97,59 @@ export function actionEnablement( return { enabled: false }; } -/** Check if an action's hard requirements are satisfied by a set of provided keys. */ -export function contextSatisfied( - action: ActionRegistration, - provides: string[], -): boolean { - const req = action.requires; - if (!req || req.length === 0) return true; - return req.every(k => provides.includes(k)); -} - -/** Return the missing required keys, or [] if satisfied. */ -export function missingContext( - action: ActionRegistration, - provides: string[], -): string[] { - const req = action.requires; - if (!req || req.length === 0) return []; - return req.filter(k => !provides.includes(k)); +/** Index an `actions` catalog's entries by id, ready for `actions.setDocs`. */ +export function actionDocs(entries: CatalogEntry[]): Record { + const out: Record = {}; + for (const e of entries) { + out[e.type] = { + displayName: e.displayName, + category: e.category ?? 'other', + description: e.description ?? undefined, + icon: e.icon ?? '', + }; + } + return out; } class ActionRegistry { private actions = new Map(); + /** Rust-owned documentation by action id, installed once during editor init + * from the `actions` catalog. Fixed for the process — a catalog is + * `&'static` data on the other side of the bridge — so the join needs no + * reactive tracking. */ + private docs: Record = {}; + + setDocs(docs: Record) { + this.docs = docs; + bumpRegistryEpoch(); + } + + /** Join a registration to its documentation. An id with neither an + * `actions` entry nor its own `doc` falls back to showing the id, the same + * way `app.displayName` does for an unknown `type_id` — the TypeScript + * join test is what fails loudly on it. */ + private resolve(reg: ActionRegistration): Action { + const { doc, ...behaviour } = reg; + const resolved = doc ?? + this.docs[reg.id] ?? { displayName: reg.id, category: 'other', icon: '' }; + return { ...behaviour, ...resolved }; + } + register(reg: ActionRegistration) { this.actions.set(reg.id, reg); bumpRegistryEpoch(); } - get(id: string): ActionRegistration | undefined { - return this.actions.get(id); + get(id: string): Action | undefined { + const reg = this.actions.get(id); + return reg && this.resolve(reg); } - /** Dispatch an action with runtime context validation. - * Checks that all required keys are present and non-nullish in ctx. */ + /** Run an action's handler. Unknown ids are a no-op — a preset can name + * one, and the Rust preset test is what catches it. */ dispatch(id: string, ctx: ActionContext = {}) { - const action = this.actions.get(id); - if (!action) return; - const req = action.requires; - if (req && req.length > 0) { - const missing = req.filter(k => ctx[k] == null); - if (missing.length > 0) { - console.warn( - `Action "${id}" requires [${req.join(', ')}] but context is missing [${missing.join(', ')}]. Skipping.` - ); - return; - } - } - action.handler(ctx); + this.actions.get(id)?.handler(ctx); } /** For 'hold' actions — called on trigger release. */ @@ -146,25 +164,20 @@ class ActionRegistry { } /** All registrations (for shortcuts editor UI). */ - all(): ActionRegistration[] { - return [...this.actions.values()]; + all(): Action[] { + return [...this.actions.values()].map(reg => this.resolve(reg)); } /** Actions grouped by category (for shortcuts editor UI). */ - byCategory(): Map { - const map = new Map(); - for (const reg of this.actions.values()) { - let list = map.get(reg.category); - if (!list) { list = []; map.set(reg.category, list); } - list.push(reg); + byCategory(): Map { + const map = new Map(); + for (const action of this.all()) { + let list = map.get(action.category); + if (!list) { list = []; map.set(action.category, list); } + list.push(action); } return map; } - - /** Actions compatible with a given binding site (for shortcuts editor UI). */ - compatibleWith(site: BindingSiteRegistration): ActionRegistration[] { - return this.all().filter(a => contextSatisfied(a, site.provides)); - } } class BindingSiteRegistry { diff --git a/frontend/src/actions/sample_color.ts b/frontend/src/actions/sample_color.ts index 84d443cf..a4ec4a88 100644 --- a/frontend/src/actions/sample_color.ts +++ b/frontend/src/actions/sample_color.ts @@ -12,11 +12,6 @@ import { screenToCanvas } from '../canvas/coordinates'; export function registerSampleColorAction(): void { actions.register({ id: 'sampleColor', - displayName: 'Sample Color', - category: 'colors', - description: - 'Hold the modifier and drag on the canvas to sample a color into the foreground swatch.', - icon: 'fa6-solid:eye-dropper', type: 'hold', handler: (ctx) => { if (!app.engine) return; diff --git a/frontend/src/config/__tests__/hotkey_label.test.ts b/frontend/src/config/__tests__/hotkey_label.test.ts index f51bbea0..2ac0e8fb 100644 --- a/frontend/src/config/__tests__/hotkey_label.test.ts +++ b/frontend/src/config/__tests__/hotkey_label.test.ts @@ -9,7 +9,17 @@ const HOTKEYS: Record = { 'hotkeys.openSettings': '$mod+Comma', 'hotkeys.isolateLayer': '', }; +// Chord rendering itself lives in Rust (`config::chord`) and is covered there; +// what this file exercises is the layer above it — `|`-splitting and +// first-binding selection — so the stub renders the handful of chords the +// fixtures use and strips the site prefix the way the bridge does. +const CHORDS: Record = { + '$mod+Shift+KeyP': 'Ctrl+Shift+P', + '$mod+KeyF': 'Ctrl+F', + '$mod+Comma': 'Ctrl+,', +}; vi.mock('../../../wasm/pkg/darkly_wasm', () => ({ + format_chord: (binding: string) => CHORDS[binding.split(':').pop() ?? ''] ?? '', config_get: (key: string) => HOTKEYS[key], config_set: () => {}, config_reset: () => {}, diff --git a/frontend/src/config/schema.ts b/frontend/src/config/schema.ts deleted file mode 100644 index 49a44b94..00000000 --- a/frontend/src/config/schema.ts +++ /dev/null @@ -1,34 +0,0 @@ -// TS mirrors of the Rust config schema views (crates/darkly/src/config/schema.rs -// -> SectionInfo / PrefInfo). The Rust side is authoritative; this file just -// describes the JSON shape returned by `config_schema()`. - -export type PrefKindName = 'bool' | 'int' | 'float' | 'str' | 'enum'; - -export type WidgetName = - | 'auto' - | 'numberInput' - | 'hotkey' - | 'mouseBinding' - | 'color' - | 'hidden'; - -export interface PrefInfo { - key: string; - displayName: string; - description?: string; - kind: PrefKindName; - min?: number; - max?: number; - /** For enum prefs: `[[value, label], ...]`. */ - options?: [string, string][]; - widget: WidgetName; -} - -export interface SectionInfo { - id: string; - displayName: string; - description?: string; - icon?: string; - order: number; - prefs: PrefInfo[]; -} diff --git a/frontend/src/config/store.svelte.ts b/frontend/src/config/store.svelte.ts index 684fb928..6962e96b 100644 --- a/frontend/src/config/store.svelte.ts +++ b/frontend/src/config/store.svelte.ts @@ -1,9 +1,16 @@ import { config_get, config_set, config_reset, config_reset_all, config_base_names, config_base_value, config_schema, config_version, + format_chord, } from '../../wasm/pkg/darkly_wasm'; import { storage, readJson, writeJson } from '../storage'; -import type { SectionInfo } from './schema'; +import type { Catalog, ParamInfo } from '../engine/protocol_gen'; + +/** The prefs a settings catalog holds — each section is one catalog with a + * single entry whose `params` are that section's prefs. */ +export function sectionPrefs(section: Catalog): ParamInfo[] { + return section.entries[0]?.params ?? []; +} import { validateOverrides } from './validate'; /** @@ -59,13 +66,13 @@ class ConfigStore { baseNames = $state([]); /** Flat preferences schema, loaded once on init. */ - schema = $state([]); + schema = $state([]); /** Initialize the store. Must be called after WASM init(). * Reads the schema, the overlay list, and the user-settings file. */ async init() { try { - this.schema = JSON.parse(config_schema()) as SectionInfo[]; + this.schema = JSON.parse(config_schema()) as Catalog[]; } catch (e) { console.error('[config] failed to parse schema JSON', e); this.schema = []; @@ -170,10 +177,10 @@ class ConfigStore { const section = this.schema.find(s => s.id === sectionId); if (!section) return; const next = { ...this.#values }; - for (const pref of section.prefs) { - if (pref.key in next) { - config_reset(pref.key); - delete next[pref.key]; + for (const pref of sectionPrefs(section)) { + if (pref.name in next) { + config_reset(pref.name); + delete next[pref.name]; } } this.#values = next; @@ -273,46 +280,15 @@ export function effectiveHotkey(actionId: string): string { * (`"layerPanel:Delete"`, `"@paint:KeyB"`, `"canvas@paint:$mod+drag"`) and * strips it before formatting — only the chord is user-facing. * - * Handles both the keyboard chord vocabulary (`Shift`/`Alt` capitalized, key - * codes like `KeyA`/`Comma`) and the mouse chord vocabulary - * (`shift`/`alt`/`ctrl`/`meta` lowercase, verbs like `click`/`drag`). - * - * Takes exactly ONE binding. Raw `hotkeys.` values may hold several joined - * with `|` — go through `hotkeyLabel` for those. + * The chord vocabulary lives in Rust (`config::chord`), because the metadata + * export ships chords already rendered and a second copy of that table here + * would be a byte-for-byte duplicate. All this adds is the one thing Rust + * cannot know: which platform the browser is running on. */ export function formatHotkey(binding: string | undefined): string | undefined { if (!binding) return undefined; - const colonIdx = binding.indexOf(':'); - const chord = colonIdx < 0 ? binding : binding.slice(colonIdx + 1); - if (!chord) return undefined; const isMac = /Mac|iPhone|iPad/.test(navigator.userAgent); - return chord.split('+').map(part => { - if (part === '$mod') return isMac ? '⌘' : 'Ctrl'; - if (part === 'Shift' || part === 'shift') return isMac ? '⇧' : 'Shift'; - if (part === 'Alt' || part === 'alt') return isMac ? '⌥' : 'Alt'; - if (part === 'ctrl') return isMac ? '⌃' : 'Ctrl'; - if (part === 'meta') return isMac ? '⌘' : 'Win'; - if (part === 'click') return 'click'; - if (part === 'doubleClick') return 'double-click'; - if (part === 'middleClick') return 'middle-click'; - if (part === 'drag') return 'drag'; - if (part === 'middleDrag') return 'middle-drag'; - if (part === 'rightDrag') return 'right-drag'; - if (part.startsWith('Key')) return part.slice(3); - if (part === 'Delete') return 'Del'; - if (part === 'Comma') return ','; - if (part === 'Period') return '.'; - if (part === 'Semicolon') return ';'; - if (part === 'Quote') return "'"; - if (part === 'BracketLeft') return '['; - if (part === 'BracketRight') return ']'; - if (part === 'Backslash') return '\\'; - if (part === 'Minus') return '-'; - if (part === 'Equal') return '='; - if (part === 'Slash') return '/'; - if (part === 'Backquote') return '`'; - return part; - }).join('+'); + return format_chord(binding, isMac) || undefined; } /** diff --git a/frontend/src/config/validate.ts b/frontend/src/config/validate.ts index 076c3f6d..6ca53c83 100644 --- a/frontend/src/config/validate.ts +++ b/frontend/src/config/validate.ts @@ -1,4 +1,5 @@ -import type { PrefInfo, SectionInfo } from './schema'; +import type { Catalog, ParamInfo } from '../engine/protocol_gen'; +import { sectionPrefs } from './store.svelte'; /** * Per-action keys are accepted by prefix even though no schema entry @@ -16,12 +17,12 @@ const PER_ACTION_PREFIXES = ['hotkeys.', 'mouseclicks.']; * persisting the cleaned set back if anything changed. */ export function validateOverrides( - sections: SectionInfo[], + sections: Catalog[], overrides: Record, ): { cleaned: Record; changed: boolean } { - const byKey = new Map(); + const byKey = new Map(); for (const section of sections) { - for (const pref of section.prefs) byKey.set(pref.key, pref); + for (const pref of sectionPrefs(section)) byKey.set(pref.name, pref); } const cleaned: Record = {}; @@ -65,7 +66,7 @@ export function validateOverrides( const DROP = Symbol('drop'); -function coerce(pref: PrefInfo, value: unknown): unknown | typeof DROP { +function coerce(pref: ParamInfo, value: unknown): unknown | typeof DROP { switch (pref.kind) { case 'bool': return typeof value === 'boolean' ? value : DROP; @@ -73,7 +74,8 @@ function coerce(pref: PrefInfo, value: unknown): unknown | typeof DROP { return typeof value === 'string' ? value : DROP; case 'enum': { if (typeof value !== 'string') return DROP; - const ok = pref.options?.some(([k]) => k === value) ?? false; + const opts = (pref.options ?? []) as [string, string][]; + const ok = opts.some(([k]) => k === value); return ok ? value : DROP; } case 'int': @@ -81,8 +83,8 @@ function coerce(pref: PrefInfo, value: unknown): unknown | typeof DROP { if (typeof value !== 'number' || !Number.isFinite(value)) return DROP; let v = value; if (pref.kind === 'int') v = Math.trunc(v); - if (pref.min !== undefined && v < pref.min) v = pref.min; - if (pref.max !== undefined && v > pref.max) v = pref.max; + if (pref.min != null && v < pref.min) v = pref.min; + if (pref.max != null && v > pref.max) v = pref.max; return v; } } diff --git a/frontend/src/editor.ts b/frontend/src/editor.ts index 055606c9..c94f6840 100644 --- a/frontend/src/editor.ts +++ b/frontend/src/editor.ts @@ -2,6 +2,7 @@ import init from '../wasm/pkg/darkly_wasm'; import { config } from './config/store.svelte'; import { registerHotkeys } from './config/hotkeys.svelte'; import { registerActions } from './actions'; +import { actions, actionDocs } from './actions/registry'; import { rebuildClickIndex } from './actions/triggers'; import { theme } from './state/theme.svelte'; import { pixelFilter } from './state/pixelFilter.svelte'; @@ -131,6 +132,12 @@ export async function createInstance( // Action/hotkey registration is process-wide but reads the active // instance via the `app` proxy. Calling it here is idempotent. + // + // Every action's documentation is Rust's (`crates/darkly/src/actions/`) and + // arrives in the `actions` catalog; the registry joins it to the handlers by + // id. Installed before registration so nothing observes a half-joined + // registry. + actions.setDocs(actionDocs(instance.entries('actions'))); registerActions(); registerHotkeys(); rebuildClickIndex(); diff --git a/frontend/src/engine/protocol_gen.ts b/frontend/src/engine/protocol_gen.ts index daec94b6..cc9feb93 100644 --- a/frontend/src/engine/protocol_gen.ts +++ b/frontend/src/engine/protocol_gen.ts @@ -62,8 +62,6 @@ export type BeginStrokeReq = { id: number, }; export type BeginTransformReq = { id: number, }; -export type BlendModeTypeInfo = { type: string, displayName: string, category: string, }; - export type BorderSelectionReq = { radius: number, }; export type BrushGraphCapabilities = { @@ -74,12 +72,20 @@ export type BrushGraphCapabilities = { */ supports_erase: boolean, /** - * Iconify icon to show in place of baked dab/stroke thumbnails, - * contributed by the first node whose registration sets - * `preview_fallback_icon` — content-dependent nodes (clone, blur, - * smudge, liquify) whose preview bake renders blank. + * Iconify icon to show in the dab slot in place of a baked thumbnail, + * contributed by the first node whose registration declares + * `preview_staging` — content-dependent nodes (clone, blur, smudge, + * liquify) whose still-dab bake renders blank. + */ +preview_fallback_icon: string | null, +/** + * Field the stroke preview is rendered over, from the same declaration + * the icon comes from. [`PreviewBackdrop::Flat`] for a brush that deposits + * pigment and so needs nothing staged under it. */ -preview_fallback_icon: string | null, }; +preview_backdrop: PreviewBackdrop, }; + +export type PreviewBackdrop = "Flat" | "Stripes"; export type BrushDabThumbnailReq = { name: string, }; @@ -155,6 +161,8 @@ export type BrushGraphSetInputReq = { node_id: string, input_name: string, kind: export type BrushGraphSetNodeCommentReq = { node_id: string, comment: string, }; +export type BrushGraphSetPortRangeReq = { node_id: string, port_name: string, display_min: number, display_max: number, }; + export type BrushGraphUnexposePortReq = { node_id: string, port_name: string, }; export type BrushInfo = { name: string, category: string, author: string, description: string, tags: Array, @@ -170,6 +178,18 @@ export type BrushLoadReq = { name: string, }; export type BrushNodePreviewReq = { node_id: string, }; +export type PreviewStaging = { +/** + * Iconify glyph shown in the dab slot, where a single stationary sample + * has no motion to make the effect visible at all. + */ +icon: string, +/** + * Field painted under the stroke preview, giving the node something to + * transport. + */ +backdrop: PreviewBackdrop, }; + export type PortDef = { name: string, dir: PortDir, wire_type: BrushWireType, /** * Slider min when the port is disconnected (UI metadata only). @@ -263,8 +283,8 @@ exposed: boolean, * neutralizer (`reset_exposed_scrubs`) that targets every * exposed scrub regardless of `preview_value`. * - * Canonical example: `paint.size` (0.1, so a huge brush's - * preview still fits the small cursor mask and the editor + * Canonical example: `brush_settings.size` (0.1, so a huge + * brush's preview still fits the small cursor mask and the editor * preview doesn't redraw on every size scrub). */ preview_value: number | null, @@ -313,7 +333,7 @@ visible_when: [string, Array] | null, * stays "UI hint only, not enforced", and `with_natural_range` is the * separate, explicit opt-in for wire-boundary range mapping. Most * ports declare both with the same numbers; the two diverge for - * over-drag sliders like `paint.size`, where the slider range is + * over-drag sliders like `brush_settings.size`, where the range is * a hint but the wire-side semantics are passthrough. */ natural_range: [number, number] | null, @@ -362,10 +382,10 @@ preview_image: boolean, */ source: boolean, }; -export type PortDir = "Input" | "Output"; - export type BrushWireType = "Scalar" | "Int" | "Bool" | "Vec2" | "Vec4" | "Enum" | "String" | "Curve"; +export type PortDir = "Input" | "Output"; + export type InputValue = boolean | number | number | string | Array<[number, number]> | [number, number] | [number, number, number, number]; export type NodeRegistration = { @@ -376,7 +396,8 @@ type_id: string, /** * UI category for the add-node palette — describes what the node *does*, * not how it executes. Current values: "input", "math", "modulate", - * "color", "shape", "texture", "output", and "internal" (filtered out). + * "color", "shape", "texture", "output". Nothing filters on it; every + * registered node appears in the palette and in the catalog. */ category: string, /** @@ -418,12 +439,13 @@ is_terminal: boolean, */ supports_erase: boolean, /** - * Iconify icon shown in place of baked dab/stroke thumbnails for any - * brush whose graph contains this node. Set by nodes whose output - * depends on existing canvas content — stroking the flat preview - * background renders blank, so the picker shows this icon instead. + * How a preview of any brush containing this node must be staged. Set by + * nodes whose output depends on existing canvas content — over a flat + * preview background they render blank, so the stroke gets a field to + * transport and the dab slot gets a glyph. `None` for a node that makes + * its own marks, which is every node that does not sample the canvas. */ -preview_fallback_icon: string | null, }; +preview_staging: PreviewStaging | null, }; export type BrushSaveReq = { name: string, category: string, }; @@ -441,6 +463,74 @@ export type CanvasDimensionsResp = { width: number, height: number, }; export type CanvasRectResp = { origin_x: number, origin_y: number, width: number, height: number, }; +export type CatalogEntry = { type: string, displayName: string, +/** + * Iconify name, or `None` when the variant deliberately declares no icon + * (veils render a live preview; raster layers always show a thumbnail). + */ +icon: string | null, description: string | null, +/** + * Grouping label within the catalog, for variants that group. + */ +category: string | null, +/** + * Action id this variant is bound to, for variants a hotkey can select. + */ +hotkeyAction: string | null, params: Array, +/** + * Whether this variant declares a + * [`PreviewAnim`](crate::gpu::preview::PreviewAnim) — the one fact behind + * "a rendered preview of it exists". False for the registries whose entries + * are affordances rather than images. + * + * It does **not** promise a *picker* preview. A blend mode declares one and + * has a documentation asset, but is a relation between two images rather + * than an effect over one, so its catalog exports no preview mechanism and + * `start_preview` no-ops for it exactly as it does for an unknown type. + * Whether a catalog can be driven live is + * [`preview_mechanisms`](crate::catalog::preview_mechanisms)' answer, not + * this field's. + */ +supportsPreview: boolean, +/** + * How the browser captures this variant's external frames; voids only. + */ +captureKind: CaptureKind | null, }; + +export type ParamValue = boolean | number | number | string | Array<[number, number]> | [number, number, number, number, number] | [number, number, number] | [number, number] | Array<{ [key in string]: ParamValue }>; + +export type ParamDisplay = { min: string | null, max: string | null, default: string | null, +/** + * The unit suffix alone, for a column header. Empty for unitless values. + */ +unit: string, }; + +export type ParamInfo = { kind: string, name: string, +/** + * Display label. `None` → the UI title-cases `name`. + */ +label: string | null, description: string | null, +/** + * How to render this parameter's editor. One closed set, which both + * `ParamKind` and the settings schema's `WidgetHint` map into: + * `"auto"`, `"numberInput"`, `"icon"`, `"hotkey"`, `"color"`, `"hidden"`. + */ +widget: string, unit: UnitType, min: number | null, max: number | null, default: ParamValue, value: ParamValue | null, +/** + * Enum: `["Label1", "Label2", ...]`. + * Icon: `[["fa6-solid:icon-name", "Label"], ...]`. + */ +options: JsonValue | null, display: ParamDisplay, }; + +export type CaptureKind = "camera" | "display" | "stream"; + +export type Catalog = { id: string, title: string, description: string | null, icon: string | null, +/** + * Presentation order, for catalogs that declare one. Registry catalogs do + * not; settings sections do. + */ +order: number | null, entries: Array, }; + export type ClearSelectionContentsReq = { id: number, }; export type CommitFilterPreviewReq = { node_id: number, filter_type: string, params: JsonValue, }; @@ -463,29 +553,6 @@ export type FillBackgroundReq = { id: number, }; export type FillBackgroundColorReq = { id: number, rgba: [number, number, number, number], }; -export type ParamValue = boolean | number | number | string | Array<[number, number]> | [number, number, number, number, number] | [number, number, number] | [number, number] | Array<{ [key in string]: ParamValue }>; - -export type ParamInfo = { kind: string, name: string, min: number | null, max: number | null, default: ParamValue, value: ParamValue | null, -/** - * Enum: `["Label1", "Label2", ...]`. - * Icon: `[["fa6-solid:icon-name", "Label"], ...]`. - */ -options: JsonValue | null, }; - -export type VeilTypeInfo = { type: string, displayName: string, -/** - * Iconify name shown for this type. Filters carry a per-variant icon so - * each reads distinctly in the Colors menu and the Add Filter Layer picker; - * veils leave it empty (their UI renders a live preview, not an icon). - */ -icon: string, -/** - * One-sentence summary from the registration — picker tooltips, and (for - * filters) folded into the Colors-menu action description where the - * command palette's search indexes it. - */ -description: string, params: Array, }; - export type FlattenNodeReq = { node_id: number, }; export type FlipCanvasReq = { axis: FlipAxis, }; @@ -510,8 +577,6 @@ export type HistogramReq = { id: number, }; export type HitTestVectorObjectReq = { id: number, x: number, y: number, }; -export type LayerKindTypeInfo = { type: string, displayName: string, }; - export type LayerTransformCapabilityReq = { id: number, }; export type ModifierInfo = { id: number, kind: string, name: string, visible: boolean, locked: boolean, @@ -583,8 +648,6 @@ export type MergeDownReq = { source_id: number, }; export type MergeLayersReq = { ids: Array, }; -export type ModifierTypeInfo = { type: string, displayName: string, }; - export type MoveLayerReq = { id: number, target: MoveTarget, }; export type MoveTarget = { "target_type": "before", "target_id": number } | { "target_type": "after", "target_id": number } | { "target_type": "into_top", "target_id": number } | { "target_type": "into_bottom", "target_id": number }; @@ -609,7 +672,9 @@ export type PasteLayerRichReq = { json: string, active_layer_id: number, }; export type PickColorReq = { x: number, y: number, id: number, }; -export type PreviewReq = { kind: string, type: string, }; +export type PreviewReq = { catalog: string, type: string, variant: PreviewVariant, }; + +export type PreviewVariant = "still" | "animated"; export type PreviewFilterReq = { node_id: number, filter_type: string, params: JsonValue, }; @@ -731,8 +796,6 @@ export type TransformCapabilityError = { endpoint: number, operation: PixelTrans export type LayerIdReq = { id: number, }; -export type ToolTypeInfo = { type: string, displayName: string, params: Array, }; - export type UpdateFloatingMatrixReq = { transform: Transform, }; export type Transform = { "mode": "Basic", "data": [number, number, number, number, number, number] } | { "mode": "Perspective", "data": [number, number, number, number, number, number, number, number, number] }; @@ -751,17 +814,6 @@ export type VoidTransformInfoReq = { id: number, }; export type VoidTransformInfoResp = { ox: number, oy: number, w: number, h: number, mode: number, matrix: Array, }; -export type CaptureKind = "camera" | "display" | "stream"; - -export type VoidTypeInfo = { type: string, displayName: string, params: Array, icon: string, supportsPreview: boolean, -/** - * How the browser captures this void's external frames (`"camera"` / - * `"display"`), or absent for procedural voids. The frontend builds a - * `voidType → CaptureKind` map from this to pick `getUserMedia` vs - * `getDisplayMedia` and to drive the generic MediaStream lifecycle. - */ -captureKind: CaptureKind | null, }; - export type RequestKind = | 'active_brush_needs_source' | 'add_filter' @@ -777,7 +829,6 @@ export type RequestKind = | 'apply_mask' | 'begin_stroke' | 'begin_transform' - | 'blend_mode_types' | 'border_selection' | 'brush_active_capabilities' | 'brush_active_dab_preview' @@ -800,6 +851,7 @@ export type RequestKind = | 'brush_graph_set_exposed_port_meta' | 'brush_graph_set_input' | 'brush_graph_set_node_comment' + | 'brush_graph_set_port_range' | 'brush_graph_unexpose_port' | 'brush_graph_validate' | 'brush_import' @@ -820,6 +872,7 @@ export type RequestKind = | 'cancel_floating' | 'canvas_dimensions' | 'canvas_rect' + | 'catalogs' | 'clear_brush_cursor_preview_pose' | 'clear_clone_overlay' | 'clear_overlay' @@ -841,7 +894,6 @@ export type RequestKind = | 'feather_selection' | 'fill_background' | 'fill_background_color' - | 'filter_types' | 'flatten_image' | 'flatten_node' | 'flip_canvas' @@ -860,7 +912,6 @@ export type RequestKind = | 'invert_selection' | 'is_dirty' | 'last_picked_color' - | 'layer_kind_types' | 'layer_transform_capability' | 'layer_tree' | 'list_fonts' @@ -868,7 +919,6 @@ export type RequestKind = | 'mask_to_selection' | 'merge_down' | 'merge_layers' - | 'modifier_types' | 'move_layer' | 'move_layers' | 'move_veil' @@ -942,7 +992,6 @@ export type RequestKind = | 'stroke_to' | 'take_transform_setup_error' | 'text_objects' - | 'tool_types' | 'undo' | 'update_floating_matrix' | 'update_vector_object_transform' @@ -950,9 +999,7 @@ export type RequestKind = | 'update_void_transform' | 'vector_object_info' | 'veil_list' - | 'veil_types' | 'void_transform_info' - | 'void_types' | 'warm_vector_renderer' ; @@ -971,7 +1018,6 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'apply_mask', 'begin_stroke', 'begin_transform', - 'blend_mode_types', 'border_selection', 'brush_active_capabilities', 'brush_active_dab_preview', @@ -994,6 +1040,7 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'brush_graph_set_exposed_port_meta', 'brush_graph_set_input', 'brush_graph_set_node_comment', + 'brush_graph_set_port_range', 'brush_graph_unexpose_port', 'brush_graph_validate', 'brush_import', @@ -1014,6 +1061,7 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'cancel_floating', 'canvas_dimensions', 'canvas_rect', + 'catalogs', 'clear_brush_cursor_preview_pose', 'clear_clone_overlay', 'clear_overlay', @@ -1035,7 +1083,6 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'feather_selection', 'fill_background', 'fill_background_color', - 'filter_types', 'flatten_image', 'flatten_node', 'flip_canvas', @@ -1054,7 +1101,6 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'invert_selection', 'is_dirty', 'last_picked_color', - 'layer_kind_types', 'layer_transform_capability', 'layer_tree', 'list_fonts', @@ -1062,7 +1108,6 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'mask_to_selection', 'merge_down', 'merge_layers', - 'modifier_types', 'move_layer', 'move_layers', 'move_veil', @@ -1136,7 +1181,6 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'stroke_to', 'take_transform_setup_error', 'text_objects', - 'tool_types', 'undo', 'update_floating_matrix', 'update_vector_object_transform', @@ -1144,9 +1188,7 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'update_void_transform', 'vector_object_info', 'veil_list', - 'veil_types', 'void_transform_info', - 'void_types', 'warm_vector_renderer', ] as const; @@ -1173,7 +1215,6 @@ export interface EngineApi { applyMask(req: ApplyMaskReq): void; beginStroke(req: BeginStrokeReq): void; beginTransform(req: BeginTransformReq): Promise; - blendModeTypes(): Promise>; borderSelection(req: BorderSelectionReq): void; brushActiveCapabilities(): Promise; brushActiveDabPreview(): Promise<{ bytes: Uint8Array }>; @@ -1196,6 +1237,7 @@ export interface EngineApi { brushGraphSetExposedPortMeta(req: BrushGraphSetExposedPortMetaReq): Promise<{ graph: JsonValue } | { error: string }>; brushGraphSetInput(req: BrushGraphSetInputReq): Promise<{ graph: JsonValue } | { error: string }>; brushGraphSetNodeComment(req: BrushGraphSetNodeCommentReq): Promise<{ graph: JsonValue } | { error: string }>; + brushGraphSetPortRange(req: BrushGraphSetPortRangeReq): Promise<{ graph: JsonValue } | { error: string }>; brushGraphUnexposePort(req: BrushGraphUnexposePortReq): Promise<{ graph: JsonValue } | { error: string }>; brushGraphValidate(req: BrushGraphJsonReq): Promise; brushImport(bytes: Uint8Array): Promise; @@ -1216,6 +1258,7 @@ export interface EngineApi { cancelFloating(): void; canvasDimensions(): Promise; canvasRect(): Promise; + catalogs(): Promise>; clearBrushCursorPreviewPose(): void; clearCloneOverlay(): void; clearOverlay(): void; @@ -1237,7 +1280,6 @@ export interface EngineApi { featherSelection(req: FeatherSelectionReq): void; fillBackground(req: FillBackgroundReq): void; fillBackgroundColor(req: FillBackgroundColorReq): void; - filterTypes(): Promise>; flattenImage(): Promise; flattenNode(req: FlattenNodeReq): Promise; flipCanvas(req: FlipCanvasReq): void; @@ -1256,7 +1298,6 @@ export interface EngineApi { invertSelection(): void; isDirty(): Promise; lastPickedColor(): Promise<{ bytes: Uint8Array }>; - layerKindTypes(): Promise>; layerTransformCapability(req: LayerTransformCapabilityReq): Promise; layerTree(): Promise>; listFonts(): Promise<{ fonts: string[] }>; @@ -1264,7 +1305,6 @@ export interface EngineApi { maskToSelection(req: MaskToSelectionReq): void; mergeDown(req: MergeDownReq): Promise; mergeLayers(req: MergeLayersReq): Promise; - modifierTypes(): Promise>; moveLayer(req: MoveLayerReq): void; moveLayers(req: MoveLayersReq): Promise; moveVeil(req: MoveVeilReq): void; @@ -1338,7 +1378,6 @@ export interface EngineApi { strokeTo(req: StrokeToReq): void; takeTransformSetupError(): Promise; textObjects(req: LayerIdReq): Promise<{ objects: Array<{ object: number, content: string, font_family: string, size: number, variations: Record, features: Record, letter_spacing: number, word_spacing: number, line_height: number, italic: boolean, align: string, color: [number, number, number, number], box: [number, number] | null }> }>; - toolTypes(): Promise>; undo(): void; updateFloatingMatrix(req: UpdateFloatingMatrixReq): void; updateVectorObjectTransform(req: UpdateVectorObjectTransformReq): void; @@ -1346,9 +1385,7 @@ export interface EngineApi { updateVoidTransform(req: UpdateVoidTransformReq): void; vectorObjectInfo(req: ObjectRefReq): Promise<{ ox: number, oy: number, w: number, h: number, mode: number, matrix: number[] } | null>; veilList(): Promise>; - veilTypes(): Promise>; voidTransformInfo(req: VoidTransformInfoReq): Promise; - voidTypes(): Promise>; warmVectorRenderer(): void; } @@ -1369,7 +1406,6 @@ export function makeApi(t: Transport): EngineApi { applyMask: (req) => t.postFF('apply_mask', req), beginStroke: (req) => t.postFF('begin_stroke', req), beginTransform: (req) => t.request('begin_transform', req), - blendModeTypes: () => t.request('blend_mode_types'), borderSelection: (req) => t.postFF('border_selection', req), brushActiveCapabilities: () => t.request('brush_active_capabilities'), brushActiveDabPreview: () => t.request('brush_active_dab_preview'), @@ -1392,6 +1428,7 @@ export function makeApi(t: Transport): EngineApi { brushGraphSetExposedPortMeta: (req) => t.request('brush_graph_set_exposed_port_meta', req), brushGraphSetInput: (req) => t.request('brush_graph_set_input', req), brushGraphSetNodeComment: (req) => t.request('brush_graph_set_node_comment', req), + brushGraphSetPortRange: (req) => t.request('brush_graph_set_port_range', req), brushGraphUnexposePort: (req) => t.request('brush_graph_unexpose_port', req), brushGraphValidate: (req) => t.request('brush_graph_validate', req), brushImport: (bytes) => t.request('brush_import', {}, bytes), @@ -1412,6 +1449,7 @@ export function makeApi(t: Transport): EngineApi { cancelFloating: () => t.postFF('cancel_floating'), canvasDimensions: () => t.request('canvas_dimensions'), canvasRect: () => t.request('canvas_rect'), + catalogs: () => t.request('catalogs'), clearBrushCursorPreviewPose: () => t.postFF('clear_brush_cursor_preview_pose'), clearCloneOverlay: () => t.postFF('clear_clone_overlay'), clearOverlay: () => t.postFF('clear_overlay'), @@ -1433,7 +1471,6 @@ export function makeApi(t: Transport): EngineApi { featherSelection: (req) => t.postFF('feather_selection', req), fillBackground: (req) => t.postFF('fill_background', req), fillBackgroundColor: (req) => t.postFF('fill_background_color', req), - filterTypes: () => t.request('filter_types'), flattenImage: () => t.request('flatten_image'), flattenNode: (req) => t.request('flatten_node', req), flipCanvas: (req) => t.postFF('flip_canvas', req), @@ -1452,7 +1489,6 @@ export function makeApi(t: Transport): EngineApi { invertSelection: () => t.postFF('invert_selection'), isDirty: () => t.request('is_dirty'), lastPickedColor: () => t.request('last_picked_color'), - layerKindTypes: () => t.request('layer_kind_types'), layerTransformCapability: (req) => t.request('layer_transform_capability', req), layerTree: () => t.request('layer_tree'), listFonts: () => t.request('list_fonts'), @@ -1460,7 +1496,6 @@ export function makeApi(t: Transport): EngineApi { maskToSelection: (req) => t.postFF('mask_to_selection', req), mergeDown: (req) => t.request('merge_down', req), mergeLayers: (req) => t.request('merge_layers', req), - modifierTypes: () => t.request('modifier_types'), moveLayer: (req) => t.postFF('move_layer', req), moveLayers: (req) => t.request('move_layers', req), moveVeil: (req) => t.postFF('move_veil', req), @@ -1534,7 +1569,6 @@ export function makeApi(t: Transport): EngineApi { strokeTo: (req) => t.postFF('stroke_to', req), takeTransformSetupError: () => t.request('take_transform_setup_error'), textObjects: (req) => t.request('text_objects', req), - toolTypes: () => t.request('tool_types'), undo: () => t.postFF('undo'), updateFloatingMatrix: (req) => t.postFF('update_floating_matrix', req), updateVectorObjectTransform: (req) => t.postFF('update_vector_object_transform', req), @@ -1542,9 +1576,7 @@ export function makeApi(t: Transport): EngineApi { updateVoidTransform: (req) => t.postFF('update_void_transform', req), vectorObjectInfo: (req) => t.request('vector_object_info', req), veilList: () => t.request('veil_list'), - veilTypes: () => t.request('veil_types'), voidTransformInfo: (req) => t.request('void_transform_info', req), - voidTypes: () => t.request('void_types'), warmVectorRenderer: () => t.postFF('warm_vector_renderer'), }; } diff --git a/frontend/src/icons/bundle.generated.ts b/frontend/src/icons/bundle.generated.ts index 7dbf29c3..3f902362 100644 --- a/frontend/src/icons/bundle.generated.ts +++ b/frontend/src/icons/bundle.generated.ts @@ -2,17 +2,16 @@ // Regenerated automatically by the icon-bundle Vite plugin (dev + build) and by // `npm run gen:icons`. Derived from the Iconify icon-name string literals found // in the source, registered for offline rendering. -// 118 icon(s) across 12 collection(s). +// 119 icon(s) across 11 collection(s). /* eslint-disable */ // @ts-nocheck import { addCollection } from '@iconify/svelte/dist/offline-functions.js'; addCollection({"prefix":"at-icons","icons":{"text":{"body":"","left":0.969,"top":0.969,"width":14.063,"height":14.063}},"lastModified":1784526570}); -addCollection({"prefix":"boxicons","icons":{"square-dashed":{"body":"","left":2.906,"top":2.953,"width":18.188,"height":18.094}},"lastModified":1771495506,"width":24,"height":24}); +addCollection({"prefix":"boxicons","icons":{"gradient":{"body":"","left":2.953,"top":2.953,"width":18.094,"height":18.094},"square-dashed":{"body":"","left":2.906,"top":2.953,"width":18.188,"height":18.094}},"lastModified":1771495506,"width":24,"height":24}); addCollection({"prefix":"fa6-brands","icons":{"github":{"body":"","width":496,"left":0,"top":7,"height":486}},"lastModified":1734421834,"width":448,"height":512}); -addCollection({"prefix":"fa6-solid","icons":{"anchor":{"body":"","width":576,"left":0,"top":0,"height":511.875},"arrow-right-arrow-left":{"body":"","width":448,"left":0,"top":0,"height":512},"arrow-up-right-from-square":{"body":"","left":0,"top":0,"width":512,"height":512},"arrows-down-to-line":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"arrows-left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"arrows-up-down":{"body":"","width":258,"left":31,"top":0,"height":512},"arrows-up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"ban":{"body":"","left":0,"top":0,"width":512,"height":512},"bars":{"body":"","width":448,"left":0,"top":63,"height":386},"book":{"body":"","width":448,"left":0,"top":0,"height":512},"border-all":{"body":"","width":448,"left":0,"top":31,"height":450},"chart-line":{"body":"","left":0,"top":31,"width":512,"height":450},"check":{"body":"","width":448,"left":0,"top":95,"height":322},"chevron-down":{"body":"","left":31,"top":159,"width":450,"height":258},"chevron-right":{"body":"","width":257,"left":63,"top":31,"height":450},"chevron-up":{"body":"","left":31,"top":95,"width":450,"height":258},"circle":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-dot":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-half-stroke":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-info":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-notch":{"body":"","left":0,"top":8,"width":512,"height":504},"clipboard":{"body":"","width":384,"left":0,"top":0,"height":512},"clock-rotate-left":{"body":"","left":0,"top":0,"width":512,"height":512},"clone":{"body":"","left":0,"top":0,"width":512,"height":512},"compress":{"body":"","width":448,"left":0,"top":31,"height":450},"copy":{"body":"","width":448,"left":0,"top":0,"height":512},"crop-simple":{"body":"","left":0,"top":0,"width":512,"height":512},"crosshairs":{"body":"","left":0,"top":0,"width":512,"height":512},"diagram-project":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"dice":{"body":"","width":628.75,"left":11.25,"top":11.25,"height":501.25},"display":{"body":"","width":576,"left":0,"top":0,"height":511.875},"down-left-and-up-right-to-center":{"body":"","left":0,"top":0,"width":512,"height":512},"droplet":{"body":"","width":384,"left":0,"top":0,"height":512},"droplet-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"eraser":{"body":"","width":508.5,"left":37.125,"top":37.125,"height":444.375},"expand":{"body":"","width":448,"left":0,"top":31,"height":450},"eye":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"eye-dropper":{"body":"","left":0,"top":0,"width":512,"height":512},"eye-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"feather":{"body":"","left":15,"top":0,"width":497,"height":512},"file":{"body":"","width":384,"left":0,"top":0,"height":512},"file-export":{"body":"","width":576,"left":0,"top":0,"height":511.875},"fill-drip":{"body":"","width":574.875,"left":1.125,"top":0,"height":511.875},"floppy-disk":{"body":"","width":448,"left":0,"top":31,"height":450},"folder":{"body":"","left":0,"top":31,"width":512,"height":450},"folder-open":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"folder-plus":{"body":"","left":0,"top":31,"width":512,"height":450},"gauge-high":{"body":"","left":0,"top":0,"width":512,"height":512},"gear":{"body":"","left":13,"top":0,"width":486,"height":512},"globe":{"body":"","left":0,"top":0,"width":512,"height":512},"grip-lines-vertical":{"body":"","width":192,"left":0,"top":31,"height":450},"grip-vertical":{"body":"","width":320,"left":0,"top":31,"height":450},"image":{"body":"","left":0,"top":31,"width":512,"height":450},"keyboard":{"body":"","width":576,"left":0,"top":63,"height":387},"layer-group":{"body":"","width":515.25,"left":30.375,"top":0,"height":511.875},"left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"link":{"body":"","width":607.5,"left":16.25,"top":20,"height":472.5},"link-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"lock":{"body":"","width":448,"left":0,"top":0,"height":512},"lock-open":{"body":"","width":576,"left":0,"top":0,"height":511.875},"magnifying-glass":{"body":"","left":0,"top":0,"width":512,"height":512},"maximize":{"body":"","left":31,"top":31,"width":450,"height":450},"minus":{"body":"","width":418,"left":15,"top":223,"height":66},"paint-roller":{"body":"","left":0,"top":0,"width":512,"height":512},"paintbrush":{"body":"","width":545.625,"left":30.375,"top":0,"height":511.875},"palette":{"body":"","left":0,"top":0,"width":512,"height":512},"paste":{"body":"","left":0,"top":0,"width":512,"height":512},"pen":{"body":"","left":0,"top":0,"width":512,"height":512},"pen-nib":{"body":"","left":1,"top":1,"width":510,"height":510},"pen-to-square":{"body":"","left":0,"top":4,"width":508,"height":508},"plus":{"body":"","width":418,"left":15,"top":47,"height":418},"right-left":{"body":"","left":0,"top":0,"width":512,"height":512},"rotate":{"body":"","left":15,"top":31,"width":482,"height":450},"rotate-left":{"body":"","left":15,"top":31,"width":466,"height":450},"rotate-right":{"body":"","left":31,"top":31,"width":466,"height":450},"ruler-horizontal":{"body":"","width":640,"left":0,"top":126.25,"height":260},"scissors":{"body":"","left":0,"top":0,"width":506,"height":512},"screwdriver-wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"sliders":{"body":"","left":0,"top":15,"width":512,"height":482},"square-plus":{"body":"","width":448,"left":0,"top":31,"height":450},"stopwatch":{"body":"","width":418,"left":15,"top":0,"height":512},"sun":{"body":"","left":0,"top":0,"width":512,"height":512},"thumbtack":{"body":"","width":384,"left":0,"top":0,"height":512},"trash":{"body":"","width":448,"left":0,"top":0,"height":512},"triangle-exclamation":{"body":"","left":0,"top":31,"width":512,"height":450},"up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"up-right-and-down-left-from-center":{"body":"","left":0,"top":0,"width":512,"height":512},"vector-square":{"body":"","width":448,"left":0,"top":31,"height":450},"video":{"body":"","width":576,"left":0,"top":63,"height":387},"wand-magic":{"body":"","left":0,"top":0,"width":512,"height":512},"wand-magic-sparkles":{"body":"","width":545.625,"left":0,"top":0,"height":511.875},"wave-square":{"body":"","width":640,"left":0,"top":30,"height":452.5},"wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"xmark":{"body":"","width":322,"left":31,"top":95,"height":322}},"lastModified":1732030010,"width":512,"height":512}); +addCollection({"prefix":"fa6-solid","icons":{"anchor":{"body":"","width":576,"left":0,"top":0,"height":511.875},"arrow-right-arrow-left":{"body":"","width":448,"left":0,"top":0,"height":512},"arrow-up-right-from-square":{"body":"","left":0,"top":0,"width":512,"height":512},"arrows-down-to-line":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"arrows-left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"arrows-up-down":{"body":"","width":258,"left":31,"top":0,"height":512},"arrows-up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"ban":{"body":"","left":0,"top":0,"width":512,"height":512},"bars":{"body":"","width":448,"left":0,"top":63,"height":386},"book":{"body":"","width":448,"left":0,"top":0,"height":512},"border-all":{"body":"","width":448,"left":0,"top":31,"height":450},"chart-line":{"body":"","left":0,"top":31,"width":512,"height":450},"check":{"body":"","width":448,"left":0,"top":95,"height":322},"chevron-down":{"body":"","left":31,"top":159,"width":450,"height":258},"chevron-right":{"body":"","width":257,"left":63,"top":31,"height":450},"chevron-up":{"body":"","left":31,"top":95,"width":450,"height":258},"circle":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-dot":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-half-stroke":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-info":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-notch":{"body":"","left":0,"top":8,"width":512,"height":504},"clipboard":{"body":"","width":384,"left":0,"top":0,"height":512},"clock-rotate-left":{"body":"","left":0,"top":0,"width":512,"height":512},"clone":{"body":"","left":0,"top":0,"width":512,"height":512},"compress":{"body":"","width":448,"left":0,"top":31,"height":450},"copy":{"body":"","width":448,"left":0,"top":0,"height":512},"crop-simple":{"body":"","left":0,"top":0,"width":512,"height":512},"crosshairs":{"body":"","left":0,"top":0,"width":512,"height":512},"diagram-project":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"dice":{"body":"","width":628.75,"left":11.25,"top":11.25,"height":501.25},"display":{"body":"","width":576,"left":0,"top":0,"height":511.875},"down-left-and-up-right-to-center":{"body":"","left":0,"top":0,"width":512,"height":512},"droplet":{"body":"","width":384,"left":0,"top":0,"height":512},"droplet-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"eraser":{"body":"","width":508.5,"left":37.125,"top":37.125,"height":444.375},"expand":{"body":"","width":448,"left":0,"top":31,"height":450},"eye":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"eye-dropper":{"body":"","left":0,"top":0,"width":512,"height":512},"eye-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"feather":{"body":"","left":15,"top":0,"width":497,"height":512},"file":{"body":"","width":384,"left":0,"top":0,"height":512},"file-export":{"body":"","width":576,"left":0,"top":0,"height":511.875},"fill-drip":{"body":"","width":574.875,"left":1.125,"top":0,"height":511.875},"floppy-disk":{"body":"","width":448,"left":0,"top":31,"height":450},"folder":{"body":"","left":0,"top":31,"width":512,"height":450},"folder-open":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"folder-plus":{"body":"","left":0,"top":31,"width":512,"height":450},"gauge-high":{"body":"","left":0,"top":0,"width":512,"height":512},"gear":{"body":"","left":13,"top":0,"width":486,"height":512},"globe":{"body":"","left":0,"top":0,"width":512,"height":512},"grip-lines-vertical":{"body":"","width":192,"left":0,"top":31,"height":450},"grip-vertical":{"body":"","width":320,"left":0,"top":31,"height":450},"image":{"body":"","left":0,"top":31,"width":512,"height":450},"keyboard":{"body":"","width":576,"left":0,"top":63,"height":387},"layer-group":{"body":"","width":515.25,"left":30.375,"top":0,"height":511.875},"left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"link":{"body":"","width":607.5,"left":16.25,"top":20,"height":472.5},"link-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"lock":{"body":"","width":448,"left":0,"top":0,"height":512},"lock-open":{"body":"","width":576,"left":0,"top":0,"height":511.875},"magnifying-glass":{"body":"","left":0,"top":0,"width":512,"height":512},"mask":{"body":"","width":576,"left":0,"top":63,"height":387},"maximize":{"body":"","left":31,"top":31,"width":450,"height":450},"minus":{"body":"","width":418,"left":15,"top":223,"height":66},"paint-roller":{"body":"","left":0,"top":0,"width":512,"height":512},"paintbrush":{"body":"","width":545.625,"left":30.375,"top":0,"height":511.875},"palette":{"body":"","left":0,"top":0,"width":512,"height":512},"paste":{"body":"","left":0,"top":0,"width":512,"height":512},"pen":{"body":"","left":0,"top":0,"width":512,"height":512},"pen-nib":{"body":"","left":1,"top":1,"width":510,"height":510},"pen-to-square":{"body":"","left":0,"top":4,"width":508,"height":508},"plus":{"body":"","width":418,"left":15,"top":47,"height":418},"right-left":{"body":"","left":0,"top":0,"width":512,"height":512},"rotate":{"body":"","left":15,"top":31,"width":482,"height":450},"rotate-left":{"body":"","left":15,"top":31,"width":466,"height":450},"rotate-right":{"body":"","left":31,"top":31,"width":466,"height":450},"ruler-horizontal":{"body":"","width":640,"left":0,"top":126.25,"height":260},"scissors":{"body":"","left":0,"top":0,"width":506,"height":512},"screwdriver-wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"sliders":{"body":"","left":0,"top":15,"width":512,"height":482},"square-plus":{"body":"","width":448,"left":0,"top":31,"height":450},"stopwatch":{"body":"","width":418,"left":15,"top":0,"height":512},"sun":{"body":"","left":0,"top":0,"width":512,"height":512},"thumbtack":{"body":"","width":384,"left":0,"top":0,"height":512},"trash":{"body":"","width":448,"left":0,"top":0,"height":512},"triangle-exclamation":{"body":"","left":0,"top":31,"width":512,"height":450},"up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"up-right-and-down-left-from-center":{"body":"","left":0,"top":0,"width":512,"height":512},"vector-square":{"body":"","width":448,"left":0,"top":31,"height":450},"video":{"body":"","width":576,"left":0,"top":63,"height":387},"wand-magic":{"body":"","left":0,"top":0,"width":512,"height":512},"wand-magic-sparkles":{"body":"","width":545.625,"left":0,"top":0,"height":511.875},"wave-square":{"body":"","width":640,"left":0,"top":30,"height":452.5},"wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"xmark":{"body":"","width":322,"left":31,"top":95,"height":322}},"lastModified":1732030010,"width":512,"height":512}); addCollection({"prefix":"file-icons","icons":{"blender":{"body":"","left":0,"top":47,"width":512,"height":418}},"lastModified":1721244157,"width":512,"height":512}); -addCollection({"prefix":"local","icons":{"gradient":{"body":"\n \n \n \n \n \n ","width":14.313,"height":14.313,"left":0.844,"top":0.844}}}); addCollection({"prefix":"lucide","icons":{"circle-dashed":{"body":"","left":0.938,"top":0.938,"width":22.125,"height":22.125},"triangle-dashed":{"body":"","left":0.938,"top":1.922,"width":22.125,"height":20.156}},"lastModified":1784351942,"width":24,"height":24}); addCollection({"prefix":"lucide-lab","icons":{"venn":{"body":"","left":0.938,"top":4.922,"width":22.125,"height":14.156}},"lastModified":1731133495,"width":24,"height":24}); addCollection({"prefix":"material-symbols","icons":{"curtains-rounded":{"body":"","left":1.922,"top":2.953,"width":20.156,"height":18.094}},"lastModified":1784526060,"width":24,"height":24}); @@ -20,4 +19,4 @@ addCollection({"prefix":"mdi","icons":{"blur":{"body":"","left":0,"top":0.967,"width":15,"height":13.066},"mask-on":{"body":"","left":0,"top":0.967,"width":15,"height":13.066}},"lastModified":1766212494,"width":15,"height":15}); addCollection({"prefix":"tabler","icons":{"camera":{"body":"","left":1.922,"top":2.953,"width":20.156,"height":18.094},"flip-horizontal":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156},"galaxy":{"body":"","left":3.141,"top":1.922,"width":17.719,"height":20.156},"lasso":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156},"perspective":{"body":"","left":3.938,"top":3.094,"width":16.125,"height":17.813},"ripple":{"body":"","left":1.922,"top":4.453,"width":20.156,"height":15.516},"screen-share":{"body":"","left":1.922,"top":2.953,"width":20.156,"height":18.094},"test":{"body":"","hidden":true,"left":1.922,"top":1.922,"width":20.156,"height":20.156},"vector":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156}},"lastModified":1784526686,"width":24,"height":24}); -export const BUNDLED_ICON_NAMES = ["at-icons:text","boxicons:square-dashed","fa6-brands:github","fa6-solid:anchor","fa6-solid:angles-left-right","fa6-solid:arrow-right-arrow-left","fa6-solid:arrow-up-right-from-square","fa6-solid:arrows-down-to-line","fa6-solid:arrows-left-right","fa6-solid:arrows-up-down","fa6-solid:arrows-up-down-left-right","fa6-solid:ban","fa6-solid:bars","fa6-solid:book","fa6-solid:border-all","fa6-solid:chart-line","fa6-solid:check","fa6-solid:chevron-down","fa6-solid:chevron-right","fa6-solid:chevron-up","fa6-solid:circle","fa6-solid:circle-dot","fa6-solid:circle-half-stroke","fa6-solid:circle-info","fa6-solid:circle-notch","fa6-solid:clipboard","fa6-solid:clock-rotate-left","fa6-solid:clone","fa6-solid:compress","fa6-solid:copy","fa6-solid:crop-simple","fa6-solid:crosshairs","fa6-solid:diagram-project","fa6-solid:dice","fa6-solid:display","fa6-solid:down-left-and-up-right-to-center","fa6-solid:droplet","fa6-solid:droplet-slash","fa6-solid:eraser","fa6-solid:expand","fa6-solid:eye","fa6-solid:eye-dropper","fa6-solid:eye-slash","fa6-solid:feather","fa6-solid:file","fa6-solid:file-export","fa6-solid:fill-drip","fa6-solid:floppy-disk","fa6-solid:folder","fa6-solid:folder-open","fa6-solid:folder-plus","fa6-solid:gauge-high","fa6-solid:gear","fa6-solid:globe","fa6-solid:grip-lines-vertical","fa6-solid:grip-vertical","fa6-solid:icon-name","fa6-solid:image","fa6-solid:keyboard","fa6-solid:layer-group","fa6-solid:left-right","fa6-solid:link","fa6-solid:link-slash","fa6-solid:lock","fa6-solid:lock-open","fa6-solid:magnifying-glass","fa6-solid:maximize","fa6-solid:minus","fa6-solid:paint-roller","fa6-solid:paintbrush","fa6-solid:palette","fa6-solid:paste","fa6-solid:pen","fa6-solid:pen-nib","fa6-solid:pen-to-square","fa6-solid:plus","fa6-solid:right-left","fa6-solid:rotate","fa6-solid:rotate-left","fa6-solid:rotate-right","fa6-solid:ruler-horizontal","fa6-solid:scissors","fa6-solid:screwdriver-wrench","fa6-solid:sliders","fa6-solid:square-plus","fa6-solid:stopwatch","fa6-solid:sun","fa6-solid:thumbtack","fa6-solid:trash","fa6-solid:triangle-exclamation","fa6-solid:up-down-left-right","fa6-solid:up-right-and-down-left-from-center","fa6-solid:vector-square","fa6-solid:video","fa6-solid:wand-magic","fa6-solid:wand-magic-sparkles","fa6-solid:wave-square","fa6-solid:wrench","fa6-solid:xmark","file-icons:blender","local:gradient","lucide-lab:venn","lucide:circle-dashed","lucide:triangle-dashed","material-symbols:curtains-rounded","mdi:blur","mdi:gesture-swipe","radix-icons:mask-off","radix-icons:mask-on","tabler:camera","tabler:flip-horizontal","tabler:galaxy","tabler:lasso","tabler:perspective","tabler:ripple","tabler:screen-share","tabler:test","tabler:vector"]; +export const BUNDLED_ICON_NAMES = ["at-icons:text","boxicons:gradient","boxicons:square-dashed","fa6-brands:github","fa6-solid:anchor","fa6-solid:angles-left-right","fa6-solid:arrow-right-arrow-left","fa6-solid:arrow-up-right-from-square","fa6-solid:arrows-down-to-line","fa6-solid:arrows-left-right","fa6-solid:arrows-up-down","fa6-solid:arrows-up-down-left-right","fa6-solid:ban","fa6-solid:bars","fa6-solid:book","fa6-solid:border-all","fa6-solid:chart-line","fa6-solid:check","fa6-solid:chevron-down","fa6-solid:chevron-right","fa6-solid:chevron-up","fa6-solid:circle","fa6-solid:circle-dot","fa6-solid:circle-half-stroke","fa6-solid:circle-info","fa6-solid:circle-notch","fa6-solid:clipboard","fa6-solid:clock-rotate-left","fa6-solid:clone","fa6-solid:compress","fa6-solid:copy","fa6-solid:crop-simple","fa6-solid:crosshairs","fa6-solid:diagram-project","fa6-solid:dice","fa6-solid:display","fa6-solid:down-left-and-up-right-to-center","fa6-solid:droplet","fa6-solid:droplet-slash","fa6-solid:eraser","fa6-solid:expand","fa6-solid:eye","fa6-solid:eye-dropper","fa6-solid:eye-slash","fa6-solid:feather","fa6-solid:file","fa6-solid:file-export","fa6-solid:fill-drip","fa6-solid:floppy-disk","fa6-solid:folder","fa6-solid:folder-open","fa6-solid:folder-plus","fa6-solid:gauge-high","fa6-solid:gear","fa6-solid:globe","fa6-solid:grip-lines-vertical","fa6-solid:grip-vertical","fa6-solid:icon-name","fa6-solid:image","fa6-solid:keyboard","fa6-solid:layer-group","fa6-solid:left-right","fa6-solid:link","fa6-solid:link-slash","fa6-solid:lock","fa6-solid:lock-open","fa6-solid:magnifying-glass","fa6-solid:mask","fa6-solid:maximize","fa6-solid:minus","fa6-solid:paint-roller","fa6-solid:paintbrush","fa6-solid:palette","fa6-solid:paste","fa6-solid:pen","fa6-solid:pen-nib","fa6-solid:pen-to-square","fa6-solid:plus","fa6-solid:right-left","fa6-solid:rotate","fa6-solid:rotate-left","fa6-solid:rotate-right","fa6-solid:ruler-horizontal","fa6-solid:scissors","fa6-solid:screwdriver-wrench","fa6-solid:sliders","fa6-solid:square-plus","fa6-solid:stopwatch","fa6-solid:sun","fa6-solid:thumbtack","fa6-solid:trash","fa6-solid:triangle-exclamation","fa6-solid:up-down-left-right","fa6-solid:up-right-and-down-left-from-center","fa6-solid:vector-square","fa6-solid:video","fa6-solid:wand-magic","fa6-solid:wand-magic-sparkles","fa6-solid:wave-square","fa6-solid:wrench","fa6-solid:xmark","file-icons:blender","lucide-lab:venn","lucide:circle-dashed","lucide:triangle-dashed","material-symbols:curtains-rounded","mdi:blur","mdi:gesture-swipe","radix-icons:mask-off","radix-icons:mask-on","tabler:camera","tabler:flip-horizontal","tabler:galaxy","tabler:lasso","tabler:perspective","tabler:ripple","tabler:screen-share","tabler:test","tabler:vector"]; diff --git a/frontend/src/icons/svg/gradient.svg b/frontend/src/icons/svg/gradient.svg deleted file mode 100644 index dad9d5c6..00000000 --- a/frontend/src/icons/svg/gradient.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/lib/__tests__/scrubDrag.test.ts b/frontend/src/lib/__tests__/scrubDrag.test.ts new file mode 100644 index 00000000..152fac39 --- /dev/null +++ b/frontend/src/lib/__tests__/scrubDrag.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest'; +import { beginScrubDrag } from '../scrubDrag'; + +/** A drag harness whose value is just the x coordinate, so assertions read + * directly as pointer positions. */ +function harness() { + const onPreview = vi.fn(); + const onCommit = vi.fn(); + const onFinish = vi.fn(); + const drag = beginScrubDrag({ + toValue: (clientX) => clientX, + onPreview, + onCommit, + onFinish, + }); + return { drag, onPreview, onCommit, onFinish }; +} + +describe('beginScrubDrag', () => { + it('previews every move but commits once, on release', () => { + const { drag, onPreview, onCommit } = harness(); + + for (const x of [10, 20, 30, 40, 50]) drag.move(x, 0); + expect(onPreview).toHaveBeenCalledTimes(5); + expect(onCommit).not.toHaveBeenCalled(); + + drag.end(); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith(50); + }); + + it('commits nothing when the pointer never moved', () => { + const { drag, onCommit, onFinish } = harness(); + drag.end(); + expect(onCommit).not.toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalledTimes(1); + }); + + it('commits the previewed value when capture is lost mid-drag', () => { + // `lostpointercapture` routes to the same `end`. Discarding here would + // leave the caller's local state showing a value never committed. + const { drag, onCommit } = harness(); + drag.move(10, 0); + drag.move(25, 0); + drag.end(); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith(25); + }); + + it('is idempotent — pointerup and lostpointercapture both landing commit once', () => { + const { drag, onCommit, onFinish } = harness(); + drag.move(15, 0); + drag.end(); + drag.end(); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onFinish).toHaveBeenCalledTimes(1); + }); + + it('ignores moves after the drag has ended', () => { + const { drag, onPreview, onCommit } = harness(); + drag.move(15, 0); + drag.end(); + drag.move(99, 0); + expect(onPreview).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith(15); + }); + + it('runs onFinish exactly once so paired acquire/release stays balanced', () => { + const { drag, onFinish } = harness(); + drag.move(5, 0); + drag.end(); + drag.end(); + expect(onFinish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/lib/scrubDrag.ts b/frontend/src/lib/scrubDrag.ts new file mode 100644 index 00000000..1b87eabd --- /dev/null +++ b/frontend/src/lib/scrubDrag.ts @@ -0,0 +1,58 @@ +// Drag lifecycle for value scrubs: preview locally while the pointer moves, +// commit once when it's released. Kept free of DOM so it can be unit-tested +// headlessly (vitest runs in node — no `window`), and free of app state so the +// caller decides what a preview and a commit mean. +// +// The intermediate values of a scrub are transient session state. Sending each +// one to the engine makes every frame of a gesture a committed mutation, which +// recompiles the brush graph and re-derives its previews for values the user is +// only passing through. + +export interface ScrubDragOptions { + /** Map a pointer position to the value it represents. */ + toValue: (clientX: number, clientY: number) => number; + /** Called on every move with the value under the pointer. Local only. */ + onPreview: (value: number) => void; + /** Called at most once, with the last previewed value. */ + onCommit: (value: number) => void; + /** Called exactly once when the drag finishes, however it finishes — the + * hook for releasing whatever the caller acquired at pointerdown. */ + onFinish?: () => void; +} + +export interface ScrubDrag { + /** Preview the value at this pointer position. */ + move: (clientX: number, clientY: number) => void; + /** Finish the drag, committing the last previewed value. Idempotent, so + * `pointerup` and `lostpointercapture` can both route here. */ + end: () => void; +} + +/** + * Start a scrub drag. Nothing is committed until {@link ScrubDrag.end}, and a + * drag that never moved commits nothing — seed it with an immediate `move` if + * the gesture should take effect from the pointerdown position (a slider track + * that jumps to the click). + * + * A drag whose pointer capture is lost mid-gesture still commits: the user has + * already seen the previewed value, so committing is what keeps the caller's + * local state and the engine in agreement. + */ +export function beginScrubDrag(options: ScrubDragOptions): ScrubDrag { + let previewed: number | null = null; + let finished = false; + + return { + move(clientX: number, clientY: number) { + if (finished) return; + previewed = options.toValue(clientX, clientY); + options.onPreview(previewed); + }, + end() { + if (finished) return; + finished = true; + if (previewed !== null) options.onCommit(previewed); + options.onFinish?.(); + }, + }; +} diff --git a/frontend/src/state/__tests__/toolGlyph.test.ts b/frontend/src/state/__tests__/toolGlyph.test.ts new file mode 100644 index 00000000..d4c7e5aa --- /dev/null +++ b/frontend/src/state/__tests__/toolGlyph.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { DarklyInstance } from '../app.svelte'; +import { toolRegistry } from '../../tools/registry'; +import { brushSession } from '../../tools/brush.svelte'; + +// A tool's glyph is registry metadata and arrives in the `tools` catalog from +// Rust. A descriptor may override it only when the glyph tracks live session +// state — the brush swaps to the eraser icon while erase mode is on, which a +// static registration cannot express. `toolGlyph` is the single place that +// precedence is decided, so this pins both directions of it. + +/** Stand-in for the catalog the engine would deliver at startup. */ +function withToolsCatalog(inst: DarklyInstance, entries: Array<[string, string | null]>) { + inst.catalogs = { + tools: { + id: 'tools', + title: 'Tools', + description: null, + icon: null, + order: null, + entries: entries.map(([type, icon]) => ({ + type, + displayName: type, + icon, + description: null, + category: null, + hotkeyAction: null, + params: [], + supportsPreview: false, + captureKind: null, + })), + }, + } as never; +} + +describe('toolGlyph', () => { + beforeAll(async () => { + await import('../../tools/index'); // side effect: populates toolRegistry + }); + + it('uses the registry icon for a tool that declares no override', () => { + const inst = new DarklyInstance(); + withToolsCatalog(inst, [['fill', 'fa6-solid:fill-drip']]); + // The fill descriptor carries no `icon` — its glyph is Rust's. + expect(toolRegistry.get('fill')?.icon).toBeUndefined(); + expect(inst.toolGlyph('fill')).toBe('fa6-solid:fill-drip'); + }); + + it("prefers the brush's session-dependent override over the registry icon", () => { + const inst = new DarklyInstance(); + withToolsCatalog(inst, [['brush', 'fa6-solid:paintbrush']]); + + const wasErasing = brushSession.eraseMode; + try { + brushSession.eraseMode = false; + expect(inst.toolGlyph('brush')).toBe('fa6-solid:paintbrush'); + + // The override is what makes the toolbar button a mode indicator; + // the registry icon must not win here. + brushSession.eraseMode = true; + expect(inst.toolGlyph('brush')).toBe('fa6-solid:eraser'); + } finally { + brushSession.eraseMode = wasErasing; + } + }); + + it('falls back to a generic glyph before the catalog has loaded', () => { + const inst = new DarklyInstance(); + expect(inst.toolGlyph('fill')).toBe('fa6-solid:wrench'); + }); +}); diff --git a/frontend/src/state/app.svelte.ts b/frontend/src/state/app.svelte.ts index a34acf35..f610c0c3 100644 --- a/frontend/src/state/app.svelte.ts +++ b/frontend/src/state/app.svelte.ts @@ -1,8 +1,9 @@ import { reportEngineError, type Engine, type EngineState } from '../engine/protocol'; -import type { JsonValue } from '../engine/protocol_gen'; +import type { Catalog, CatalogEntry, JsonValue } from '../engine/protocol_gen'; import type { SaveBundle } from '../storage/saveDocument'; import { compute_view_matrices } from '../../wasm/pkg/darkly_wasm'; import { toolRegistry, type Tool } from '../tools/registry'; +import { tooltipForAction } from '../config/store.svelte'; import { pollPick } from '../tools/color_pick_sync'; import { SessionEngine, runHook } from '../tools/tool_session'; import { tickColorPickerCursor } from '../tools/colorpicker_cursor'; @@ -210,99 +211,76 @@ export class DarklyInstance { * activeToolId. */ lastToolByCluster = $state>({}); - // Registry-backed display-name lookups. Each map is populated once at - // startup from the matching `*_types()` WASM query (see `loadRegistries`). - // Per-instance payloads (LayerInfo, VeilInfo, ModifierInfo, etc.) carry - // only the stable `type_id`; UI code resolves the human-readable label - // through these maps — there is no second copy of the display string. - toolDisplayNames = $state>({}); - veilDisplayNames = $state>({}); - voidDisplayNames = $state>({}); + /** Every registry the Rust core declares, keyed by catalog id ("filters", + * "veils", "tools", …). Fetched once at startup (see `loadRegistries`). + * Per-instance payloads (LayerInfo, VeilInfo, …) carry only the stable + * `type_id`; UI code resolves the human-readable label and the icon + * through here, so there is no second copy of either. */ + catalogs = $state>({}); + /** `voidType → CaptureKind` for voids backed by a browser MediaStream - * (camera / screenshare). Built from `void_types` in `loadRegistries`; - * procedural voids are absent. Drives which `MediaDevices` API to call and - * is the single source of truth for "is this a stream-backed void?" across - * the reconciler, picker, and properties panel. */ + * (camera / screenshare). Built from the `voids` catalog in + * `loadRegistries`; procedural voids are absent. Drives which + * `MediaDevices` API to call and is the single source of truth for "is + * this a stream-backed void?" across the reconciler, picker, and + * properties panel. */ voidCaptureKind = $state>(new Map()); - blendModeDisplayNames = $state>({}); - modifierDisplayNames = $state>({}); - layerKindDisplayNames = $state>({}); - - /** Registered destructive color-filter types (invert, …), fetched once - * at startup. Drives the dynamic, auto-discovered Colors-menu actions in - * `registerActions` — a new filter in the Rust core surfaces a menu - * entry with zero frontend edits. */ - filterTypes = $state< - Array<{ - type: string; - displayName: string; - icon: string; - description: string; - params?: unknown[]; - }> - >([]); - - toolDisplayName(id: string): string { - return this.toolDisplayNames[id] ?? id; - } - veilDisplayName(id: string): string { - return this.veilDisplayNames[id] ?? id; - } - voidDisplayName(id: string): string { - return this.voidDisplayNames[id] ?? id; + + /** Entries of one catalog, or an empty array when it is unknown. */ + entries(catalogId: string): CatalogEntry[] { + return this.catalogs[catalogId]?.entries ?? []; } - blendModeDisplayName(id: string): string { - return this.blendModeDisplayNames[id] ?? id; + + /** One entry by catalog and `type_id`, or `undefined`. */ + entry(catalogId: string, typeId: string): CatalogEntry | undefined { + return this.entries(catalogId).find((e) => e.type === typeId); } - modifierDisplayName(id: string): string { - return this.modifierDisplayNames[id] ?? id; + + /** Display label for a `type_id` within a catalog (e.g. `"curves"` → + * `"Curves"`), falling back to the id itself when unknown. */ + displayName(catalogId: string, typeId: string): string { + return this.entry(catalogId, typeId)?.displayName ?? typeId; } - layerKindDisplayName(id: string): string { - return this.layerKindDisplayNames[id] ?? id; + + /** The Iconify glyph to render for a tool. + * + * A tool's glyph is registry metadata and lives on its Rust registration. + * A descriptor may override it when the glyph depends on live session + * state a static registration cannot express — the brush shows the eraser + * icon while erase mode is on. This is the single place that precedence + * is decided, so no caller branches on a tool id. */ + toolGlyph(typeId: string): string { + const override = toolRegistry.get(typeId)?.icon; + const resolved = typeof override === 'function' ? override() : override; + return resolved ?? this.entry('tools', typeId)?.icon ?? 'fa6-solid:wrench'; } - /** Display label for a filter `type_id` (e.g. `"curves"` → `"Curves"`), - * resolved from the `filterTypes` registry list. */ - filterDisplayName(id: string): string { - return this.filterTypes.find((f) => f.type === id)?.displayName ?? id; + + /** A tool button's `title` — its label plus the chord currently bound to + * the action that selects it. Both the label and that action id are the + * tool's own registry metadata, so resolving them together here keeps the + * toolbar and the cluster flyout from each doing the lookup. */ + toolTooltip(typeId: string): string { + const entry = this.entry('tools', typeId); + return tooltipForAction(entry?.displayName ?? typeId, entry?.hotkeyAction ?? ''); } - /** Populate every registry-backed display-name map from the Rust core in - * one pass. Called once during editor init, before action registration - * and before `this.handle` is set, so the maps are ready by the time any - * UI mounts. */ + /** Populate every registry projection from the Rust core in one pass. + * Called once during editor init, before action registration and before + * `this.handle` is set, so the catalogs are ready by the time any UI + * mounts. */ async loadRegistries(engine: Engine) { - const buildMap = ( - arr: Array<{ type: string; displayName: string }>, - ): Record => { - const m: Record = {}; - for (const e of arr ?? []) m[e.type] = e.displayName; - return m; - }; - const [tools, veils, voids, blends, modifiers, layerKinds, filters] = await Promise.all([ - engine.api.toolTypes(), - engine.api.veilTypes(), - engine.api.voidTypes(), - engine.api.blendModeTypes(), - engine.api.modifierTypes(), - engine.api.layerKindTypes(), - engine.api.filterTypes(), - ]); - this.toolDisplayNames = buildMap(tools); - this.veilDisplayNames = buildMap(veils); - this.voidDisplayNames = buildMap(voids); + const byId: Record = {}; + for (const c of (await engine.api.catalogs()) ?? []) byId[c.id] = c; + this.catalogs = byId; // Map each void type to its browser capture API, if any. Voids with a // `captureKind` (camera / screenshare) drive the generic MediaStream - // lifecycle; procedural voids (noise) omit the field and never appear - // here. Built once from the same `void_types` query. + // lifecycle; procedural voids (noise) leave it null and never appear + // here. const capKinds = new Map(); - for (const v of (voids ?? []) as Array<{ type: string; captureKind?: CaptureKind }>) { + for (const v of this.entries('voids')) { if (v.captureKind) capKinds.set(v.type, v.captureKind); } this.voidCaptureKind = capKinds; - this.blendModeDisplayNames = buildMap(blends); - this.modifierDisplayNames = buildMap(modifiers); - this.layerKindDisplayNames = buildMap(layerKinds); - this.filterTypes = filters ?? []; } /** Add a veil with a partial overrides record. Param names match the diff --git a/frontend/src/state/brush_graph.svelte.ts b/frontend/src/state/brush_graph.svelte.ts index 3d899a49..cbf6b4ac 100644 --- a/frontend/src/state/brush_graph.svelte.ts +++ b/frontend/src/state/brush_graph.svelte.ts @@ -228,7 +228,7 @@ export class BrushGraphState { * active graph contains a content-dependent node (clone, blur, * smudge, liquify) — its bake against the flat preview background * renders blank. Declared per node type via the registration's - * `preview_fallback_icon`; refreshed alongside `supportsErase`. */ + * `preview_staging`; refreshed alongside `supportsErase`. */ previewIcon = $state(null); /** @@ -470,6 +470,21 @@ export class BrushGraphState { ); } + /** Override an input port's slider bounds on one node instance. + * `min`/`max` are display-space — hand back the numbers the control + * was rendered with. Rejected by the engine unless ascending. */ + async setPortRange(nodeId: string, portName: string, min: number, max: number) { + if (!app.engine) return; + await this.applyResult( + await app.engine.api.brushGraphSetPortRange({ + node_id: nodeId, + port_name: portName, + display_min: min, + display_max: max, + }), + ); + } + /** Move a brush-bar entry to a target index in the display order. */ async reorderExposedPort(key: string, newIndex: number) { if (!app.engine) return; diff --git a/frontend/src/state/filterModal.svelte.ts b/frontend/src/state/filterModal.svelte.ts index 97fe1200..9cb7c36d 100644 --- a/frontend/src/state/filterModal.svelte.ts +++ b/frontend/src/state/filterModal.svelte.ts @@ -7,7 +7,7 @@ * bakes them into the node via `applyFilter`. Param-free filters (invert) skip * this and apply immediately. */ -import type { FilterParam } from '../ui/filters/filterParams'; +import type { ParamInfo } from '../ui/filters/filterParams'; class FilterModalState { open = $state(false); @@ -15,9 +15,9 @@ class FilterModalState { filterType = $state(''); displayName = $state(''); /** The filter type's schema (params carry their defaults). */ - schema = $state([]); + schema = $state([]); - show(nodeId: number, filterType: string, displayName: string, schema: FilterParam[]) { + show(nodeId: number, filterType: string, displayName: string, schema: ParamInfo[]) { this.nodeId = nodeId; this.filterType = filterType; this.displayName = displayName; diff --git a/frontend/src/tools/brush.svelte.ts b/frontend/src/tools/brush.svelte.ts index eaf63b78..d7bc7c8f 100644 --- a/frontend/src/tools/brush.svelte.ts +++ b/frontend/src/tools/brush.svelte.ts @@ -323,7 +323,6 @@ export const brushTool: ToolDescriptor = { : 'fa6-solid:paintbrush'; }, group: 'paint', - hotkeyAction: 'brushTool', optionsComponent: BrushOptions, panelComponent: BrushBuilderPanel, create: (inst: DarklyInstance) => new BrushTool(inst), diff --git a/frontend/src/tools/colorpicker.svelte.ts b/frontend/src/tools/colorpicker.svelte.ts index 22bec22a..e46997a3 100644 --- a/frontend/src/tools/colorpicker.svelte.ts +++ b/frontend/src/tools/colorpicker.svelte.ts @@ -50,9 +50,7 @@ class ColorPickerTool extends ToolBase { export const colorPickerTool: ToolDescriptor = { id: 'colorpicker', - icon: 'fa6-solid:eye-dropper', group: 'paint', - hotkeyAction: 'colorPickerTool', optionsComponent: ColorPickerOptions, create: (inst: DarklyInstance) => new ColorPickerTool(inst), }; diff --git a/frontend/src/tools/ellipse_select.svelte.ts b/frontend/src/tools/ellipse_select.svelte.ts index 54f98b83..43ead131 100644 --- a/frontend/src/tools/ellipse_select.svelte.ts +++ b/frontend/src/tools/ellipse_select.svelte.ts @@ -101,9 +101,7 @@ class EllipseSelectTool extends ToolBase { export const ellipseSelectTool: ToolDescriptor = { id: 'ellipse_select', - icon: 'lucide:circle-dashed', group: 'select', cluster: 'select', - hotkeyAction: 'ellipseSelectTool', create: (inst: DarklyInstance) => new EllipseSelectTool(inst), }; diff --git a/frontend/src/tools/fill.svelte.ts b/frontend/src/tools/fill.svelte.ts index df97aa4f..b931b987 100644 --- a/frontend/src/tools/fill.svelte.ts +++ b/frontend/src/tools/fill.svelte.ts @@ -32,9 +32,7 @@ class FillTool extends ToolBase { export const fillTool: ToolDescriptor = { id: 'fill', - icon: 'fa6-solid:fill-drip', group: 'paint', cluster: 'fill', - hotkeyAction: 'fillTool', create: (inst: DarklyInstance) => new FillTool(inst), }; diff --git a/frontend/src/tools/gradient.svelte.ts b/frontend/src/tools/gradient.svelte.ts index f6ceb29d..4fcf9dc8 100644 --- a/frontend/src/tools/gradient.svelte.ts +++ b/frontend/src/tools/gradient.svelte.ts @@ -163,16 +163,9 @@ class GradientTool extends ToolBase { } } -// Custom icon: no icon set has anything that reads as "linear gradient" at -// toolbar size. The bespoke SVG lives at src/icons/svg/gradient.svg and is -// bundled under the `local:` prefix (see scripts/gen-icon-bundle.mjs) — a -// rounded square painted with a currentColor→transparent fade, so it inherits -// the toolbar's muted/active text color. export const gradientTool: ToolDescriptor = { id: 'gradient', - icon: 'local:gradient', group: 'paint', cluster: 'fill', - hotkeyAction: 'gradientTool', create: (inst: DarklyInstance) => new GradientTool(inst), }; diff --git a/frontend/src/tools/lasso_select.svelte.ts b/frontend/src/tools/lasso_select.svelte.ts index 6a38feb9..f79d6948 100644 --- a/frontend/src/tools/lasso_select.svelte.ts +++ b/frontend/src/tools/lasso_select.svelte.ts @@ -81,9 +81,7 @@ class LassoSelectTool extends ToolBase { export const lassoSelectTool: ToolDescriptor = { id: 'lasso_select', - icon: 'tabler:lasso', group: 'select', cluster: 'select', - hotkeyAction: 'lassoSelectTool', create: (inst: DarklyInstance) => new LassoSelectTool(inst), }; diff --git a/frontend/src/tools/magic_wand.svelte.ts b/frontend/src/tools/magic_wand.svelte.ts index 92bcd1c1..9a6d0087 100644 --- a/frontend/src/tools/magic_wand.svelte.ts +++ b/frontend/src/tools/magic_wand.svelte.ts @@ -46,10 +46,8 @@ class MagicWandTool extends ToolBase { export const magicWandTool: ToolDescriptor = { id: 'magic_wand', - icon: 'fa6-solid:wand-magic-sparkles', group: 'select', cluster: 'select', - hotkeyAction: 'magicWandTool', optionsComponent: MagicWandOptions, create: (inst: DarklyInstance) => new MagicWandTool(inst), }; diff --git a/frontend/src/tools/polygon_select.svelte.ts b/frontend/src/tools/polygon_select.svelte.ts index 9ba10859..3602c443 100644 --- a/frontend/src/tools/polygon_select.svelte.ts +++ b/frontend/src/tools/polygon_select.svelte.ts @@ -186,9 +186,7 @@ class PolygonSelectTool extends ToolBase { export const polygonSelectTool: ToolDescriptor = { id: 'polygon_select', - icon: 'lucide:triangle-dashed', group: 'select', cluster: 'select', - hotkeyAction: 'polygonSelectTool', create: (inst: DarklyInstance) => new PolygonSelectTool(inst), }; diff --git a/frontend/src/tools/rect_select.svelte.ts b/frontend/src/tools/rect_select.svelte.ts index 1615fc17..d8b03c29 100644 --- a/frontend/src/tools/rect_select.svelte.ts +++ b/frontend/src/tools/rect_select.svelte.ts @@ -101,9 +101,7 @@ class RectSelectTool extends ToolBase { export const rectSelectTool: ToolDescriptor = { id: 'rect_select', - icon: 'boxicons:square-dashed', group: 'select', cluster: 'select', - hotkeyAction: 'rectSelectTool', create: (inst: DarklyInstance) => new RectSelectTool(inst), }; diff --git a/frontend/src/tools/registry.ts b/frontend/src/tools/registry.ts index 5ea363ac..2c3178f3 100644 --- a/frontend/src/tools/registry.ts +++ b/frontend/src/tools/registry.ts @@ -71,10 +71,15 @@ export interface Tool { */ export interface ToolDescriptor { readonly id: string; - /** Iconify icon name (e.g. 'fa6-solid:paintbrush', 'local:gradient'). - * Rendered via the shared `` component. May be a getter (the brush's - * icon tracks the global erase-mode flag). */ - readonly icon?: string; + /** Session-dependent override of the tool's registry icon. + * + * The tool's own glyph lives on its Rust `ToolRegistration` and reaches + * the UI through the `tools` catalog. This field exists only for a glyph + * that depends on live session state, which a static registration cannot + * express: the brush swaps to the eraser icon while erase mode is on. + * Resolve through `app.toolGlyph(id)` rather than reading it directly — + * that is where override-beats-registry is decided. */ + readonly icon?: string | (() => string); /** Tool group for toolbar visual separation (e.g. 'paint', 'select'). */ readonly group: string; @@ -83,10 +88,6 @@ export interface ToolDescriptor { * metadata (icon, default sub-tool, order) lives in {@link ToolCluster}. */ readonly cluster?: string; - /** Key name in HotkeyMap that activates this tool (e.g. 'brushTool'). - * Used by hotkey registration to wire up tool switching automatically. */ - readonly hotkeyAction: string; - /** Optional Svelte component rendered inside the always-visible bottom * options strip. Owns the per-tool widgets (sliders, toggles, pickers). * When absent, the strip shows a generic placeholder. */ diff --git a/frontend/src/tools/text.svelte.ts b/frontend/src/tools/text.svelte.ts index 6911bde0..f4bd5794 100644 --- a/frontend/src/tools/text.svelte.ts +++ b/frontend/src/tools/text.svelte.ts @@ -328,8 +328,6 @@ export function focusedTextTool(): TextTool | null { export const textTool: ToolDescriptor = { id: 'text', - icon: 'at-icons:text', group: 'paint', - hotkeyAction: 'textTool', create: (inst: DarklyInstance) => new TextTool(inst), }; diff --git a/frontend/src/tools/transform.svelte.ts b/frontend/src/tools/transform.svelte.ts index af0a254a..416f625a 100644 --- a/frontend/src/tools/transform.svelte.ts +++ b/frontend/src/tools/transform.svelte.ts @@ -256,18 +256,11 @@ export function focusedTransformTool(): TransformTool | null { } /** Descriptor factory for a transform cluster variant. */ -function transformDescriptor(opts: { - id: string; - icon: string; - hotkeyAction: string; - entry: number; -}): ToolDescriptor { +function transformDescriptor(opts: { id: string; entry: number }): ToolDescriptor { return { id: opts.id, - icon: opts.icon, group: 'transform', cluster: 'transform', - hotkeyAction: opts.hotkeyAction, create: (inst): Tool => new TransformTool(inst, opts.entry), }; } @@ -275,15 +268,11 @@ function transformDescriptor(opts: { /** Free (affine) transform — pan / scale / rotate. The cluster default. */ export const transformTool: ToolDescriptor = transformDescriptor({ id: 'transform', - icon: 'fa6-solid:up-down-left-right', - hotkeyAction: 'transformTool', entry: 0, }); /** Perspective transform — enters the four-corner homography mode directly. */ export const transformPerspectiveTool: ToolDescriptor = transformDescriptor({ id: 'transform_perspective', - icon: 'tabler:perspective', - hotkeyAction: 'transformPerspectiveTool', entry: 1, }); diff --git a/frontend/src/ui/BrushOptions.svelte b/frontend/src/ui/BrushOptions.svelte index df7b6782..498832b0 100644 --- a/frontend/src/ui/BrushOptions.svelte +++ b/frontend/src/ui/BrushOptions.svelte @@ -3,7 +3,7 @@ import { brushGraph } from '../state/brush_graph.svelte'; import type { BrushInfo, ExposedPortInfo } from '../state/brush_graph.svelte'; import { unitFor } from '../lib/units'; - import { brushSession } from '../tools/brush.svelte'; + import { brushSession, focusedBrushTool } from '../tools/brush.svelte'; import BrushPicker from './brush_picker/BrushPicker.svelte'; import LiveBrushPreviewStrip from './brush_picker/LiveBrushPreviewStrip.svelte'; import Scrub from './Scrub.svelte'; @@ -33,9 +33,21 @@ brushPickerOpen = false; } - function handleExposedPort(nodeId: string, portName: string, displayValue: number) { + /** Transient feedback while a scrub is being dragged. Local only — the + * engine recompiles the graph and re-derives its previews on every + * exposed-port write, which is work the values a drag passes through + * don't warrant. */ + function previewExposedPort(nodeId: string, portName: string, displayValue: number) { brushGraph.setExposedPortValueLocal(nodeId, portName, displayValue); - brushGraph.setExposedPortValue(nodeId, portName, displayValue); + } + + /** The value the user settled on. Refreshes the on-canvas hover overlay + * afterward so the brush outline reflects the new value without waiting + * for a pointer move — same courtesy the `[` / `]` hotkeys extend. */ + async function commitExposedPort(nodeId: string, portName: string, displayValue: number) { + brushGraph.setExposedPortValueLocal(nodeId, portName, displayValue); + await brushGraph.setExposedPortValue(nodeId, portName, displayValue); + focusedBrushTool()?.refreshHoverOverlay(); } /** Flip a Bool exposed port — toggles the input value between 0 and 1 via @@ -124,7 +136,8 @@ max={d.max} default={d.default} formatValue={(v) => unitFor(d.unitType).format(v)} - onChange={(v) => handleExposedPort(port.nodeId, port.portName, v)} + onChange={(v) => previewExposedPort(port.nodeId, port.portName, v)} + onCommit={(v) => void commitExposedPort(port.nodeId, port.portName, v)} title={port.description || undefined} /> {:else if port.data.kind === 'bool'} diff --git a/frontend/src/ui/EffectPreview.svelte b/frontend/src/ui/EffectPreview.svelte index 70703a77..faf05064 100644 --- a/frontend/src/ui/EffectPreview.svelte +++ b/frontend/src/ui/EffectPreview.svelte @@ -1,64 +1,123 @@ - - + +{#if showsPreview(entry)} + + +{:else if entry.icon} +
+ +
+{:else} +
+ {entry.displayName ?? entry.type} +
+{/if} diff --git a/frontend/src/ui/LeftSidebar.svelte b/frontend/src/ui/LeftSidebar.svelte index a0680ffc..2f26e468 100644 --- a/frontend/src/ui/LeftSidebar.svelte +++ b/frontend/src/ui/LeftSidebar.svelte @@ -100,11 +100,9 @@ class="tool" class:active={app.activeToolId === item.tool.id} onclick={() => app.activeToolId = item.tool.id} - title={tooltipForAction(app.toolDisplayName(item.tool.id), item.tool.hotkeyAction)} + title={app.toolTooltip(item.tool.id)} > - {#if item.tool.icon} - - {/if} + {/if} {/each} diff --git a/frontend/src/ui/Scrub.svelte b/frontend/src/ui/Scrub.svelte index c7803538..4a519005 100644 --- a/frontend/src/ui/Scrub.svelte +++ b/frontend/src/ui/Scrub.svelte @@ -1,5 +1,7 @@ diff --git a/frontend/src/ui/ToolCluster.svelte b/frontend/src/ui/ToolCluster.svelte index 5a932925..9e113ee9 100644 --- a/frontend/src/ui/ToolCluster.svelte +++ b/frontend/src/ui/ToolCluster.svelte @@ -1,7 +1,6 @@ @@ -87,8 +82,8 @@ onmouseenter={onClusterEnter} title={clusterTitle} > - {#if iconSource?.icon} - + {#if iconSource} + {/if} @@ -101,11 +96,9 @@ class="tool" class:active={app.activeToolId === tool.id} onclick={() => pickTool(tool.id)} - title={toolTitle(tool)} + title={app.toolTooltip(tool.id)} > - {#if tool.icon} - - {/if} + {/each} diff --git a/frontend/src/ui/ToolOptionsBar.svelte b/frontend/src/ui/ToolOptionsBar.svelte index 6ac77f1b..746db200 100644 --- a/frontend/src/ui/ToolOptionsBar.svelte +++ b/frontend/src/ui/ToolOptionsBar.svelte @@ -16,7 +16,7 @@ {#if Options} {:else} - {tool ? app.toolDisplayName(tool.id) : ''} + {tool ? app.displayName('tools', tool.id) : ''}
{/if} diff --git a/frontend/src/ui/__tests__/preview_frames.test.ts b/frontend/src/ui/__tests__/preview_frames.test.ts index 7d7b351a..846aa370 100644 --- a/frontend/src/ui/__tests__/preview_frames.test.ts +++ b/frontend/src/ui/__tests__/preview_frames.test.ts @@ -1,10 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; import { pollPreview, + showsPreview, toPreviewData, - voidShowsPreview, type RawPreview, - type PreviewKind, } from '../preview_frames'; import type { Engine } from '../../engine/protocol'; import { withApi } from '../../engine/testApi'; @@ -37,7 +36,7 @@ function rawPreview(frameCount: number, w = 2, h = 2): RawPreview { } /** Fake engine whose `send('poll_preview', …)` returns the scripted payload. - * Captures the payload so tests can assert the `{ kind, type }` wire shape. */ + * Captures the payload so tests can assert the `{ catalog, type }` wire shape. */ function fakeEngine(payload: RawPreview | null) { const send = vi.fn(async (_kind: string, _payload: unknown) => payload); const engine = withApi({ send, post: vi.fn() }) as unknown as Engine; @@ -60,7 +59,7 @@ describe('toPreviewData', () => { describe('pollPreview', () => { it('returns null while the engine is still generating', async () => { const { engine } = fakeEngine(null); - expect(await pollPreview(engine, 'veil', 'grain')).toBeNull(); + expect(await pollPreview(engine, 'veils', 'grain', 'still')).toBeNull(); }); it('returns null for an empty frame set', async () => { @@ -71,45 +70,72 @@ describe('pollPreview', () => { frameCount: 0, bytes: new Uint8Array(0), }); - expect(await pollPreview(engine, 'veil', 'grain')).toBeNull(); + expect(await pollPreview(engine, 'veils', 'grain', 'still')).toBeNull(); }); it('converts the frames once the generation completes', async () => { const { engine } = fakeEngine(rawPreview(4, 8, 4)); - const data = await pollPreview(engine, 'veil', 'vhs'); + const data = await pollPreview(engine, 'veils', 'vhs', 'animated'); expect(data?.frames).toHaveLength(4); expect(data?.width).toBe(8); expect(data?.height).toBe(4); }); - it('sends the generic poll_preview with { kind, type } for both kinds', async () => { + it('sends { catalog, type, variant } for every catalog', async () => { const { engine, send } = fakeEngine(rawPreview(1)); - const kinds: PreviewKind[] = ['veil', 'void']; - for (const kind of kinds) { - await pollPreview(engine, kind, 'noise'); + // Catalog ids, not a second vocabulary — the same strings the pickers + // already hold and `catalogs()` publishes. + for (const catalog of ['veils', 'voids', 'filters']) { + await pollPreview(engine, catalog, 'noise', 'still'); } - expect(send).toHaveBeenNthCalledWith(1, 'poll_preview', { kind: 'veil', type: 'noise' }); - expect(send).toHaveBeenNthCalledWith(2, 'poll_preview', { kind: 'void', type: 'noise' }); + for (const [i, catalog] of ['veils', 'voids', 'filters'].entries()) { + expect(send).toHaveBeenNthCalledWith(i + 1, 'poll_preview', { + catalog, + type: 'noise', + variant: 'still', + }); + } + }); + + it('polls the two variants independently', async () => { + const { engine, send } = fakeEngine(rawPreview(1)); + // A card polls for its still and, once hovered, for its animation. They + // are separate generations engine-side, so they are separate requests. + await pollPreview(engine, 'veils', 'frozen', 'still'); + await pollPreview(engine, 'veils', 'frozen', 'animated'); + expect(send).toHaveBeenNthCalledWith(1, 'poll_preview', { + catalog: 'veils', + type: 'frozen', + variant: 'still', + }); + expect(send).toHaveBeenNthCalledWith(2, 'poll_preview', { + catalog: 'veils', + type: 'frozen', + variant: 'animated', + }); }); it('re-polls the engine each call (no caching)', async () => { const { engine, send } = fakeEngine(rawPreview(2)); - await pollPreview(engine, 'void', 'noise'); - await pollPreview(engine, 'void', 'noise'); + await pollPreview(engine, 'voids', 'noise', 'animated'); + await pollPreview(engine, 'voids', 'noise', 'animated'); // Unlike a cached path, every call hits the engine — the preview tracks // the live document, so results are never memoised. expect(send).toHaveBeenCalledTimes(2); }); }); -describe('voidShowsPreview', () => { - // The "Add Void" picker renders a live thumbnail when the void opts into a - // rendered preview, and falls back to its iconify icon otherwise. This is - // the predicate that drives that template branch (see VoidPickerModal). - it('is true only when the void declares supportsPreview', () => { - expect(voidShowsPreview({ supportsPreview: true })).toBe(true); - expect(voidShowsPreview({ supportsPreview: false })).toBe(false); - // Missing flag is treated as no preview (icon fallback). - expect(voidShowsPreview({})).toBe(false); +describe('showsPreview', () => { + // Every picker renders a live thumbnail when the entry opts into a rendered + // preview, and falls back otherwise. This is the predicate that drives the + // first arm of `EffectPreview`'s chain, whatever the catalog. + it('is true only when the entry declares supportsPreview', () => { + // A filter, a veil and a void entry are the same shape to this + // predicate — that is the point of generalising it. + expect(showsPreview({ supportsPreview: true })).toBe(true); + expect(showsPreview({ supportsPreview: false })).toBe(false); + // Missing flag is treated as no preview, which is what sends the card + // down the icon → named-placeholder half of the chain. + expect(showsPreview({})).toBe(false); }); }); diff --git a/frontend/src/ui/brush_builder/BrushBarEntryModal.svelte b/frontend/src/ui/brush_builder/BrushBarEntryModal.svelte index 216686c2..5a4891d9 100644 --- a/frontend/src/ui/brush_builder/BrushBarEntryModal.svelte +++ b/frontend/src/ui/brush_builder/BrushBarEntryModal.svelte @@ -18,6 +18,20 @@ // offline via . let iconInput = $state(''); + // Slider bounds, in the same display space the control renders in. + // Only scalars have them — a toggle or a dropdown has no travel to + // re-range — so the whole section is hidden for other kinds. + let minInput = $state(0); + let maxInput = $state(1); + let advancedOpen = $state(false); + + const scalar = $derived(entry?.data.kind === 'scalar' ? entry.data : null); + // Mirrors the engine's rule, so an unsavable range is caught before the + // round trip rather than coming back as an error string. + const rangeValid = $derived( + Number.isFinite(minInput) && Number.isFinite(maxInput) && minInput < maxInput, + ); + /** Re-seed the inputs whenever the modal opens for a fresh entry — * the engine emits the current effective values (registration * fallbacks applied) so the placeholders/values match what the @@ -27,17 +41,28 @@ labelInput = entry.label; descriptionInput = entry.description; iconInput = entry.icon; + if (entry.data.kind === 'scalar') { + minInput = entry.data.min; + maxInput = entry.data.max; + } + advancedOpen = false; } }); - function onSave() { - if (!entry) return; - brushGraph.setExposedPortMeta( + async function onSave() { + if (!entry || !rangeValid) return; + await brushGraph.setExposedPortMeta( entry.key, labelInput, descriptionInput, iconInput, ); + // Only when actually changed: the range is a per-instance override, + // and re-sending the current bounds would pin a port to values it + // was merely inheriting from its registration. + if (scalar && (minInput !== scalar.min || maxInput !== scalar.max)) { + await brushGraph.setPortRange(entry.nodeId, entry.portName, minInput, maxInput); + } open = false; } @@ -90,9 +115,44 @@ {/each} + {#if scalar} +
+ + {#if advancedOpen} +
+

+ Slider range for this brush. Narrow it onto the values that + actually do something, or re-center it — a range of −1 to 1 + gives a control that works in both directions. +

+
+ + +
+ {#if !rangeValid} +

Min must be less than max.

+ {/if} +
+ {/if} +
+ {/if}
- +
{/if} @@ -171,12 +231,53 @@ font-size: 10px; color: var(--text-muted); } + .disclosure { + display: flex; + align-items: center; + gap: 6px; + padding: 0; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-family: inherit; + font-size: 11px; + } + .disclosure:hover { + color: var(--text); + } + .advanced { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + } + .range-row { + display: flex; + gap: 8px; + } + .range-field { + flex: 1; + } + .hint { + margin: 0; + font-size: 11px; + line-height: 1.45; + color: var(--text-muted); + } + .hint.error { + color: var(--danger, #e0645a); + } .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; } + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } .btn { padding: 7px 14px; font-size: 13px; diff --git a/frontend/src/ui/brush_builder/NodePalette.svelte b/frontend/src/ui/brush_builder/NodePalette.svelte index c76c7034..ed94c24e 100644 --- a/frontend/src/ui/brush_builder/NodePalette.svelte +++ b/frontend/src/ui/brush_builder/NodePalette.svelte @@ -7,14 +7,12 @@ let { onaddnode }: Props = $props(); - // Group node types by category. `internal` is a hidden category used by - // the engine for synthesised terminals (e.g. `preview_terminal` for the - // per-node preview pipeline) — these are not user-placeable and would - // confuse the palette UI. + // Group node types by category. Every registered node type is placeable — + // the engine's synthesised preview terminal is a WGSL construct, not a + // registration, so there is nothing to hide here. let categories = $derived((() => { const cats: Record = {}; for (const nt of brushGraph.nodeTypes) { - if (nt.category === 'internal') continue; const cat = nt.category || 'other'; if (!cats[cat]) cats[cat] = []; cats[cat].push(nt); diff --git a/frontend/src/ui/brush_builder/PortWidget.svelte b/frontend/src/ui/brush_builder/PortWidget.svelte index 05c25a63..eacdfbd0 100644 --- a/frontend/src/ui/brush_builder/PortWidget.svelte +++ b/frontend/src/ui/brush_builder/PortWidget.svelte @@ -2,6 +2,7 @@ import { getContext, untrack } from 'svelte'; import { brushGraph, WIRE_COLORS, EXTENDED_RANGE_MAX, type PortDef } from '../../state/brush_graph.svelte'; import { unitFor } from '../../lib/units'; + import { beginScrubDrag, type ScrubDrag } from '../../lib/scrubDrag'; import { app } from '../../state/app.svelte'; import type { NodeCanvasContext } from './NodeCanvas.svelte'; import Icon from '../../icons/Icon.svelte'; @@ -219,12 +220,12 @@ // --- Inline slider for disconnected Scalar/Int/Bool inputs --- let sliderEl = $state(); - let sliding = false; + let sliderDrag: ScrubDrag | null = null; - /** Normalized position (0–1) from a pointer event relative to the slider bar. */ - function sliderFraction(e: PointerEvent): number { + /** Normalized position (0–1) of a client point relative to the slider bar. */ + function sliderFraction(clientX: number, clientY: number): number { if (!sliderEl) return 0; - const local = coords.clientToElementLocal(sliderEl, e.clientX, e.clientY); + const local = coords.clientToElementLocal(sliderEl, clientX, clientY); return Math.max(0, Math.min(1, local.x / sliderEl.clientWidth)); } @@ -255,29 +256,32 @@ // Stop propagation so the node doesn't start dragging. e.stopPropagation(); e.preventDefault(); - sliding = true; sliderEl.setPointerCapture(e.pointerId); app.beginInteraction(); - const value = valueFromFraction(sliderFraction(e)); - brushGraph.setInputLocal(nodeId, port.name, value); + sliderDrag = beginScrubDrag({ + toValue: (clientX, clientY) => valueFromFraction(sliderFraction(clientX, clientY)), + onPreview: (v) => brushGraph.setInputLocal(nodeId, port.name, v), + onCommit: commitSlider, + onFinish: () => { + sliderDrag = null; + app.endInteraction(); + }, + }); + // Seed from the pointerdown position — clicking the track jumps the + // value there, so a click that never moves still commits. + sliderDrag.move(e.clientX, e.clientY); } function onSliderMove(e: PointerEvent) { - if (!sliding) return; - const value = valueFromFraction(sliderFraction(e)); - brushGraph.setInputLocal(nodeId, port.name, value); - } - - function onSliderUp(e: PointerEvent) { - if (!sliding || !sliderEl) return; - sliding = false; - sliderEl.releasePointerCapture(e.pointerId); - commitSlider(numValue); + sliderDrag?.move(e.clientX, e.clientY); } - function onSliderLostCapture() { - sliding = false; - app.endInteraction(); + /** Wired to both `pointerup` and `lostpointercapture`; `end` is idempotent. + * A lost capture commits rather than discarding — the previewed value is + * already on screen, so dropping it would leave the widget showing a value + * the engine never received. */ + function onSliderEnd() { + sliderDrag?.end(); } // --- Enum dropdown --- @@ -407,8 +411,8 @@ bind:this={sliderEl} onpointerdown={onSliderDown} onpointermove={onSliderMove} - onpointerup={onSliderUp} - onlostpointercapture={onSliderLostCapture} + onpointerup={onSliderEnd} + onlostpointercapture={onSliderEnd} ondblclick={onSliderDblClick} >
- {#if brushGraph.previewIcon} - - {:else} -
+
+ {#if brushGraph.previewIcon} + + {:else} -
-
- -
- {/if} + {/if} +
+
+ +