diff --git a/ADR/006-shared-canvas-extraction.md b/ADR/006-shared-canvas-extraction.md index b20ef16..a37a79e 100644 --- a/ADR/006-shared-canvas-extraction.md +++ b/ADR/006-shared-canvas-extraction.md @@ -1,6 +1,6 @@ # ADR 006 — Shared Canvas Extraction from Loki -**Status:** Proposed +**Status:** Accepted **Date:** 2024-11-01 **Deciders:** AppThere core team @@ -37,3 +37,21 @@ This gate is tracked in the Loki ADR backlog (Loki ADR TBD). The status field of - Iris Phase 1 cannot begin `iris-canvas` implementation until the Loki extraction is complete. The workspace scaffold includes `iris-canvas` as a stub to maintain the dependency graph, but implementation is blocked. - Any bug fix or improvement to the `CustomPaintSource` integration must be made in `appthere-canvas` and will benefit both Loki and Iris. - `appthere-canvas` must be kept free of document semantics — it must not know about pages, layers, tiles, or paths. + +## Implementation notes + +The extraction target was loki-render-cache, not loki-vello as originally assumed. +loki-vello is entirely Loki-specific (document layout painters) and was not extracted. + +appthere-canvas = loki-render-cache + FontDataCache + event-driven scroll helpers. + +Changes made during extraction: +- PageCache — generic key type (was PageIndex hardcoded) +- BlitPipeline struct — blit pipeline cached, not recreated per downsample +- FontDataCache — moved from loki-vello, re-exported there for API stability +- use_settle_detector — event-driven via tokio::sync::watch (was 16ms poll) +- LokiPageSource.renderer — shared Arc> from RendererState (was per-page) +- loki-text migrated to loki-renderer; document_source.rs and wgpu_surface.rs deleted + +iris-canvas uses iris_pixel::TileCoord as its CacheKey. +appthere-canvas lives at crates/appthere-canvas/ (excluded from workspace members). diff --git a/CLAUDE.md b/CLAUDE.md index cfd8e9d..65402ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,7 +167,7 @@ Check `ADR/006-shared-canvas-extraction.md` for current status. | Crate | Gate condition | |---|---| | `iris-aif` | loki-opc path dep at `crates/loki-opc/` — GATE OPEN (ADR 008) | -| `iris-canvas` | `appthere-canvas` published and Loki updated to consume it | +| `iris-canvas` | appthere-canvas at crates/appthere-canvas/ — GATE OPEN (ADR 006) | Until gates are open, these crates exist as stubs only (`lib.rs` with a `// TODO` comment). diff --git a/Cargo.lock b/Cargo.lock index c2d7f7e..5be5c8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,7 +199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e2c6900aa6fa601379c17b824d0882c5c4ffd2f974124b273ba083b64f76077" dependencies = [ "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "raw-window-handle", ] @@ -212,7 +212,7 @@ dependencies = [ "anyrender", "image", "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "thiserror 2.0.18", "usvg", ] @@ -226,10 +226,10 @@ dependencies = [ "anyrender", "debug_timer", "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "pollster 0.4.0", "rustc-hash 2.1.2", - "vello", + "vello 0.6.0", "wgpu 26.0.1", "wgpu_context", ] @@ -243,7 +243,7 @@ dependencies = [ "anyrender", "debug_timer", "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "pixels_window_renderer", "vello_cpu", ] @@ -264,6 +264,18 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac436601d6bdde674a0d7fb593e829ffe7b3387c351b356dd20e2d40f5bf3ee5" +[[package]] +name = "appthere-canvas" +version = "0.1.0" +dependencies = [ + "dioxus", + "peniko 0.5.0", + "thiserror 2.0.18", + "tokio", + "tracing", + "wgpu 26.0.1", +] + [[package]] name = "appthere-color" version = "0.1.1" @@ -672,7 +684,7 @@ dependencies = [ "atomic_refcell", "bitflags 2.11.1", "blitz-traits", - "color", + "color 0.3.3", "cssparser", "cursor-icon", "debug_timer", @@ -735,11 +747,11 @@ dependencies = [ "anyrender_svg", "blitz-dom", "blitz-traits", - "color", + "color 0.3.3", "euclid", "kurbo 0.12.0", "parley", - "peniko", + "peniko 0.5.0", "stylo", "taffy", "usvg", @@ -993,6 +1005,12 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "color" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7f99105610438d4b3ee7ae8e453c2990e325c806a14d71e8ea937d584c5289" + [[package]] name = "color" version = "0.3.3" @@ -2155,6 +2173,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "font-types" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" +dependencies = [ + "bytemuck", +] + [[package]] name = "font-types" version = "0.10.1" @@ -2506,6 +2533,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "glow" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "glow" version = "0.16.0" @@ -3240,11 +3279,18 @@ dependencies = [ name = "iris-canvas" version = "0.1.0" dependencies = [ + "anyrender_vello", + "appthere-canvas", + "bytemuck", + "dioxus", "iris-pixel", "iris-vector", + "kurbo 0.11.3", "thiserror 2.0.18", "tracing", "uuid", + "vello 0.4.1", + "wgpu 26.0.1", ] [[package]] @@ -4025,6 +4071,21 @@ dependencies = [ "paste", ] +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + [[package]] name = "metal" version = "0.32.0" @@ -4097,6 +4158,27 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "naga" +version = "23.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "364f94bc34f61332abebe8cad6f6cd82a5b65cff22c828d05d0968911462ca4f" +dependencies = [ + "arrayvec", + "bit-set 0.8.0", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "codespan-reporting 0.11.1", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + [[package]] name = "naga" version = "26.0.0" @@ -4854,6 +4936,30 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "peniko" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1f594c54ccdc9bd177a726885f066bf28d20e17169e31a8a1456217b1316b4" +dependencies = [ + "color 0.2.4", + "kurbo 0.11.3", + "peniko 0.4.1", + "smallvec", +] + +[[package]] +name = "peniko" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288" +dependencies = [ + "color 0.3.3", + "kurbo 0.11.3", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "peniko" version = "0.5.0" @@ -4861,7 +4967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3c76095c9a636173600478e0373218c7b955335048c2bcd12dc6a79657649d8" dependencies = [ "bytemuck", - "color", + "color 0.3.3", "kurbo 0.12.0", "linebender_resource_handle", "smallvec", @@ -5377,6 +5483,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f9e8a4f503e5c8750e4cd3b32a4e090035c46374b305a15c70bad833dca05f" +dependencies = [ + "bytemuck", + "font-types 0.8.4", +] + [[package]] name = "read-fonts" version = "0.35.0" @@ -5983,6 +6099,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "skrifa" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cc1aa86c26dbb1b63875a7180aa0819709b33348eb5b1491e4321fae388179d" +dependencies = [ + "bytemuck", + "read-fonts 0.25.3", +] + [[package]] name = "skrifa" version = "0.37.0" @@ -7066,6 +7192,25 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vello" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d5b0bafa35e0c2e4132104576d6bcec4bf7cd0044f1760e92ecae0d4d9bc0e7" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko 0.3.2", + "png", + "skrifa 0.26.6", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding 0.4.1", + "vello_shaders 0.4.1", + "wgpu 23.0.1", +] + [[package]] name = "vello" version = "0.6.0" @@ -7075,13 +7220,13 @@ dependencies = [ "bytemuck", "futures-intrusive", "log", - "peniko", + "peniko 0.5.0", "png", "skrifa 0.37.0", "static_assertions", "thiserror 2.0.18", - "vello_encoding", - "vello_shaders", + "vello_encoding 0.6.0", + "vello_shaders 0.6.0", "wgpu 26.0.1", ] @@ -7095,7 +7240,7 @@ dependencies = [ "fearless_simd", "hashbrown 0.15.5", "log", - "peniko", + "peniko 0.5.0", "skrifa 0.37.0", "smallvec", ] @@ -7110,6 +7255,19 @@ dependencies = [ "vello_common", ] +[[package]] +name = "vello_encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbbdec68dea2b39ece9f82ab15ec4cf2c4f8600ce6926df0638290702d95b3f7" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko 0.3.2", + "skrifa 0.26.6", + "smallvec", +] + [[package]] name = "vello_encoding" version = "0.6.0" @@ -7118,11 +7276,23 @@ checksum = "cfd5e0b9fec91df34a09fbcbbed474cec68d05691b590a911c7af83c4860ae42" dependencies = [ "bytemuck", "guillotiere", - "peniko", + "peniko 0.5.0", "skrifa 0.37.0", "smallvec", ] +[[package]] +name = "vello_shaders" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0179d74cf9131dfd7882323751d2544f3aefdfda9d16c39bbe2729799410d2" +dependencies = [ + "bytemuck", + "naga 23.1.0", + "thiserror 2.0.18", + "vello_encoding 0.4.1", +] + [[package]] name = "vello_shaders" version = "0.6.0" @@ -7133,7 +7303,7 @@ dependencies = [ "log", "naga 26.0.0", "thiserror 2.0.18", - "vello_encoding", + "vello_encoding 0.6.0", ] [[package]] @@ -7503,6 +7673,31 @@ dependencies = [ "wgpu-types 0.19.2", ] +[[package]] +name = "wgpu" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80f70000db37c469ea9d67defdc13024ddf9a5f1b89cb2941b812ad7cde1735a" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "naga 23.1.0", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core 23.0.1", + "wgpu-hal 23.0.1", + "wgpu-types 23.0.0", +] + [[package]] name = "wgpu" version = "26.0.1" @@ -7558,6 +7753,31 @@ dependencies = [ "wgpu-types 0.19.2", ] +[[package]] +name = "wgpu-core" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d63c3c478de8e7e01786479919c8769f62a22eec16788d8c2ac77ce2c132778a" +dependencies = [ + "arrayvec", + "bit-vec 0.8.0", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga 23.1.0", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal 23.0.1", + "wgpu-types 23.0.0", +] + [[package]] name = "wgpu-core" version = "26.0.1" @@ -7661,6 +7881,51 @@ dependencies = [ "winapi", ] +[[package]] +name = "wgpu-hal" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89364b8a0b211adc7b16aeaf1bd5ad4a919c1154b44c9ce27838213ba05fd821" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash 0.38.0+1.3.281", + "bit-set 0.8.0", + "bitflags 2.11.1", + "block", + "bytemuck", + "cfg_aliases 0.1.1", + "core-graphics-types 0.1.3", + "glow 0.14.2", + "glutin_wgl_sys 0.6.1", + "gpu-alloc", + "gpu-allocator 0.27.0", + "gpu-descriptor 0.3.2", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal 0.29.0", + "naga 23.1.0", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types 23.0.0", + "windows 0.58.0", + "windows-core 0.58.0", +] + [[package]] name = "wgpu-hal" version = "26.0.6" @@ -7720,6 +7985,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wgpu-types" +version = "23.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610f6ff27778148c31093f3b03abc4840f9636d58d597ca2f5977433acfe0068" +dependencies = [ + "bitflags 2.11.1", + "js-sys", + "web-sys", +] + [[package]] name = "wgpu-types" version = "26.0.0" diff --git a/Cargo.toml b/Cargo.toml index 6c9edb9..d4ba48d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -exclude = ["crates/loki-file-access", "crates/loki-opc"] +exclude = ["crates/loki-file-access", "crates/loki-opc", "crates/appthere-canvas"] members = [ "crates/iris-app", "crates/iris-canvas", @@ -26,7 +26,7 @@ repository = "https://github.com/appthere/iris" # Shared dependency versions — pin once here, reference with {version} = ... [workspace.dependencies] # AppThere shared crates (path deps until published — swap to version once on crates.io) -appthere-canvas = { path = "../appthere-canvas", version = "0.1" } +appthere-canvas = { path = "crates/appthere-canvas", features = ["gpu", "font-cache"] } appthere-color = { version = "0.1" } appthere-file-access = { package = "loki-file-access", path = "crates/loki-file-access" } loki-opc = { path = "crates/loki-opc", features = ["serde"] } @@ -42,7 +42,7 @@ iris-plugin-api = { path = "crates/iris-plugin-api" } # External dependencies dioxus = { version = "0.7", features = ["native"] } vello = { version = "0.4" } -wgpu = { version = "22" } +wgpu = { version = "26" } kurbo = { version = "0.11" } parley = { version = "0.2" } loro = { version = "1" } @@ -58,6 +58,7 @@ zip = { version = "2" } quick-xml = { version = "0.36" } serde = { version = "1", features = ["derive"] } proptest = { version = "1" } +bytemuck = { version = "1", features = ["derive"] } png = { version = "0.17" } [profile.dev] diff --git a/SPEC.md b/SPEC.md index c7fed62..420d1a4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -996,7 +996,9 @@ Unlike Loki's paginated scroll, Iris uses an infinite canvas anchored at `(0, 0) ```rust // iris-canvas/src/viewport.rs pub struct CanvasViewport { - /// Document-space origin at the top-left of the screen viewport + /// Document-space coordinate anchored at the screen center. + /// Rotation is always around the screen center. (Q1: top-left convention + /// rejected — it is undefined when rotation is non-zero.) pub pan: Vec2, /// Zoom factor (1.0 = 100%, 0.1 = 10%, 64.0 = 6400%) pub zoom: f32, diff --git a/crates/iris-canvas/Cargo.toml b/crates/iris-canvas/Cargo.toml index 55520c7..df635df 100644 --- a/crates/iris-canvas/Cargo.toml +++ b/crates/iris-canvas/Cargo.toml @@ -13,6 +13,10 @@ tracing = { workspace = true } uuid = { workspace = true } iris-pixel = { workspace = true } iris-vector = { workspace = true } -# appthere-canvas = { workspace = true } # Uncomment once gate is open -# vello = { workspace = true } # Uncomment once gate is open -# wgpu = { workspace = true } # Uncomment once gate is open +appthere-canvas = { workspace = true, features = ["dioxus"] } +dioxus = { workspace = true } +vello = { workspace = true } +wgpu = { workspace = true } +kurbo = { workspace = true } +bytemuck = { workspace = true } +anyrender_vello = "0.6.2" diff --git a/crates/iris-canvas/src/canvas_widget.rs b/crates/iris-canvas/src/canvas_widget.rs new file mode 100644 index 0000000..fe48066 --- /dev/null +++ b/crates/iris-canvas/src/canvas_widget.rs @@ -0,0 +1,136 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`IrisCanvas`] — Dioxus component that composites a [`LayerTree`] into a +//! wgpu-rendered canvas element via Dioxus Native's `use_wgpu` hook. +//! +//! # Architecture +//! +//! Dioxus Native (Blitz) owns the GPU device and calls the paint source on +//! every frame. The bridge: +//! 1. `IrisCanvas` holds shared `Arc>` state for the viewport and +//! layer tree, updated on each Dioxus re-render. +//! 2. `use_wgpu` registers an [`IrisCanvasPaintSource`] with the Blitz +//! renderer; it calls `Compositor::composite()` each frame, hands the +//! resulting `wgpu::Texture` to `CustomPaintCtx::register_texture`, and +//! returns the `TextureHandle` for ``. +//! 3. `IrisPageSource` implements `appthere_canvas::PageSource` for +//! non-Dioxus contexts (e.g. headless rendering, tests). +//! +//! Hooks rule: every `use_*` call is unconditional at the top level of the +//! component body — no hooks inside closures, conditions, or loops. + +use std::sync::{Arc, Mutex}; + +use dioxus::native::use_wgpu; +use dioxus::prelude::*; +use iris_pixel::LayerTree; + +use crate::compositor::Compositor; +use crate::paint_bridge::IrisCanvasPaintSource; +use crate::viewport::CanvasViewport; + +/// Props for the [`IrisCanvas`] component. +#[derive(Props, Clone, PartialEq)] +pub struct IrisCanvasProps { + /// Reactive document layer tree. Component re-renders when the signal changes. + pub tree: Signal, + /// Reactive viewport state (pan, zoom, rotation). Component re-renders on change. + pub viewport: Signal, + /// Canvas width in CSS pixels. + pub width: u32, + /// Canvas height in CSS pixels. + pub height: u32, +} + +/// Dioxus component for the Iris infinite canvas. +/// +/// Renders via Dioxus Native's `use_wgpu` + Blitz ``. +/// Reactive signals keep viewport and layer tree in sync with the component tree. +#[component] +pub fn IrisCanvas(props: IrisCanvasProps) -> Element { + // Lazily-initialised compositor, shared with the paint source. + let compositor = use_hook(|| Arc::new(Mutex::new(Compositor::new()))); + + // Shared viewport and tree: initialised once, updated each re-render so the + // paint source always sees the latest state without needing Dioxus context. + let shared_viewport = use_hook(|| Arc::new(Mutex::new(props.viewport.peek().clone()))); + let shared_tree = use_hook(|| Arc::new(Mutex::new(props.tree.peek().clone()))); + + // Sync signal values into shared state on every re-render. + if let Ok(mut vp) = shared_viewport.try_lock() { + *vp = props.viewport.read().clone(); + } + if let Ok(mut tr) = shared_tree.try_lock() { + *tr = props.tree.read().clone(); + } + + // Register the paint source with Blitz. `use_wgpu` uses `use_hook_with_cleanup` + // internally — auto-unregisters when the component is dropped. FnOnce captures + // cloned Arcs, so the shared state remains live for the component's lifetime. + let canvas_id = use_wgpu(|| { + IrisCanvasPaintSource::new( + compositor.clone(), + shared_viewport.clone(), + shared_tree.clone(), + ) + }); + + rsx! { + canvas { + // "src" is not in Dioxus's canvas element schema but blitz-dom reads + // it to associate a registered CustomPaintSource with this element. + "src": "{canvas_id}", + width: "{props.width}", + height: "{props.height}", + style: "display: block; width: {props.width}px; height: {props.height}px;", + } + } +} + +/// [`appthere_canvas::PageSource`] implementation for the Iris compositor. +/// +/// The unit key `()` represents the single composited viewport. Useful for +/// headless rendering and non-Dioxus contexts. Dioxus Native rendering uses +/// [`IrisCanvasPaintSource`] via `use_wgpu` instead. +pub struct IrisPageSource { + compositor: Arc>, + width: u32, + height: u32, + viewport: CanvasViewport, + tree: LayerTree, +} + +impl IrisPageSource { + /// Create a new page source from current component state. + pub fn new( + compositor: Arc>, + width: u32, + height: u32, + viewport: CanvasViewport, + tree: LayerTree, + ) -> Self { + Self { compositor, width, height, viewport, tree } + } +} + +impl appthere_canvas::PageSource for IrisPageSource { + type Key = (); + + fn page_size_px(&self, _index: ()) -> (u32, u32) { + (self.width, self.height) + } + + fn render( + &self, + _index: (), + _scale: f32, + device: &wgpu::Device, + queue: &wgpu::Queue, + ) -> Result { + let guard = self.compositor.lock().unwrap_or_else(|e| e.into_inner()); + guard + .composite(&self.tree, &self.viewport, self.width, self.height, device, queue) + .map_err(|e| appthere_canvas::RenderError::Wgpu(e.to_string())) + } +} diff --git a/crates/iris-canvas/src/compositor/composite.rs b/crates/iris-canvas/src/compositor/composite.rs new file mode 100644 index 0000000..348d796 --- /dev/null +++ b/crates/iris-canvas/src/compositor/composite.rs @@ -0,0 +1,133 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Core composite logic: layer iteration, tile upload, blend pass invocation. + +use iris_pixel::{BlendMode, LayerContent, LayerTree, TILE_SIZE, TileCoord}; + +use crate::viewport::CanvasViewport; + +use super::pass::{BlendPass, TileEntry, TileParamsGpu}; +use super::upload; +use super::CompositorError; + +/// Composite all visible pixel layers in `tree` into a single `Rgba16Float` texture. +/// +/// Called from [`Compositor::composite`]; receives the lazily-initialised blend pipeline. +pub(super) fn run( + blend: &BlendPass, + tree: &LayerTree, + viewport: &CanvasViewport, + width_px: u32, + height_px: u32, + device: &wgpu::Device, + queue: &wgpu::Queue, +) -> Result { + let visible_rect = viewport.visible_doc_rect(width_px, height_px); + let mut tiles: Vec = Vec::new(); + + // Collect layers bottom-to-top (DFS is top-first → reverse). + let layers: Vec<_> = tree.iter_depth_first().collect(); + for layer in layers.iter().rev() { + if !layer.visible { + continue; + } + let LayerContent::Pixel(ref px) = layer.content else { + // TODO(iris): SPEC.md §6.2 — Phase 3+: vector/text layer compositing + tracing::warn!( + layer_id = ?layer.id, + "iris-canvas Phase 2: non-pixel layer skipped in compositor" + ); + continue; + }; + if layer.blend_mode != BlendMode::Normal { + // TODO(iris): SPEC.md §4.8 — Phase 4: non-Normal WGSL compute shaders + tracing::warn!( + layer_id = ?layer.id, + mode = ?layer.blend_mode, + "iris-canvas Phase 2: only Normal blend composited; skipping layer" + ); + continue; + } + + let offset_x = px.canvas_offset_x as f64; + let offset_y = px.canvas_offset_y as f64; + let ts = TILE_SIZE as f64; + + let tx_min = ((visible_rect.x0 - offset_x) / ts).floor().max(0.0) as u32; + let ty_min = ((visible_rect.y0 - offset_y) / ts).floor().max(0.0) as u32; + let tx_max = ((visible_rect.x1 - offset_x) / ts).ceil().max(0.0) as u32; + let ty_max = ((visible_rect.y1 - offset_y) / ts).ceil().max(0.0) as u32; + + for ty in ty_min..=ty_max { + for tx in tx_min..=tx_max { + let coord = TileCoord { tx, ty }; + let texture = match px.tiles.get(coord) { + Some(data) => upload::upload_tile(device, queue, data), + None => upload::transparent_tile(device, queue), + }; + let view = texture.create_view(&Default::default()); + let params = tile_params( + viewport, offset_x, offset_y, tx, ty, + (width_px, height_px), layer.opacity, + ); + tiles.push(TileEntry { view, params }); + } + } + } + + let output = device.create_texture(&wgpu::TextureDescriptor { + label: Some("iris-canvas composite-output"), + size: wgpu::Extent3d { width: width_px, height: height_px, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let output_view = output.create_view(&Default::default()); + blend.run(device, queue, &tiles, &output_view)?; + + Ok(appthere_canvas::GpuTexture { inner: output, width: width_px, height: height_px }) +} + +/// Compute NDC quad corners for a tile at `(tx, ty)` given the current viewport. +pub(super) fn tile_params( + vp: &CanvasViewport, + offset_x: f64, + offset_y: f64, + tx: u32, + ty: u32, + screen_size: (u32, u32), + opacity: f32, +) -> TileParamsGpu { + let (width_px, height_px) = screen_size; + let ts = TILE_SIZE as f64; + let doc_x0 = offset_x + tx as f64 * ts; + let doc_y0 = offset_y + ty as f64 * ts; + let doc_x1 = doc_x0 + ts; + let doc_y1 = doc_y0 + ts; + + let tl = vp.doc_to_screen(kurbo::Vec2::new(doc_x0, doc_y0), width_px, height_px); + let tr = vp.doc_to_screen(kurbo::Vec2::new(doc_x1, doc_y0), width_px, height_px); + let bl = vp.doc_to_screen(kurbo::Vec2::new(doc_x0, doc_y1), width_px, height_px); + let br = vp.doc_to_screen(kurbo::Vec2::new(doc_x1, doc_y1), width_px, height_px); + + TileParamsGpu { + corner_tl: screen_to_ndc(tl, width_px, height_px), + corner_tr: screen_to_ndc(tr, width_px, height_px), + corner_bl: screen_to_ndc(bl, width_px, height_px), + corner_br: screen_to_ndc(br, width_px, height_px), + opacity, + _pad: [0.0; 3], + } +} + +/// Convert a screen-space point to WebGPU NDC (x: [-1,1], y: [+1,-1]). +fn screen_to_ndc(screen: kurbo::Vec2, width_px: u32, height_px: u32) -> [f32; 2] { + let nx = (screen.x / width_px as f64 * 2.0 - 1.0) as f32; + // NDC Y is +1 at top, -1 at bottom (opposite of screen Y). + let ny = (1.0 - screen.y / height_px as f64 * 2.0) as f32; + [nx, ny] +} diff --git a/crates/iris-canvas/src/compositor/mod.rs b/crates/iris-canvas/src/compositor/mod.rs new file mode 100644 index 0000000..fe47978 --- /dev/null +++ b/crates/iris-canvas/src/compositor/mod.rs @@ -0,0 +1,66 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Pixel compositor: composites all visible [`LayerTree`] layers into a single +//! `Rgba16Float` wgpu texture representing the current viewport. +//! +//! Phase 2: Normal blend mode only. All other blend modes log a warning and +//! the layer is skipped. See TODO below for Phase 4. + +pub(crate) mod pass; +pub(crate) mod upload; +mod composite; + +use std::sync::{Arc, Mutex}; + +use iris_pixel::LayerTree; + +use crate::viewport::CanvasViewport; + +use pass::BlendPass; + +/// Errors produced by the compositor. +#[derive(Debug, thiserror::Error)] +pub enum CompositorError { + /// A wgpu-level operation failed. + #[error("wgpu compositor error: {0}")] + Wgpu(String), +} + +/// GPU pixel compositor, lazily initialised on first [`Compositor::composite`] call. +/// +/// The [`BlendPass`] pipeline is created once and reused across frames. +/// `Arc>` allows the compositor to be shared between the Dioxus +/// component (which owns it) and the [`PageSource`] impl (Q5 from audit). +pub struct Compositor { + state: Arc>>, +} + +impl Compositor { + /// Create a new compositor. Pipeline is initialised lazily. + pub fn new() -> Self { + Self { state: Arc::new(Mutex::new(None)) } + } + + /// Composite all visible pixel layers in `tree` and return the output texture. + /// + /// `device` and `queue` arrive from `PageSource::render()`. The pipeline is + /// initialised on the first call and reused thereafter. + pub fn composite( + &self, + tree: &LayerTree, + viewport: &CanvasViewport, + width_px: u32, + height_px: u32, + device: &wgpu::Device, + queue: &wgpu::Queue, + ) -> Result { + let mut guard = self.state.lock().unwrap_or_else(|e| e.into_inner()); + let blend = guard.get_or_insert_with(|| BlendPass::new(device)); + composite::run(blend, tree, viewport, width_px, height_px, device, queue) + } +} + +impl Default for Compositor { + fn default() -> Self { Self::new() } +} diff --git a/crates/iris-canvas/src/compositor/pass.rs b/crates/iris-canvas/src/compositor/pass.rs new file mode 100644 index 0000000..9b74727 --- /dev/null +++ b/crates/iris-canvas/src/compositor/pass.rs @@ -0,0 +1,193 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Render-pipeline compositor pass. +//! +//! [`BlendPass`] holds a cached wgpu render pipeline implementing Normal blend +//! via hardware `PREMULTIPLIED_ALPHA_BLENDING`. One pipeline, many draw calls. +//! +//! See `src/shaders/normal_blend.wgsl` for the vertex/fragment shaders. + +use bytemuck::{Pod, Zeroable}; +use wgpu::util::DeviceExt as _; + +use super::CompositorError; + +/// GPU-side representation of per-tile parameters. +/// Layout must match the WGSL `TileParams` struct (16-byte aligned, 48 bytes total). +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub(super) struct TileParamsGpu { + /// NDC position of the top-left tile corner. + pub corner_tl: [f32; 2], + /// NDC position of the top-right tile corner. + pub corner_tr: [f32; 2], + /// NDC position of the bottom-left tile corner. + pub corner_bl: [f32; 2], + /// NDC position of the bottom-right tile corner. + pub corner_br: [f32; 2], + /// Layer opacity in [0.0, 1.0]. + pub opacity: f32, + pub _pad: [f32; 3], +} + +/// One tile entry: an uploaded texture view + its NDC quad corners + opacity. +pub(super) struct TileEntry { + pub view: wgpu::TextureView, + pub params: TileParamsGpu, +} + +/// Cached wgpu render pipeline for Normal blend compositing. +pub(super) struct BlendPass { + pipeline: wgpu::RenderPipeline, + bind_group_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, +} + +impl BlendPass { + const SHADER: &'static str = + include_str!("../shaders/normal_blend.wgsl"); + + /// Compile the blend shader and build all pipeline objects. Called once per device. + pub fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("iris-canvas normal-blend shader"), + source: wgpu::ShaderSource::Wgsl(Self::SHADER.into()), + }); + let bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("iris-canvas blend-bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + 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::VERTEX | 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("iris-canvas blend-pl"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("iris-canvas blend-rp"), + 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_main"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("iris-canvas blend-sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + Self { pipeline, bind_group_layout, sampler } + } + + /// Render all `tiles` (bottom-to-top order) into `output_view` with Normal blend. + pub fn run( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + tiles: &[TileEntry], + output_view: &wgpu::TextureView, + ) -> Result<(), CompositorError> { + let mut encoder = + device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("iris-canvas composite"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("iris-canvas composite-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: output_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&self.pipeline); + + for tile in tiles { + let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("iris-canvas tile-params"), + contents: bytemuck::bytes_of(&tile.params), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("iris-canvas tile-bg"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&tile.view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: uniform_buf.as_entire_binding(), + }, + ], + }); + pass.set_bind_group(0, &bind_group, &[]); + pass.draw(0..4, 0..1); + } + } + queue.submit(Some(encoder.finish())); + Ok(()) + } +} diff --git a/crates/iris-canvas/src/compositor/upload.rs b/crates/iris-canvas/src/compositor/upload.rs new file mode 100644 index 0000000..a00c6ae --- /dev/null +++ b/crates/iris-canvas/src/compositor/upload.rs @@ -0,0 +1,95 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! CPU tile → GPU texture upload helpers. +//! +//! [`upload_tile`] converts a [`TileData`] (raw f16 RGBA bytes, straight alpha) +//! into a `wgpu::Texture` with format `Rgba16Float` on the given device/queue. +//! No CPU-side conversion is needed: `queue.write_texture` copies the bytes +//! directly; the f16 encoding is identical between TileData and Rgba16Float. + +use iris_pixel::{TileData, TILE_SIZE}; + +/// Upload a [`TileData`] as an `Rgba16Float` GPU texture. +/// +/// The tile texture has usage `TEXTURE_BINDING | COPY_DST` and is ready for +/// sampling immediately after this call returns. +pub(super) fn upload_tile( + device: &wgpu::Device, + queue: &wgpu::Queue, + tile_data: &TileData, +) -> wgpu::Texture { + let texture = create_tile_texture(device); + let size = wgpu::Extent3d { + width: TILE_SIZE, + height: TILE_SIZE, + depth_or_array_layers: 1, + }; + // bytes_per_row: TILE_SIZE pixels × 4 channels × 2 bytes/f16 = 2048 + let bytes_per_row = TILE_SIZE * 4 * 2; + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &tile_data.0, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(TILE_SIZE), + }, + size, + ); + texture +} + +/// Create a fully-transparent (all-zero) `Rgba16Float` tile texture. +/// +/// Used when a tile coordinate is not present in the layer's cache — +/// absent tile = transparent = f16 zero for all channels. +pub(super) fn transparent_tile(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::Texture { + let texture = create_tile_texture(device); + let size = wgpu::Extent3d { + width: TILE_SIZE, + height: TILE_SIZE, + depth_or_array_layers: 1, + }; + let bytes_per_row = TILE_SIZE * 4 * 2; + let zeros = vec![0u8; (bytes_per_row * TILE_SIZE) as usize]; + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &zeros, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(TILE_SIZE), + }, + size, + ); + texture +} + +/// Allocate a 256×256 `Rgba16Float` texture for tile data. +fn create_tile_texture(device: &wgpu::Device) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("iris-canvas tile"), + size: wgpu::Extent3d { + width: TILE_SIZE, + height: TILE_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }) +} diff --git a/crates/iris-canvas/src/key.rs b/crates/iris-canvas/src/key.rs new file mode 100644 index 0000000..3d123d0 --- /dev/null +++ b/crates/iris-canvas/src/key.rs @@ -0,0 +1,41 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`TileKey`] — composite cache key for a specific layer tile. +//! +//! Lives in iris-canvas (not iris-pixel) because it is a rendering concern, +//! not a document-model concern. iris-pixel owns TileCoord and LayerId; +//! iris-canvas owns their combination as a render-cache key. + +use iris_pixel::{LayerId, TileCoord}; + +/// Cache key identifying a specific tile within a specific layer. +/// +/// Used by `PageCache` to track hot/warm/cold tier state. +/// Both fields are `Copy`, so `TileKey` is `Copy` and satisfies the +/// `CacheKey` blanket impl (`Hash + Eq + Copy + Send + Sync + 'static`). +/// +/// Pan convention: iris-canvas uses screen-center-anchored pan (Q1 decision — +/// top-left was rejected because it is undefined when rotation is non-zero). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TileKey { + /// The layer this tile belongs to. + pub layer_id: LayerId, + /// Tile grid coordinate within that layer. + pub tile: TileCoord, +} + +impl TileKey { + /// Construct a new key from a layer ID and tile coordinate. + pub fn new(layer_id: LayerId, tile: TileCoord) -> Self { + Self { layer_id, tile } + } +} + +// Compile-time assertion: TileKey satisfies the CacheKey blanket impl. +const _: () = { + fn _assert() {} + fn _check() { + _assert::(); + } +}; diff --git a/crates/iris-canvas/src/lib.rs b/crates/iris-canvas/src/lib.rs index 22b8352..969467b 100644 --- a/crates/iris-canvas/src/lib.rs +++ b/crates/iris-canvas/src/lib.rs @@ -1,11 +1,29 @@ // Copyright 2024 AppThere Project // SPDX-License-Identifier: Apache-2.0 - -//! Iris infinite canvas: viewport, scroll model, compositor +// +//! Iris-specific canvas: infinite viewport, tile compositor, blend modes. +//! Built on appthere-canvas generic GPU infrastructure. //! -//! See SPEC.md and crates/iris-canvas/BRIEF.md before implementing. +//! See SPEC.md §6.2 and ADR/006-shared-canvas-extraction.md. #![forbid(unsafe_code)] -#![deny(missing_docs)] -// TODO(iris): SPEC.md §
— stub only; implementation gated on milestone plan +pub mod canvas_widget; +pub mod key; +pub(crate) mod compositor; +pub(crate) mod paint_bridge; +pub mod viewport; + +// TODO(iris): SPEC.md §6.2 — Phase 2: overlay pass (selection, guides, artboards) +// pub mod overlay; + +// Re-export generic canvas types Iris callers need +pub use appthere_canvas::{ + CacheKey, CacheTier, GpuTexture, PageSource, RenderError, ScrollPhase, ScrollState, +}; + +// Iris-specific public API +pub use canvas_widget::IrisCanvas; +pub use compositor::CompositorError; +pub use key::TileKey; +pub use viewport::{CanvasViewport, MAX_ZOOM, MIN_ZOOM}; diff --git a/crates/iris-canvas/src/paint_bridge.rs b/crates/iris-canvas/src/paint_bridge.rs new file mode 100644 index 0000000..247940a --- /dev/null +++ b/crates/iris-canvas/src/paint_bridge.rs @@ -0,0 +1,93 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Bridge between [`Compositor`] and `anyrender_vello::CustomPaintSource`. +//! +//! API confirmed from anyrender_vello 0.6.2 / wgpu_context 0.1.2: +//! - `DeviceHandle { pub device: wgpu::Device, pub queue: wgpu::Queue, … }` +//! All fields are public; `DeviceHandle: Clone` (wgpu::Device/Queue are +//! reference-counted handles and are also Clone). +//! - `CustomPaintCtx::register_texture(wgpu::Texture) -> TextureHandle` +//! Takes `Texture` by value. `anyrender_vello` depends on `wgpu = "26"`, +//! the same version iris-canvas uses — types are compatible. +//! - `CustomPaintCtx::unregister_texture(TextureHandle)` — takes by value. +//! - `TextureHandle: Clone` — safe to clone when both storing and returning. +//! - `CustomPaintSource::render()` returning `None` skips the frame; +//! Blitz reuses the last registered texture until a `Some` is returned. + +use std::sync::{Arc, Mutex}; + +use anyrender_vello::{CustomPaintCtx, CustomPaintSource, DeviceHandle, TextureHandle}; +use iris_pixel::LayerTree; + +use crate::compositor::Compositor; +use crate::viewport::CanvasViewport; + +/// Bridges [`Compositor`] into `anyrender_vello::CustomPaintSource` so that +/// Dioxus Native's `use_wgpu` hook can drive the Iris render pipeline. +/// +/// `viewport` and `tree` are `Arc>` shared with the `IrisCanvas` +/// component body, which updates them on every re-render. The Blitz paint +/// loop calls `render()` independently; no Dioxus reactive context is needed. +pub(crate) struct IrisCanvasPaintSource { + compositor: Arc>, + viewport: Arc>, + tree: Arc>, + device_handle: Option, + last_handle: Option, +} + +impl IrisCanvasPaintSource { + pub(crate) fn new( + compositor: Arc>, + viewport: Arc>, + tree: Arc>, + ) -> Self { + Self { compositor, viewport, tree, device_handle: None, last_handle: None } + } +} + +impl CustomPaintSource for IrisCanvasPaintSource { + fn resume(&mut self, device_handle: &DeviceHandle) { + self.device_handle = Some(device_handle.clone()); + } + + fn suspend(&mut self) { + self.device_handle = None; + self.last_handle = None; + } + + fn render( + &mut self, + mut ctx: CustomPaintCtx<'_>, + width: u32, + height: u32, + scale: f64, + ) -> Option { + let dh = self.device_handle.as_ref()?; + + if let Some(old) = self.last_handle.take() { + ctx.unregister_texture(old); + } + + // Clone viewport (CanvasViewport: Copy+Clone); hold tree and compositor + // guards only for the duration of the composite call. + let viewport = self.viewport.lock().ok()?.clone(); + let tree_guard = self.tree.lock().ok()?; + let compositor_guard = self.compositor.lock().ok()?; + + let gpu_texture = compositor_guard + .composite(&tree_guard, &viewport, width, height, &dh.device, &dh.queue) + .ok()?; + + drop(compositor_guard); + drop(tree_guard); + + // scale is passed through for future use; current compositor uses width/height directly. + let _ = scale; + + let handle = ctx.register_texture(gpu_texture.inner); + self.last_handle = Some(handle.clone()); + Some(handle) + } +} diff --git a/crates/iris-canvas/src/shaders/normal_blend.wgsl b/crates/iris-canvas/src/shaders/normal_blend.wgsl new file mode 100644 index 0000000..b99d0e1 --- /dev/null +++ b/crates/iris-canvas/src/shaders/normal_blend.wgsl @@ -0,0 +1,65 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 +// +// Normal-blend render shader for iris-canvas Phase 2. +// +// Design note: the audit specified a compute shader with +// texture_storage_2d, but wgpu 26 only supports +// read_write storage access for rgba16float with TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES, +// which is not universally available. Using a render pipeline with +// wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING gives identical results for +// Normal blend mode with no feature flag. +// +// TODO(iris): SPEC.md §4.8 — Phase 4: replace with compute pipeline for +// non-Normal blend modes (SHADER_F16 + compute are universally supported on +// all five target platforms per the audit). + +struct TileParams { + // NDC coordinates of the four tile corners on screen. + // Passed as a pair of corners; bilinear interpolation handles rotation. + corner_tl: vec2, // top-left in NDC + corner_tr: vec2, // top-right in NDC + corner_bl: vec2, // bottom-left in NDC + corner_br: vec2, // bottom-right in NDC + opacity: f32, + _pad: vec3, // 16-byte alignment +}; + +@group(0) @binding(0) var t_tile: texture_2d; +@group(0) @binding(1) var s_tile: sampler; +@group(0) @binding(2) var params: TileParams; + +struct VertexOut { + @builtin(position) clip_pos: vec4, + @location(0) uv: vec2, +}; + +// Vertex UVs: index → (u, v) in [0,1] x [0,1] +// 0 = TL (0,0), 1 = TR (1,0), 2 = BL (0,1), 3 = BR (1,1) +// Draw call: draw(0..4) with TriangleStrip topology. +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOut { + let u = f32(vi & 1u); + let v = f32((vi >> 1u) & 1u); + // Bilinear interpolation of the four NDC corners. + // Correct for both axis-aligned and rotated tiles. + let top = mix(params.corner_tl, params.corner_tr, u); + let bottom = mix(params.corner_bl, params.corner_br, u); + let pos = mix(top, bottom, v); + var out: VertexOut; + out.clip_pos = vec4(pos, 0.0, 1.0); + out.uv = vec2(u, v); + return out; +} + +// Q3 decision: premultiply straight-alpha on the fly in the fragment shader. +// The render pipeline blend state (PREMULTIPLIED_ALPHA_BLENDING) then applies +// Porter-Duff "over" in hardware: +// out_rgb = src_rgb + dst_rgb * (1 - src_a) +// out_a = src_a + dst_a * (1 - src_a) +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + let c = textureSample(t_tile, s_tile, in.uv); + // Premultiply straight alpha, then scale by layer opacity. + return vec4(c.rgb * c.a, c.a) * params.opacity; +} diff --git a/crates/iris-canvas/src/viewport.rs b/crates/iris-canvas/src/viewport.rs new file mode 100644 index 0000000..3df64bd --- /dev/null +++ b/crates/iris-canvas/src/viewport.rs @@ -0,0 +1,221 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`CanvasViewport`] — infinite-canvas transform: pan, zoom, rotation. +//! +//! Pan convention (Q1 decision): `pan` is the document-space coordinate +//! visible at the **screen center**. Rotation is always around the screen +//! center. The spec's original "top-left" comment was rejected because that +//! convention is undefined when rotation is non-zero. + +/// Minimum allowed zoom factor (10%). +pub const MIN_ZOOM: f32 = 0.1; +/// Maximum allowed zoom factor (6400%). +pub const MAX_ZOOM: f32 = 64.0; + +/// Infinite-canvas viewport transform. +/// +/// Maps between document space (pixels, origin at top-left of the document) +/// and screen space (pixels, origin at top-left of the screen). +/// +/// The transform is a similarity: translate → rotate → scale, anchored at +/// the screen center. +#[derive(Debug, Clone, PartialEq)] +pub struct CanvasViewport { + /// Document-space coordinate visible at the screen center. + /// Changing `pan` translates the canvas without affecting zoom or rotation. + pub pan: kurbo::Vec2, + /// Zoom factor. 1.0 = 100%, 0.1 = 10% (MIN_ZOOM), 64.0 = 6400% (MAX_ZOOM). + pub zoom: f32, + /// Canvas rotation in radians, counter-clockwise, applied around the screen center. + /// 0.0 = upright (default). Non-destructive: document pixels are never modified. + pub rotation: f32, +} + +impl Default for CanvasViewport { + fn default() -> Self { + Self::new() + } +} + +impl CanvasViewport { + /// Create a default viewport (zoom = 1.0, pan = origin, no rotation). + pub fn new() -> Self { + Self { + pan: kurbo::Vec2::ZERO, + zoom: 1.0, + rotation: 0.0, + } + } + + /// Convert a screen-space point to document space. + /// + /// Transform sequence (screen-center anchor, Q1): + /// 1. Translate by `−screen_center` → relative to screen center + /// 2. Rotate by `−self.rotation` (undo canvas rotation) + /// 3. Scale by `1/zoom` + /// 4. Translate by `+self.pan` → document-space result + pub fn screen_to_doc(&self, screen: kurbo::Vec2, sw: u32, sh: u32) -> kurbo::Vec2 { + let center = kurbo::Vec2::new(sw as f64 * 0.5, sh as f64 * 0.5); + let rel = screen - center; + let (sin, cos) = ((-self.rotation) as f64).sin_cos(); + let rotated = kurbo::Vec2::new( + rel.x * cos - rel.y * sin, + rel.x * sin + rel.y * cos, + ); + self.pan + rotated / self.zoom as f64 + } + + /// Convert a document-space point to screen space. Inverse of [`screen_to_doc`]. + /// + /// Transform sequence: + /// 1. Translate by `−self.pan` → relative to document anchor + /// 2. Scale by `zoom` + /// 3. Rotate by `+self.rotation` + /// 4. Translate by `+screen_center` + pub fn doc_to_screen(&self, doc: kurbo::Vec2, sw: u32, sh: u32) -> kurbo::Vec2 { + let center = kurbo::Vec2::new(sw as f64 * 0.5, sh as f64 * 0.5); + let rel = (doc - self.pan) * self.zoom as f64; + let (sin, cos) = (self.rotation as f64).sin_cos(); + let rotated = kurbo::Vec2::new( + rel.x * cos - rel.y * sin, + rel.x * sin + rel.y * cos, + ); + center + rotated + } + + /// Axis-aligned bounding box in document space covering all visible screen pixels. + /// + /// At rotation = 0 this is a tight rect. At rotation ≠ 0 it is the AABB of + /// the four rotated viewport corners — always a superset of visible pixels. + pub fn visible_doc_rect(&self, sw: u32, sh: u32) -> kurbo::Rect { + let corners = [ + self.screen_to_doc(kurbo::Vec2::new(0.0, 0.0), sw, sh), + self.screen_to_doc(kurbo::Vec2::new(sw as f64, 0.0), sw, sh), + self.screen_to_doc(kurbo::Vec2::new(0.0, sh as f64), sw, sh), + self.screen_to_doc(kurbo::Vec2::new(sw as f64, sh as f64), sw, sh), + ]; + let x0 = corners.iter().map(|v| v.x).fold(f64::INFINITY, f64::min); + let y0 = corners.iter().map(|v| v.y).fold(f64::INFINITY, f64::min); + let x1 = corners.iter().map(|v| v.x).fold(f64::NEG_INFINITY, f64::max); + let y1 = corners.iter().map(|v| v.y).fold(f64::NEG_INFINITY, f64::max); + kurbo::Rect::new(x0, y0, x1, y1) + } + + /// Zoom towards/away from `anchor_screen`, keeping that screen point fixed in doc space. + /// + /// `new_zoom` is clamped to `[MIN_ZOOM, MAX_ZOOM]`. After this call, the doc-space + /// coordinate under `anchor_screen` is identical to before. + pub fn zoom_to(&mut self, new_zoom: f32, anchor_screen: kurbo::Vec2, sw: u32, sh: u32) { + let new_zoom = new_zoom.clamp(MIN_ZOOM, MAX_ZOOM); + let anchor_doc = self.screen_to_doc(anchor_screen, sw, sh); + self.zoom = new_zoom; + // Recompute pan so anchor_doc maps back to anchor_screen after the zoom change. + let center = kurbo::Vec2::new(sw as f64 * 0.5, sh as f64 * 0.5); + let rel = anchor_screen - center; + let (sin, cos) = ((-self.rotation) as f64).sin_cos(); + let rotated = kurbo::Vec2::new( + rel.x * cos - rel.y * sin, + rel.x * sin + rel.y * cos, + ); + self.pan = anchor_doc - rotated / new_zoom as f64; + } + + /// Clamp zoom to the valid range without changing pan or rotation. + pub fn clamp_zoom(&mut self) { + self.zoom = self.zoom.clamp(MIN_ZOOM, MAX_ZOOM); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::FRAC_PI_4; + + const SW: u32 = 256; + const SH: u32 = 256; + const TOL: f64 = 1e-9; + + fn approx_eq(a: kurbo::Vec2, b: kurbo::Vec2) -> bool { + (a.x - b.x).abs() < TOL && (a.y - b.y).abs() < TOL + } + + #[test] + fn round_trip_zoom1_no_rotation() { + let vp = CanvasViewport::new(); + for pt in [ + kurbo::Vec2::new(0.0, 0.0), + kurbo::Vec2::new(128.0, 128.0), + kurbo::Vec2::new(256.0, 256.0), + ] { + let doc = vp.screen_to_doc(pt, SW, SH); + let back = vp.doc_to_screen(doc, SW, SH); + assert!(approx_eq(pt, back), "round-trip failed for {pt:?}"); + } + } + + #[test] + fn round_trip_zoom2() { + let vp = CanvasViewport { zoom: 2.0, ..CanvasViewport::new() }; + let pt = kurbo::Vec2::new(64.0, 200.0); + let back = vp.doc_to_screen(vp.screen_to_doc(pt, SW, SH), SW, SH); + assert!(approx_eq(pt, back)); + } + + #[test] + fn round_trip_rotation_quarter_pi() { + let vp = CanvasViewport { rotation: FRAC_PI_4, ..CanvasViewport::new() }; + let pt = kurbo::Vec2::new(10.0, 240.0); + let back = vp.doc_to_screen(vp.screen_to_doc(pt, SW, SH), SW, SH); + assert!(approx_eq(pt, back)); + } + + #[test] + fn visible_doc_rect_covers_all_corners() { + let vp = CanvasViewport { rotation: FRAC_PI_4, zoom: 0.5, ..CanvasViewport::new() }; + let rect = vp.visible_doc_rect(SW, SH); + for screen in [ + kurbo::Vec2::new(0.0, 0.0), + kurbo::Vec2::new(SW as f64, 0.0), + kurbo::Vec2::new(0.0, SH as f64), + kurbo::Vec2::new(SW as f64, SH as f64), + ] { + let doc = vp.screen_to_doc(screen, SW, SH); + assert!(rect.x0 - TOL <= doc.x && doc.x <= rect.x1 + TOL); + assert!(rect.y0 - TOL <= doc.y && doc.y <= rect.y1 + TOL); + } + } + + #[test] + fn rotation_widens_visible_rect() { + let vp_flat = CanvasViewport::new(); + let vp_rot = CanvasViewport { rotation: FRAC_PI_4, ..CanvasViewport::new() }; + let flat = vp_flat.visible_doc_rect(SW, SH); + let rot = vp_rot.visible_doc_rect(SW, SH); + let flat_area = (flat.x1 - flat.x0) * (flat.y1 - flat.y0); + let rot_area = (rot.x1 - rot.x0) * (rot.y1 - rot.y0); + assert!(rot_area > flat_area, "rotated rect should have larger AABB"); + } + + #[test] + fn zoom_clamp() { + let mut vp = CanvasViewport::new(); + vp.zoom_to(0.0, kurbo::Vec2::new(128.0, 128.0), SW, SH); + assert!((vp.zoom - MIN_ZOOM).abs() < f32::EPSILON); + vp.zoom_to(1000.0, kurbo::Vec2::new(128.0, 128.0), SW, SH); + assert!((vp.zoom - MAX_ZOOM).abs() < f32::EPSILON); + } + + #[test] + fn zoom_anchor_stable() { + let mut vp = CanvasViewport { zoom: 1.0, ..CanvasViewport::new() }; + let anchor_screen = kurbo::Vec2::new(80.0, 60.0); + let anchor_doc_before = vp.screen_to_doc(anchor_screen, SW, SH); + vp.zoom_to(3.0, anchor_screen, SW, SH); + let anchor_doc_after = vp.screen_to_doc(anchor_screen, SW, SH); + assert!( + approx_eq(anchor_doc_before, anchor_doc_after), + "anchor moved: before={anchor_doc_before:?} after={anchor_doc_after:?}" + ); + } +} diff --git a/crates/iris-canvas/tests/integration.rs b/crates/iris-canvas/tests/integration.rs index 6f729ba..bdc68c7 100644 --- a/crates/iris-canvas/tests/integration.rs +++ b/crates/iris-canvas/tests/integration.rs @@ -1,5 +1,107 @@ // Copyright 2024 AppThere Project // SPDX-License-Identifier: Apache-2.0 -// Integration tests for iris-canvas. -// Each test file in tests/fixtures/ must have a corresponding test here. +//! Integration tests for iris-canvas — coordinate pipeline, no GPU required. + +use iris_canvas::{CanvasViewport, TileKey}; +use iris_pixel::{ + BlendMode, LayerContent, LayerTree, PixelLayer, + BitDepth, ChannelLayout, ExrCompression, TileCache, TileCoord, TileData, TILE_SIZE, + LINEAR_SRGB, +}; +use uuid::Uuid; + +fn make_tree() -> LayerTree { + let mut tree = LayerTree::new(512, 512, 96.0, 96.0); + let layer = iris_pixel::Layer { + id: Uuid::new_v4(), + name: "bg".into(), + visible: true, + locked: false, + opacity: 1.0, + blend_mode: BlendMode::Normal, + clipping_mask: false, + mask: None, + content: LayerContent::Pixel(PixelLayer { + channel_layout: ChannelLayout::Rgba, + bit_depth: BitDepth::F16, + color_space: LINEAR_SRGB, + compression: ExrCompression::Zip, + canvas_offset_x: 0, + canvas_offset_y: 0, + crop_bounds: None, + tiles: { + let mut cache = TileCache::new(8); + // Fill (0,0) with a distinguishable pattern (all bytes = 0x3C = f16 ~= 1.0) + let data = TileData(vec![0x3Cu8; (TILE_SIZE as usize).pow(2) * 8].into_boxed_slice()); + cache.insert(TileCoord { tx: 0, ty: 0 }, data); + cache + }, + }), + }; + tree.add_layer(None, 0, layer).expect("add layer"); + tree +} + +#[test] +fn viewport_default_screen_center_maps_to_pan() { + let vp = CanvasViewport::new(); // pan=(0,0), zoom=1, rotation=0 + let sw = 256u32; + let sh = 256u32; + // Screen center → pan (which is (0,0)) + let center_screen = kurbo::Vec2::new(128.0, 128.0); + let doc = vp.screen_to_doc(center_screen, sw, sh); + assert!( + (doc.x - vp.pan.x).abs() < 1e-9 && (doc.y - vp.pan.y).abs() < 1e-9, + "screen center should map to pan; got {doc:?}" + ); +} + +#[test] +fn viewport_tile_selection_round_trip() { + let sw = 256u32; + let sh = 256u32; + let vp = CanvasViewport::new(); // zoom=1, pan=(0,0), rotation=0 + + // visible_doc_rect should cover the tile (0,0) doc bounds [0..256] × [0..256]. + let rect = vp.visible_doc_rect(sw, sh); + // At zoom=1, pan=(0,0): visible rect is [-128..128] × [-128..128] + // Tile (0,0) covers doc [0..256] × [0..256] — partially visible (0..128 visible). + assert!(rect.x1 > 0.0 && rect.y1 > 0.0, "tile (0,0) should be partially visible"); + + // Round-trip: screen → doc → screen for a sample of points. + for &(sx, sy) in &[(0.0f64, 0.0), (128.0, 128.0), (200.0, 100.0)] { + let screen = kurbo::Vec2::new(sx, sy); + let doc = vp.screen_to_doc(screen, sw, sh); + let back = vp.doc_to_screen(doc, sw, sh); + assert!( + (screen.x - back.x).abs() < 1e-9 && (screen.y - back.y).abs() < 1e-9, + "round-trip failed for screen {screen:?}: got {back:?}" + ); + } +} + +#[test] +fn tile_key_equality_and_hash() { + use std::collections::HashSet; + let id = Uuid::new_v4(); + let c = TileCoord { tx: 3, ty: 7 }; + let k1 = TileKey::new(id, c); + let k2 = TileKey::new(id, c); + let k3 = TileKey::new(Uuid::new_v4(), c); + assert_eq!(k1, k2); + assert_ne!(k1, k3); + let mut set = HashSet::new(); + set.insert(k1); + assert!(set.contains(&k2)); + assert!(!set.contains(&k3)); +} + +#[test] +fn layer_tree_has_one_visible_pixel_layer() { + let tree = make_tree(); + let layers: Vec<_> = tree.iter_depth_first().collect(); + assert_eq!(layers.len(), 1); + assert!(layers[0].visible); + assert!(matches!(layers[0].content, LayerContent::Pixel(_))); +} diff --git a/crates/iris-pixel/src/tree/mod.rs b/crates/iris-pixel/src/tree/mod.rs index 6c21f03..7b126e9 100644 --- a/crates/iris-pixel/src/tree/mod.rs +++ b/crates/iris-pixel/src/tree/mod.rs @@ -20,7 +20,7 @@ pub(super) enum ParentLocation { /// All layers live in a flat [`BTreeMap`]; tree structure is encoded by /// `LayerContent::Group` child-ID lists. Root-level layers are in `root_ids`. // BTreeMap chosen over HashMap for deterministic iteration order (CLAUDE.md). -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct LayerTree { /// Canvas width in pixels. pub canvas_width: u32,