From 8c79ae46daa7a47439650d2bf8bafa06e4e36101 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 02:59:42 +0000 Subject: [PATCH 1/3] Wire appthere-canvas into Iris workspace; open ADR 006 gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exclude crates/appthere-canvas from workspace members (external path dep) - Fix workspace appthere-canvas path (was ../appthere-canvas, now crates/appthere-canvas) - Add features = ["gpu", "font-cache"] to appthere-canvas workspace dep - Uncomment appthere-canvas (+ dioxus feature), vello, wgpu in iris-canvas/Cargo.toml - Replace iris-canvas/src/lib.rs TODO stub: re-export CacheKey, CacheTier, ScrollPhase, ScrollState, GpuTexture, PageSource, RenderError from appthere_canvas; compile-time assertion that TileCoord satisfies CacheKey - ADR/006: status Proposed → Accepted; add Implementation notes section - CLAUDE.md: iris-canvas gate updated to GATE OPEN (ADR 006) cargo check --workspace: zero errors cargo test --workspace: 55 tests, 0 failures https://claude.ai/code/session_01X8kb2QAMhu3VtT8C2CkdYa --- ADR/006-shared-canvas-extraction.md | 20 +- CLAUDE.md | 2 +- Cargo.lock | 470 +++++++++++++++++++++++++++- Cargo.toml | 4 +- crates/iris-canvas/Cargo.toml | 6 +- crates/iris-canvas/src/lib.rs | 37 ++- 6 files changed, 511 insertions(+), 28 deletions(-) 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..3f6403e 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" @@ -610,6 +622,15 @@ dependencies = [ "bit-vec 0.6.3", ] +[[package]] +name = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" +dependencies = [ + "bit-vec 0.7.0", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -625,6 +646,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + [[package]] name = "bit-vec" version = "0.8.0" @@ -672,7 +699,7 @@ dependencies = [ "atomic_refcell", "bitflags 2.11.1", "blitz-traits", - "color", + "color 0.3.3", "cssparser", "cursor-icon", "debug_timer", @@ -735,11 +762,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 +1020,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" @@ -1306,6 +1339,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "d3d12" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdbd1f579714e3c809ebd822c81ef148b1ceaeb3d535352afc73fd0c4c6a0017" +dependencies = [ + "bitflags 2.11.1", + "libloading 0.8.9", + "winapi", +] + [[package]] name = "darling" version = "0.20.11" @@ -2155,6 +2199,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 +2559,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" @@ -2568,6 +2633,19 @@ dependencies = [ "windows 0.52.0", ] +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows 0.52.0", +] + [[package]] name = "gpu-allocator" version = "0.27.0" @@ -3240,11 +3318,14 @@ dependencies = [ name = "iris-canvas" version = "0.1.0" dependencies = [ + "appthere-canvas", "iris-pixel", "iris-vector", "thiserror 2.0.18", "tracing", "uuid", + "vello 0.4.1", + "wgpu 22.1.0", ] [[package]] @@ -4025,6 +4106,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 +4193,48 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec", + "bit-set 0.6.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 = "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 +4992,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 +5023,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 +5539,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 +6155,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 +7248,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 +7276,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 +7296,7 @@ dependencies = [ "fearless_simd", "hashbrown 0.15.5", "log", - "peniko", + "peniko 0.5.0", "skrifa 0.37.0", "smallvec", ] @@ -7110,6 +7311,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 +7332,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 +7359,7 @@ dependencies = [ "log", "naga 26.0.0", "thiserror 2.0.18", - "vello_encoding", + "vello_encoding 0.6.0", ] [[package]] @@ -7503,6 +7729,56 @@ dependencies = [ "wgpu-types 0.19.2", ] +[[package]] +name = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "naga 22.1.0", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core 22.1.0", + "wgpu-hal 22.0.0", + "wgpu-types 22.0.0", +] + +[[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 +7834,56 @@ dependencies = [ "wgpu-types 0.19.2", ] +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec", + "bit-vec 0.7.0", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga 22.1.0", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal 22.0.0", + "wgpu-types 22.0.0", +] + +[[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" @@ -7630,7 +7956,7 @@ dependencies = [ "block", "cfg_aliases 0.1.1", "core-graphics-types 0.1.3", - "d3d12", + "d3d12 0.19.0", "glow 0.13.1", "glutin_wgl_sys 0.5.0", "gpu-alloc", @@ -7661,6 +7987,96 @@ dependencies = [ "winapi", ] +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash 0.38.0+1.3.281", + "bit-set 0.6.0", + "bitflags 2.11.1", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types 0.1.3", + "d3d12 22.0.0", + "glow 0.13.1", + "glutin_wgl_sys 0.6.1", + "gpu-alloc", + "gpu-allocator 0.26.0", + "gpu-descriptor 0.3.2", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal 0.29.0", + "naga 22.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 22.0.0", + "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 +8136,28 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.11.1", + "js-sys", + "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..11de258 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"] } diff --git a/crates/iris-canvas/Cargo.toml b/crates/iris-canvas/Cargo.toml index 55520c7..355c01f 100644 --- a/crates/iris-canvas/Cargo.toml +++ b/crates/iris-canvas/Cargo.toml @@ -13,6 +13,6 @@ 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"] } +vello = { workspace = true } +wgpu = { workspace = true } diff --git a/crates/iris-canvas/src/lib.rs b/crates/iris-canvas/src/lib.rs index 22b8352..92d406d 100644 --- a/crates/iris-canvas/src/lib.rs +++ b/crates/iris-canvas/src/lib.rs @@ -1,11 +1,38 @@ // 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 +// Re-export generic canvas types Iris callers need +pub use appthere_canvas::{ + CacheKey, CacheTier, ScrollPhase, ScrollState, + GpuTexture, PageSource, RenderError, +}; + +// iris-canvas uses TileCoord as its concrete CacheKey. +// TileCoord: struct TileCoord { pub tx: u32, pub ty: u32 } +// Already derives Hash, Eq, Copy — CacheKey blanket impl covers it. +use iris_pixel::TileCoord; + +// Compile-time assertion: TileCoord satisfies CacheKey +const _: () = { + fn _assert() {} + fn _check() { _assert::(); } +}; + +// TODO(iris): SPEC.md §6.2 — Phase 2: CanvasViewport (pan/zoom/rotation) +// pub mod viewport; + +// TODO(iris): SPEC.md §6.2 — Phase 2: pixel tile compositor, 27 blend modes +// pub mod compositor; + +// TODO(iris): SPEC.md §6.2 — Phase 2: Dioxus IrisCanvas component +// pub mod canvas_widget; + +// TODO(iris): SPEC.md §6.2 — Phase 2: overlay pass (selection, guides, artboards) +// pub mod overlay; From 40333927daf02350151f4f58189c7ff4835f9b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 03:50:48 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(iris-canvas):=20implement=20Phase=202?= =?UTF-8?q?=20=E2=80=94=20TileKey,=20CanvasViewport,=20GPU=20compositor,?= =?UTF-8?q?=20IrisCanvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TileKey: (LayerId, TileCoord) composite key satisfying appthere_canvas::CacheKey - CanvasViewport: screen-center-anchored pan, zoom/rotation math, screen↔doc round-trip transforms, visible_doc_rect AABB for culling, zoom_to anchor-stable - Compositor: lazy BlendPass pipeline (Normal blend via PREMULTIPLIED_ALPHA_BLENDING render pipeline), DFS bottom-to-top layer iteration, tile upload to Rgba16Float, tile_params NDC quad corners per viewport transform - IrisCanvas Dioxus component (placeholder div, BLOCKED on appthere-canvas paint hook) with complete IrisPageSource / PageSource implementation - Integration tests: viewport round-trips, TileKey hash, LayerTree structure - Upgrade wgpu workspace dep 22→26 to match appthere-canvas; add bytemuck, dioxus deps - Split compositor/mod.rs under 100-line ceiling into mod.rs + composite.rs - All 11 iris-canvas tests pass; clippy -D warnings clean; cargo test --workspace green https://claude.ai/code/session_01X8kb2QAMhu3VtT8C2CkdYa --- Cargo.lock | 173 +------------- Cargo.toml | 3 +- SPEC.md | 4 +- crates/iris-canvas/Cargo.toml | 7 +- crates/iris-canvas/src/canvas_widget.rs | 126 ++++++++++ .../iris-canvas/src/compositor/composite.rs | 133 +++++++++++ crates/iris-canvas/src/compositor/mod.rs | 66 ++++++ crates/iris-canvas/src/compositor/pass.rs | 193 +++++++++++++++ crates/iris-canvas/src/compositor/upload.rs | 95 ++++++++ crates/iris-canvas/src/key.rs | 41 ++++ crates/iris-canvas/src/lib.rs | 38 ++- .../iris-canvas/src/shaders/normal_blend.wgsl | 65 ++++++ crates/iris-canvas/src/viewport.rs | 221 ++++++++++++++++++ crates/iris-canvas/tests/integration.rs | 106 ++++++++- 14 files changed, 1073 insertions(+), 198 deletions(-) create mode 100644 crates/iris-canvas/src/canvas_widget.rs create mode 100644 crates/iris-canvas/src/compositor/composite.rs create mode 100644 crates/iris-canvas/src/compositor/mod.rs create mode 100644 crates/iris-canvas/src/compositor/pass.rs create mode 100644 crates/iris-canvas/src/compositor/upload.rs create mode 100644 crates/iris-canvas/src/key.rs create mode 100644 crates/iris-canvas/src/shaders/normal_blend.wgsl create mode 100644 crates/iris-canvas/src/viewport.rs diff --git a/Cargo.lock b/Cargo.lock index 3f6403e..47ae968 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -622,15 +622,6 @@ dependencies = [ "bit-vec 0.6.3", ] -[[package]] -name = "bit-set" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" -dependencies = [ - "bit-vec 0.7.0", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -646,12 +637,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" -[[package]] -name = "bit-vec" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" - [[package]] name = "bit-vec" version = "0.8.0" @@ -1339,17 +1324,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "d3d12" -version = "22.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdbd1f579714e3c809ebd822c81ef148b1ceaeb3d535352afc73fd0c4c6a0017" -dependencies = [ - "bitflags 2.11.1", - "libloading 0.8.9", - "winapi", -] - [[package]] name = "darling" version = "0.20.11" @@ -2633,19 +2607,6 @@ dependencies = [ "windows 0.52.0", ] -[[package]] -name = "gpu-allocator" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" -dependencies = [ - "log", - "presser", - "thiserror 1.0.69", - "winapi", - "windows 0.52.0", -] - [[package]] name = "gpu-allocator" version = "0.27.0" @@ -3319,13 +3280,16 @@ name = "iris-canvas" version = "0.1.0" dependencies = [ "appthere-canvas", + "bytemuck", + "dioxus", "iris-pixel", "iris-vector", + "kurbo 0.11.3", "thiserror 2.0.18", "tracing", "uuid", "vello 0.4.1", - "wgpu 22.1.0", + "wgpu 26.0.1", ] [[package]] @@ -4193,27 +4157,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "naga" -version = "22.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" -dependencies = [ - "arrayvec", - "bit-set 0.6.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 = "23.1.0" @@ -7729,31 +7672,6 @@ dependencies = [ "wgpu-types 0.19.2", ] -[[package]] -name = "wgpu" -version = "22.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" -dependencies = [ - "arrayvec", - "cfg_aliases 0.1.1", - "document-features", - "js-sys", - "log", - "naga 22.1.0", - "parking_lot", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core 22.1.0", - "wgpu-hal 22.0.0", - "wgpu-types 22.0.0", -] - [[package]] name = "wgpu" version = "23.0.1" @@ -7834,31 +7752,6 @@ dependencies = [ "wgpu-types 0.19.2", ] -[[package]] -name = "wgpu-core" -version = "22.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" -dependencies = [ - "arrayvec", - "bit-vec 0.7.0", - "bitflags 2.11.1", - "cfg_aliases 0.1.1", - "document-features", - "indexmap", - "log", - "naga 22.1.0", - "once_cell", - "parking_lot", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 1.0.69", - "wgpu-hal 22.0.0", - "wgpu-types 22.0.0", -] - [[package]] name = "wgpu-core" version = "23.0.1" @@ -7956,7 +7849,7 @@ dependencies = [ "block", "cfg_aliases 0.1.1", "core-graphics-types 0.1.3", - "d3d12 0.19.0", + "d3d12", "glow 0.13.1", "glutin_wgl_sys 0.5.0", "gpu-alloc", @@ -7987,51 +7880,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "wgpu-hal" -version = "22.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash 0.38.0+1.3.281", - "bit-set 0.6.0", - "bitflags 2.11.1", - "block", - "cfg_aliases 0.1.1", - "core-graphics-types 0.1.3", - "d3d12 22.0.0", - "glow 0.13.1", - "glutin_wgl_sys 0.6.1", - "gpu-alloc", - "gpu-allocator 0.26.0", - "gpu-descriptor 0.3.2", - "hassle-rs", - "js-sys", - "khronos-egl", - "libc", - "libloading 0.8.9", - "log", - "metal 0.29.0", - "naga 22.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 22.0.0", - "winapi", -] - [[package]] name = "wgpu-hal" version = "23.0.1" @@ -8136,17 +7984,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wgpu-types" -version = "22.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" -dependencies = [ - "bitflags 2.11.1", - "js-sys", - "web-sys", -] - [[package]] name = "wgpu-types" version = "23.0.0" diff --git a/Cargo.toml b/Cargo.toml index 11de258..d4ba48d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 355c01f..43578a2 100644 --- a/crates/iris-canvas/Cargo.toml +++ b/crates/iris-canvas/Cargo.toml @@ -14,5 +14,8 @@ uuid = { workspace = true } iris-pixel = { workspace = true } iris-vector = { workspace = true } appthere-canvas = { workspace = true, features = ["dioxus"] } -vello = { workspace = true } -wgpu = { workspace = true } +dioxus = { workspace = true } +vello = { workspace = true } +wgpu = { workspace = true } +kurbo = { workspace = true } +bytemuck = { workspace = true } diff --git a/crates/iris-canvas/src/canvas_widget.rs b/crates/iris-canvas/src/canvas_widget.rs new file mode 100644 index 0000000..0f4afb5 --- /dev/null +++ b/crates/iris-canvas/src/canvas_widget.rs @@ -0,0 +1,126 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`IrisCanvas`] — Dioxus component that composites a [`LayerTree`] into a +//! wgpu-rendered canvas element. +//! +//! # BLOCKING — appthere-canvas Dioxus paint hook not yet available +//! +//! `appthere-canvas/src/dioxus/` contains only `scroll_driver.rs` (settle +//! detector). There is no Dioxus hook or component that calls +//! `PageSource::render()` and presents the resulting `GpuTexture` to the +//! Dioxus/Blitz render tree. +//! +//! The `PageSource` implementation on [`IrisPageSource`] is complete +//! and correct. The blocking gap is the bridge from Dioxus component → wgpu +//! device acquisition → `render()` invocation. Until appthere-canvas ships +//! that bridge (e.g. a `use_canvas_paint` hook), `IrisCanvas` renders an +//! empty placeholder div. +//! +// TODO(iris): SPEC.md §6.2 — BLOCKED: implement use_canvas_paint (or equivalent) +// in appthere-canvas/src/dioxus/ that calls PageSource::render() and feeds +// the GpuTexture to Dioxus Native's CustomPaintSource. Then wire IrisCanvas +// to call it here. + +use std::sync::{Arc, Mutex}; + +use dioxus::prelude::*; +use iris_pixel::LayerTree; + +use crate::compositor::Compositor; +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. +/// +/// Phase 2 renders an empty placeholder until the appthere-canvas Dioxus paint +/// hook is available (see module-level BLOCKED comment above). +/// +/// Hooks rule (lesson from iris-aif): every `use_*` call is at the top level of +/// the component body — never inside a closure, conditional, or loop. +#[component] +pub fn IrisCanvas(props: IrisCanvasProps) -> Element { + // Lazily-initialised compositor, shared with the PageSource impl. + // Stored in use_hook so it survives re-renders without reinitialisation. + let compositor = use_hook(|| Arc::new(Mutex::new(Compositor::new()))); + + // TODO(iris): SPEC.md §6.2 — Phase 2+: use_settle_detector for quality-tier + // promotion. Wire when the appthere-canvas Dioxus paint hook is available. + // let (task, _tx) = use_settle_detector(scroll_signal, || compositor.mark_all_dirty()); + // use_drop(move || task.cancel()); + + // TODO(iris): SPEC.md §6.2 — BLOCKED: mount IrisPageSource via + // appthere_canvas::dioxus::use_canvas_paint (not yet implemented in + // appthere-canvas). Once available, replace the placeholder div below. + let _ = compositor; // suppress unused warning until paint hook is wired + + rsx! { + div { + style: "width: {props.width}px; height: {props.height}px; background: #1a1a1a;", + // TODO(iris): SPEC.md §6.2 — BLOCKED: replace placeholder div with + // wgpu-rendered canvas once appthere-canvas ships a Dioxus paint hook. + } + } +} + +/// [`appthere_canvas::PageSource`] implementation for the Iris compositor. +/// +/// The unit key `()` represents the single composited viewport — there is +/// exactly one "page" per canvas view (Q4 decision from audit). +/// +/// The `render()` call triggers the full compositor pass: iterate visible +/// layers, upload tiles, blend via Normal-mode render pipeline, return texture. +pub struct IrisPageSource { + compositor: Arc>, + width: u32, + height: u32, + // The viewport is cloned at render time from the Dioxus signal read. + 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 92d406d..2bcd610 100644 --- a/crates/iris-canvas/src/lib.rs +++ b/crates/iris-canvas/src/lib.rs @@ -8,31 +8,21 @@ #![forbid(unsafe_code)] -// Re-export generic canvas types Iris callers need -pub use appthere_canvas::{ - CacheKey, CacheTier, ScrollPhase, ScrollState, - GpuTexture, PageSource, RenderError, -}; +pub mod canvas_widget; +pub mod key; +pub(crate) mod compositor; +pub mod viewport; -// iris-canvas uses TileCoord as its concrete CacheKey. -// TileCoord: struct TileCoord { pub tx: u32, pub ty: u32 } -// Already derives Hash, Eq, Copy — CacheKey blanket impl covers it. -use iris_pixel::TileCoord; +// TODO(iris): SPEC.md §6.2 — Phase 2: overlay pass (selection, guides, artboards) +// pub mod overlay; -// Compile-time assertion: TileCoord satisfies CacheKey -const _: () = { - fn _assert() {} - fn _check() { _assert::(); } +// Re-export generic canvas types Iris callers need +pub use appthere_canvas::{ + CacheKey, CacheTier, GpuTexture, PageSource, RenderError, ScrollPhase, ScrollState, }; -// TODO(iris): SPEC.md §6.2 — Phase 2: CanvasViewport (pan/zoom/rotation) -// pub mod viewport; - -// TODO(iris): SPEC.md §6.2 — Phase 2: pixel tile compositor, 27 blend modes -// pub mod compositor; - -// TODO(iris): SPEC.md §6.2 — Phase 2: Dioxus IrisCanvas component -// pub mod canvas_widget; - -// TODO(iris): SPEC.md §6.2 — Phase 2: overlay pass (selection, guides, artboards) -// pub mod overlay; +// 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/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(_))); +} From b02a79505afdfc072ae6be6849d70e5c98132d2b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 04:55:23 +0000 Subject: [PATCH 3/3] feat(iris-canvas): wire IrisCanvas to Dioxus Native via CustomPaintSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add anyrender_vello = "0.6.2" dep to iris-canvas (Option B: bridge in iris-canvas) - New paint_bridge.rs: IrisCanvasPaintSource implements anyrender_vello::CustomPaintSource - resume(): clones DeviceHandle (wgpu::Device + Queue are Clone in wgpu 26) - suspend(): drops device handle and last registered TextureHandle - render(): reads viewport/tree from Arc>, calls compositor.composite(), unregisters old TextureHandle, calls ctx.register_texture(gpu_texture.inner), returns Some(TextureHandle) — None skips the frame (Blitz reuses last texture) - Update canvas_widget.rs: replace placeholder div with working use_wgpu integration - shared_viewport and shared_tree are Arc> updated on each Dioxus re-render - use_wgpu captures cloned Arcs via FnOnce; auto-unregisters on component drop - RSX: (quoted attr bypasses Dioxus schema; blitz-dom reads "src" on elements to load custom paint sources) - Add #[derive(Clone)] to LayerTree in iris-pixel (all fields already Clone) - All 11 iris-canvas tests pass; clippy -D warnings clean; cargo test --workspace green API discoveries vs audit assumptions: - DeviceHandle.device / .queue are public fields (not getters) — direct field access - TextureHandle: Clone — can clone for storage while returning from render() - anyrender_vello uses wgpu = "26" — same as iris-canvas, types compatible - use_wgpu is at dioxus::native::use_wgpu (not dioxus::prelude) - canvas src attribute must be quoted "src" in RSX (not in Dioxus element schema) https://claude.ai/code/session_01X8kb2QAMhu3VtT8C2CkdYa --- Cargo.lock | 1 + crates/iris-canvas/Cargo.toml | 1 + crates/iris-canvas/src/canvas_widget.rs | 92 +++++++++++++----------- crates/iris-canvas/src/lib.rs | 1 + crates/iris-canvas/src/paint_bridge.rs | 93 +++++++++++++++++++++++++ crates/iris-pixel/src/tree/mod.rs | 2 +- 6 files changed, 148 insertions(+), 42 deletions(-) create mode 100644 crates/iris-canvas/src/paint_bridge.rs diff --git a/Cargo.lock b/Cargo.lock index 47ae968..5be5c8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3279,6 +3279,7 @@ dependencies = [ name = "iris-canvas" version = "0.1.0" dependencies = [ + "anyrender_vello", "appthere-canvas", "bytemuck", "dioxus", diff --git a/crates/iris-canvas/Cargo.toml b/crates/iris-canvas/Cargo.toml index 43578a2..df635df 100644 --- a/crates/iris-canvas/Cargo.toml +++ b/crates/iris-canvas/Cargo.toml @@ -19,3 +19,4 @@ 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 index 0f4afb5..fe48066 100644 --- a/crates/iris-canvas/src/canvas_widget.rs +++ b/crates/iris-canvas/src/canvas_widget.rs @@ -2,32 +2,32 @@ // SPDX-License-Identifier: Apache-2.0 //! [`IrisCanvas`] — Dioxus component that composites a [`LayerTree`] into a -//! wgpu-rendered canvas element. +//! wgpu-rendered canvas element via Dioxus Native's `use_wgpu` hook. //! -//! # BLOCKING — appthere-canvas Dioxus paint hook not yet available +//! # Architecture //! -//! `appthere-canvas/src/dioxus/` contains only `scroll_driver.rs` (settle -//! detector). There is no Dioxus hook or component that calls -//! `PageSource::render()` and presents the resulting `GpuTexture` to the -//! Dioxus/Blitz render tree. +//! 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). //! -//! The `PageSource` implementation on [`IrisPageSource`] is complete -//! and correct. The blocking gap is the bridge from Dioxus component → wgpu -//! device acquisition → `render()` invocation. Until appthere-canvas ships -//! that bridge (e.g. a `use_canvas_paint` hook), `IrisCanvas` renders an -//! empty placeholder div. -//! -// TODO(iris): SPEC.md §6.2 — BLOCKED: implement use_canvas_paint (or equivalent) -// in appthere-canvas/src/dioxus/ that calls PageSource::render() and feeds -// the GpuTexture to Dioxus Native's CustomPaintSource. Then wire IrisCanvas -// to call it here. +//! 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. @@ -45,48 +45,58 @@ pub struct IrisCanvasProps { /// Dioxus component for the Iris infinite canvas. /// -/// Phase 2 renders an empty placeholder until the appthere-canvas Dioxus paint -/// hook is available (see module-level BLOCKED comment above). -/// -/// Hooks rule (lesson from iris-aif): every `use_*` call is at the top level of -/// the component body — never inside a closure, conditional, or loop. +/// 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 PageSource impl. - // Stored in use_hook so it survives re-renders without reinitialisation. + // Lazily-initialised compositor, shared with the paint source. let compositor = use_hook(|| Arc::new(Mutex::new(Compositor::new()))); - // TODO(iris): SPEC.md §6.2 — Phase 2+: use_settle_detector for quality-tier - // promotion. Wire when the appthere-canvas Dioxus paint hook is available. - // let (task, _tx) = use_settle_detector(scroll_signal, || compositor.mark_all_dirty()); - // use_drop(move || task.cancel()); + // 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(); + } - // TODO(iris): SPEC.md §6.2 — BLOCKED: mount IrisPageSource via - // appthere_canvas::dioxus::use_canvas_paint (not yet implemented in - // appthere-canvas). Once available, replace the placeholder div below. - let _ = compositor; // suppress unused warning until paint hook is wired + // 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! { - div { - style: "width: {props.width}px; height: {props.height}px; background: #1a1a1a;", - // TODO(iris): SPEC.md §6.2 — BLOCKED: replace placeholder div with - // wgpu-rendered canvas once appthere-canvas ships a Dioxus paint hook. + 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 — there is -/// exactly one "page" per canvas view (Q4 decision from audit). -/// -/// The `render()` call triggers the full compositor pass: iterate visible -/// layers, upload tiles, blend via Normal-mode render pipeline, return texture. +/// 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, - // The viewport is cloned at render time from the Dioxus signal read. viewport: CanvasViewport, tree: LayerTree, } diff --git a/crates/iris-canvas/src/lib.rs b/crates/iris-canvas/src/lib.rs index 2bcd610..969467b 100644 --- a/crates/iris-canvas/src/lib.rs +++ b/crates/iris-canvas/src/lib.rs @@ -11,6 +11,7 @@ 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) 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-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,