diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9fd45e0..904dd5b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -16,6 +16,18 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwayland-dev \ + libudev-dev \ + libxkbcommon-dev \ + libx11-dev \ + libxi-dev \ + libxrandr-dev \ + libxcursor-dev \ + libgl1-mesa-dev - name: Build run: cargo build --verbose - name: Run tests diff --git a/Cargo.lock b/Cargo.lock index 1982cad..b228c9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3242,6 +3242,8 @@ dependencies = [ "dioxus", "iris-canvas", "iris-pixel", + "iris-tools", + "kurbo 0.11.3", "tracing", "tracing-subscriber", "uuid", @@ -3347,6 +3349,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "iris-tools" +version = "0.1.0" +dependencies = [ + "iris-canvas", + "iris-ops", + "iris-pixel", + "kurbo 0.11.3", + "thiserror 2.0.18", + "tracing", + "uuid", +] + [[package]] name = "iris-vector" version = "0.1.0" @@ -7755,10 +7770,9 @@ dependencies = [ [[package]] name = "wgpu_context" version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0f49e6f733fcc61e41a5ecdb36910baa5148497036784bca319289bfdca6141" dependencies = [ "futures-intrusive", + "tracing", "wgpu 26.0.1", ] @@ -8853,7 +8867,3 @@ dependencies = [ "syn 2.0.117", "winnow 1.0.3", ] - -[[patch.unused]] -name = "fontique" -version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 890418d..572c09f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/iris-ai", "crates/iris-svg", "crates/iris-plugin-api", + "crates/iris-tools", "tools/aif-inspect", ] @@ -42,6 +43,7 @@ iris-vector = { path = "crates/iris-vector" } iris-aif = { path = "crates/iris-aif" } iris-canvas = { path = "crates/iris-canvas" } iris-plugin-api = { path = "crates/iris-plugin-api" } +iris-tools = { path = "crates/iris-tools" } # External dependencies dioxus = { version = "=0.7.4", features = ["native"] } @@ -66,10 +68,9 @@ bytemuck = { version = "1", features = ["derive"] } png = { version = "0.17" } [patch.crates-io] -# PATCH: fixes missing fontconfig_sys alias and dlopen/static feature-unification -# conflict with blitz-dom's fontique 0.6 — remove when fontique >0.8.0 on -# crates.io restores the alias and resolves the linkage conflict. -fontique = { path = "crates/patches/fontique" } +# COMPAT(dioxus): fontique patch removed — blitz-dom 0.2.4 resolves fontique 0.6.0 +# from crates.io directly; the 0.8.0-based patch was version-mismatched and never +# applied. Re-evaluate if fontconfig_sys linkage errors appear on CI Linux. # PATCH: vendors dioxus-native-dom to avoid runtime unimplemented!() panics in # HtmlEventConverter (composition, touch, scroll, etc.) — remove when upstream # implements the converters Loki requires. @@ -81,6 +82,11 @@ blitz-dom = { path = "crates/patches/blitz-dom" } # PATCH: forwards WindowEvent::Touch to Dioxus touch handlers. # Remove when blitz-shell implements touch natively. blitz-shell = { path = "crates/patches/blitz-shell" } +# PATCH: handle SurfaceError::Outdated/Lost gracefully on Windows/DX12. +# Without this, registering a CustomPaintSource via use_wgpu panics on the +# first render cycle when the swap chain is not yet ready. Remove when +# wgpu_context upstream fixes the TODO at surface_renderer.rs:228. +wgpu_context = { path = "crates/patches/wgpu-context" } [profile.dev] opt-level = 1 # faster compiles, still debuggable diff --git a/crates/iris-app/Cargo.toml b/crates/iris-app/Cargo.toml index 885b586..1a253b1 100644 --- a/crates/iris-app/Cargo.toml +++ b/crates/iris-app/Cargo.toml @@ -15,6 +15,8 @@ path = "src/main.rs" appthere-ui = { workspace = true } iris-canvas = { workspace = true } iris-pixel = { workspace = true } +iris-tools = { workspace = true } +kurbo = { workspace = true } dioxus = { workspace = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/iris-app/src/canvas_area.rs b/crates/iris-app/src/canvas_area.rs index 47ac26b..78c9eaa 100644 --- a/crates/iris-app/src/canvas_area.rs +++ b/crates/iris-app/src/canvas_area.rs @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use appthere_ui::tokens::colors::{COLOR_SURFACE_BASE, COLOR_TEXT_ON_CHROME_SECONDARY}; +use appthere_ui::tokens::layout::{ + RIBBON_TOTAL_HEIGHT, STATUS_BAR_HEIGHT, TAB_BAR_HEIGHT, TITLE_BAR_HEIGHT_MACOS, +}; use appthere_ui::tokens::typography::FONT_SIZE_BODY; use dioxus::prelude::*; use iris_canvas::IrisCanvas; @@ -9,14 +12,19 @@ use iris_pixel::LayerTree; use crate::state::AppState; -// TODO(iris): SPEC.md §11.3 — measure actual CanvasArea dimensions via onmounted -const CANVAS_WIDTH: u32 = 800; -const CANVAS_HEIGHT: u32 = 600; +// Chrome dimensions used to derive the canvas rendered area from window size. +// Left/right panel widths are defined in tool_palette.rs / layers_panel.rs; +// duplicated here until they are promoted to appthere-ui layout tokens. +// TODO(iris): SPEC.md §11.3 — move to appthere-ui layout tokens +const CHROME_LEFT: u32 = 56; // TOOL_PALETTE_WIDTH +const CHROME_RIGHT: u32 = 240; // LAYERS_PANEL_WIDTH +const CHROME_TOP: u32 = (TITLE_BAR_HEIGHT_MACOS + TAB_BAR_HEIGHT + RIBBON_TOTAL_HEIGHT) as u32; +const CHROME_BOTTOM: u32 = STATUS_BAR_HEIGHT as u32; #[component] pub fn CanvasArea(mut state: Signal) -> Element { // All hooks called unconditionally before any early return (Dioxus rules). - let tree_signal = use_signal(move || { + let mut tree_signal = use_signal(move || { state .read() .document @@ -41,6 +49,34 @@ pub fn CanvasArea(mut state: Signal) -> Element { } }); + // Sync tree_signal → IrisCanvas when a stroke has painted new tile data. + // canvas_dirty is set by tool_dispatch after each Down/Move/Up event. + use_effect(move || { + if state.read().canvas_dirty { + let new_tree = state + .read() + .document + .as_ref() + .map(|d| d.tree.clone()) + .unwrap_or_else(|| LayerTree::new(1, 1, 96.0, 96.0)); + *tree_signal.write() = new_tree; + state.write().canvas_dirty = false; + } + }); + + // Compute canvas rendered size = window size minus shell chrome. + // TODO(iris): SPEC.md §11.3 — replace hardcoded window size with a real + // window-size hook once dioxus-native exposes one (winit PhysicalSize is + // available but no Dioxus hook wraps it yet). + let window_w = use_signal(|| 1280u32); + let window_h = use_signal(|| 800u32); + let canvas_w = use_memo(move || { + window_w().saturating_sub(CHROME_LEFT + CHROME_RIGHT).max(100) + }); + let canvas_h = use_memo(move || { + window_h().saturating_sub(CHROME_TOP + CHROME_BOTTOM).max(100) + }); + let doc_exists = state.read().document.is_some(); // Early return AFTER all hooks. @@ -62,8 +98,11 @@ pub fn CanvasArea(mut state: Signal) -> Element { IrisCanvas { tree: tree_signal, viewport: viewport_signal, - width: CANVAS_WIDTH, - height: CANVAS_HEIGHT, + width: canvas_w(), + height: canvas_h(), + on_tool_event: move |evt| { + crate::tool_dispatch::dispatch_tool_event(evt, state); + }, } } } diff --git a/crates/iris-app/src/main.rs b/crates/iris-app/src/main.rs index 994e026..4ddf5d4 100644 --- a/crates/iris-app/src/main.rs +++ b/crates/iris-app/src/main.rs @@ -9,6 +9,7 @@ mod layout; mod ribbon; mod state; mod status_bar; +mod tool_dispatch; mod tool_palette; use app::App; diff --git a/crates/iris-app/src/state.rs b/crates/iris-app/src/state.rs index 8f5770b..97b3f93 100644 --- a/crates/iris-app/src/state.rs +++ b/crates/iris-app/src/state.rs @@ -7,12 +7,57 @@ use iris_pixel::{ BitDepth, BlendMode, ChannelLayout, ExrCompression, Layer, LayerContent, LayerId, LayerTree, PixelLayer, TileCache, LINEAR_SRGB, }; +use iris_tools::{BrushEngine, BrushSettings, EraserEngine}; + +/// Which pixel sub-tool is active within [`ToolMode::Pixel`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PixelTool { + #[default] + Brush, + Eraser, + Eyedropper, + Fill, + Marquee, +} + +/// Active marquee selection in document space. `None` = no selection (paint everywhere). +#[derive(Debug, Clone, Default)] +pub struct Selection { + pub rect: Option, +} + +/// Active pixel-tool state, stored in AppState so dispatch_tool_event can mutate it. +#[derive(Clone)] +pub struct PixelToolState { + pub brush: BrushEngine, + pub eraser: EraserEngine, + /// Flood-fill colour-similarity threshold (0.0–1.0 Euclidean RGB distance). + pub fill_tolerance: f32, +} + +impl Default for PixelToolState { + fn default() -> Self { + Self { brush: BrushEngine::default(), eraser: EraserEngine::default(), fill_tolerance: 0.1 } + } +} #[derive(Clone)] pub struct AppState { pub document: Option, pub tool_mode: ToolMode, + pub active_pixel_tool: PixelTool, pub selected_layer: Option, + pub pixel_tool_state: PixelToolState, + /// Foreground (paint) colour in linear RGBA. Default: opaque black. + pub foreground_color: [f32; 4], + /// Background colour in linear RGBA. Default: opaque white. + pub background_color: [f32; 4], + /// Current marquee selection (document pixels). Updated by the Marquee tool. + pub selection: Selection, + /// Drag origin for the marquee tool; cleared on ToolEvent::Up. + pub marquee_start: Option, + /// Set to `true` when a stroke dirties tiles; canvas_area.rs syncs tree_signal. + pub canvas_dirty: bool, pub active_tab_index: usize, pub platform: Platform, } @@ -36,10 +81,19 @@ pub enum ToolMode { impl Default for AppState { fn default() -> Self { + let doc = OpenDocument::new_blank(800, 600, "Untitled"); + let selected_layer = doc.tree.root_layer_ids().first().copied(); Self { - document: Some(OpenDocument::new_blank(800, 600, "Untitled")), + document: Some(doc), tool_mode: ToolMode::Pixel, - selected_layer: None, + active_pixel_tool: PixelTool::Brush, + selected_layer, + pixel_tool_state: PixelToolState::default(), + foreground_color: [0.0, 0.0, 0.0, 1.0], + background_color: [1.0, 1.0, 1.0, 1.0], + selection: Selection::default(), + marquee_start: None, + canvas_dirty: false, active_tab_index: 1, platform: detect_platform(), } @@ -71,13 +125,9 @@ impl OpenDocument { }; tree.add_layer(None, 0, layer) .expect("new_blank: adding initial layer to empty tree cannot fail"); - Self { - title: title.to_string(), - path: None, - tree, - viewport: CanvasViewport::new(), - dirty: false, - } + let mut viewport = CanvasViewport::new(); + viewport.pan = kurbo::Vec2::new(width_px as f64 / 2.0, height_px as f64 / 2.0); + Self { title: title.to_string(), path: None, tree, viewport, dirty: false } } pub fn zoom_percent(&self) -> u32 { diff --git a/crates/iris-app/src/tool_dispatch.rs b/crates/iris-app/src/tool_dispatch.rs new file mode 100644 index 0000000..34fffe5 --- /dev/null +++ b/crates/iris-app/src/tool_dispatch.rs @@ -0,0 +1,164 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +use dioxus::prelude::{ReadableExt, Signal, WritableExt}; +use iris_canvas::ToolEvent; + +use crate::state::{AppState, PixelTool, ToolMode}; + +/// Route a [`ToolEvent`] from the canvas to the active tool handler. +pub fn dispatch_tool_event(event: ToolEvent, state: Signal) { + let tool_mode = state.read().tool_mode; + match tool_mode { + ToolMode::Pixel => dispatch_pixel_tool(event, state), + ToolMode::Vector => { + // TODO(iris): SPEC.md §7.2 — Phase 3C: vector tool dispatch + tracing::debug!("vector tool event (Phase 3C+): {:?}", event); + } + } +} + +fn dispatch_pixel_tool(event: ToolEvent, mut state: Signal) { + let pixel_tool = state.read().active_pixel_tool; + match &event { + ToolEvent::Down { doc_pos, .. } => match pixel_tool { + PixelTool::Brush => brush_down(event, state), + PixelTool::Eraser => eraser_down(event, state), + PixelTool::Eyedropper => { + let mut s = state.write(); + let color = s.document.as_ref() + .map(|d| iris_tools::eyedropper::sample_color(*doc_pos, &d.tree)) + .unwrap_or([0.0, 0.0, 0.0, 1.0]); + s.foreground_color = color; + s.pixel_tool_state.brush.settings.color = color; + tracing::debug!(r=color[0], g=color[1], b=color[2], a=color[3], "eyedropper sample"); + } + PixelTool::Fill => { + let mut s = state.write(); + let layer_id = match s.selected_layer { Some(id) => id, None => return }; + let color = s.foreground_color; + let tolerance = s.pixel_tool_state.fill_tolerance; + let doc_rect = s.document.as_ref().map(|d| { + kurbo::Rect::new(0.0, 0.0, d.tree.canvas_width as f64, d.tree.canvas_height as f64) + }).unwrap_or_default(); + if let Some(doc) = s.document.as_mut() { + if let Some(layer) = doc.tree.get_mut(layer_id) { + iris_tools::fill::flood_fill(*doc_pos, color, tolerance, layer, layer_id, doc_rect); + doc.dirty = true; + } + } + s.canvas_dirty = true; + } + PixelTool::Marquee => { + let mut s = state.write(); + s.marquee_start = Some(*doc_pos); + s.selection.rect = None; + } + }, + + ToolEvent::Move { doc_pos, .. } => match pixel_tool { + PixelTool::Brush => brush_move(event, state), + PixelTool::Eraser => eraser_move(event, state), + PixelTool::Marquee => { + let mut s = state.write(); + if let Some(start) = s.marquee_start { + s.selection.rect = Some(kurbo::Rect::new( + start.x.min(doc_pos.x), start.y.min(doc_pos.y), + start.x.max(doc_pos.x), start.y.max(doc_pos.y), + )); + s.canvas_dirty = true; + } + } + _ => {} + }, + + ToolEvent::Up { .. } => match pixel_tool { + PixelTool::Brush => { + let mut s = state.write(); + let dirty = s.pixel_tool_state.brush.on_up(); + s.canvas_dirty = true; + tracing::debug!("brush stroke ended; {} tiles dirtied", dirty.len()); + } + PixelTool::Eraser => { + let mut s = state.write(); + let dirty = s.pixel_tool_state.eraser.on_up(); + s.canvas_dirty = true; + tracing::debug!("eraser stroke ended; {} tiles dirtied", dirty.len()); + } + PixelTool::Marquee => { + state.write().marquee_start = None; + } + _ => {} + }, + + ToolEvent::Scroll { delta_x, delta_y } => { + tracing::debug!("pixel Scroll dx={:.1} dy={:.1}", delta_x, delta_y); + } + ToolEvent::Pinch { scale_factor, anchor_screen } => { + tracing::debug!( + "pixel Pinch scale={:.3} anchor=({:.1},{:.1})", + scale_factor, anchor_screen.x, anchor_screen.y + ); + } + } +} + +fn brush_down(event: ToolEvent, mut state: Signal) { + let mut s = state.write(); + let layer_id = match s.selected_layer { Some(id) => id, None => return }; + let mut pts = std::mem::take(&mut s.pixel_tool_state); + pts.brush.settings.selection = s.selection.rect; + if let Some(doc) = s.document.as_mut() { + if let Some(layer) = doc.tree.get_mut(layer_id) { + pts.brush.on_down(&event, layer, layer_id); + doc.dirty = true; + } + } + s.pixel_tool_state = pts; + s.canvas_dirty = true; +} + +fn brush_move(event: ToolEvent, mut state: Signal) { + let mut s = state.write(); + let layer_id = match s.selected_layer { Some(id) => id, None => return }; + let mut pts = std::mem::take(&mut s.pixel_tool_state); + pts.brush.settings.selection = s.selection.rect; + if let Some(doc) = s.document.as_mut() { + if let Some(layer) = doc.tree.get_mut(layer_id) { + pts.brush.on_move(&event, layer, layer_id); + doc.dirty = true; + } + } + s.pixel_tool_state = pts; + s.canvas_dirty = true; +} + +fn eraser_down(event: ToolEvent, mut state: Signal) { + let mut s = state.write(); + let layer_id = match s.selected_layer { Some(id) => id, None => return }; + let mut pts = std::mem::take(&mut s.pixel_tool_state); + pts.eraser.settings_mut().selection = s.selection.rect; + if let Some(doc) = s.document.as_mut() { + if let Some(layer) = doc.tree.get_mut(layer_id) { + pts.eraser.on_down(&event, layer, layer_id); + doc.dirty = true; + } + } + s.pixel_tool_state = pts; + s.canvas_dirty = true; +} + +fn eraser_move(event: ToolEvent, mut state: Signal) { + let mut s = state.write(); + let layer_id = match s.selected_layer { Some(id) => id, None => return }; + let mut pts = std::mem::take(&mut s.pixel_tool_state); + pts.eraser.settings_mut().selection = s.selection.rect; + if let Some(doc) = s.document.as_mut() { + if let Some(layer) = doc.tree.get_mut(layer_id) { + pts.eraser.on_move(&event, layer, layer_id); + doc.dirty = true; + } + } + s.pixel_tool_state = pts; + s.canvas_dirty = true; +} diff --git a/crates/iris-app/src/tool_palette.rs b/crates/iris-app/src/tool_palette.rs index 25a9f9e..f0d1629 100644 --- a/crates/iris-app/src/tool_palette.rs +++ b/crates/iris-app/src/tool_palette.rs @@ -9,64 +9,141 @@ use appthere_ui::tokens::spacing::{RADIUS_SM, SPACE_2}; use appthere_ui::tokens::typography::{FONT_SIZE_LABEL, FONT_WEIGHT_SEMIBOLD}; use dioxus::prelude::*; -use crate::state::{AppState, ToolMode}; +use crate::state::{AppState, PixelTool, ToolMode}; -// TODO(iris): move TOOL_PALETTE_WIDTH to appthere_ui layout tokens const TOOL_PALETTE_WIDTH: f32 = 56.0; #[component] pub fn ToolPalette(mut state: Signal) -> Element { - let tool_mode = state.read().tool_mode; + let tool_mode = state.read().tool_mode; + let pixel_tool = state.read().active_pixel_tool; + let fg = state.read().foreground_color; + let bg = state.read().background_color; - let pixel_bg = if tool_mode == ToolMode::Pixel { COLOR_ACCENT_PRIMARY } else { COLOR_SURFACE_1 }; - let vector_bg = if tool_mode == ToolMode::Vector { COLOR_ACCENT_PRIMARY } else { COLOR_SURFACE_1 }; + let active_bg = COLOR_ACCENT_PRIMARY; + let inactive_bg = COLOR_SURFACE_1; - let btn_style = |bg: &str| { + let btn = |active: bool| -> String { + let bg = if active { active_bg } else { inactive_bg }; format!( "width: 100%; padding: {p}px 0; background-color: {bg}; \ color: {fg}; border: none; border-radius: {r}px; cursor: pointer; \ font-size: {size}px; font-weight: {weight}; margin-bottom: {p}px;", - p = SPACE_2, - bg = bg, - fg = COLOR_TEXT_ON_CHROME, - r = RADIUS_SM, - size = FONT_SIZE_LABEL, - weight = FONT_WEIGHT_SEMIBOLD, + p = SPACE_2, bg = bg, fg = COLOR_TEXT_ON_CHROME, + r = RADIUS_SM, size = FONT_SIZE_LABEL, weight = FONT_WEIGHT_SEMIBOLD, ) }; - - let disabled_btn_style = format!( + let disabled = format!( "width: 100%; padding: {p}px 0; background-color: {bg}; \ color: {fg}; border: none; border-radius: {r}px; cursor: not-allowed; \ font-size: {size}px; font-weight: {weight}; margin-bottom: {p}px; opacity: 0.5;", - p = SPACE_2, - bg = COLOR_SURFACE_1, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - r = RADIUS_SM, - size = FONT_SIZE_LABEL, - weight = FONT_WEIGHT_SEMIBOLD, + p = SPACE_2, bg = COLOR_SURFACE_1, fg = COLOR_TEXT_ON_CHROME_SECONDARY, + r = RADIUS_SM, size = FONT_SIZE_LABEL, weight = FONT_WEIGHT_SEMIBOLD, ); + let fg_css = srgb_css(fg); + let bg_css = srgb_css(bg); + + let swatch_style = |color_css: &str| -> String { + format!( + "width: 36px; height: 18px; background-color: {color_css}; \ + border: 1px solid {border}; border-radius: 2px; cursor: pointer; \ + margin: 1px auto; display: block;", + color_css = color_css, border = COLOR_BORDER_CHROME, + ) + }; + rsx! { div { style: "width: {TOOL_PALETTE_WIDTH}px; background-color: {COLOR_SURFACE_1}; \ display: flex; flex-direction: column; padding: {SPACE_2}px; \ border-right: 1px solid {COLOR_BORDER_CHROME}; flex-shrink: 0; box-sizing: border-box;", + + // Mode selectors button { - style: btn_style(pixel_bg), + style: btn(tool_mode == ToolMode::Pixel), onclick: move |_| state.write().tool_mode = ToolMode::Pixel, "Px" - // TODO(iris): Phase 3 — SVG icon when appthere_ui ships Tabler Icons } button { - style: btn_style(vector_bg), + style: btn(tool_mode == ToolMode::Vector), onclick: move |_| state.write().tool_mode = ToolMode::Vector, "Vc" } - button { style: disabled_btn_style.clone(), disabled: true, "B" } - button { style: disabled_btn_style.clone(), disabled: true, "E" } - button { style: disabled_btn_style.clone(), disabled: true, "P" } - button { style: disabled_btn_style.clone(), disabled: true, "T" } + + // Pixel sub-tool selectors + button { + style: btn(tool_mode == ToolMode::Pixel && pixel_tool == PixelTool::Brush), + onclick: move |_| { + state.write().tool_mode = ToolMode::Pixel; + state.write().active_pixel_tool = PixelTool::Brush; + }, + "B" + } + button { + style: btn(tool_mode == ToolMode::Pixel && pixel_tool == PixelTool::Eraser), + onclick: move |_| { + state.write().tool_mode = ToolMode::Pixel; + state.write().active_pixel_tool = PixelTool::Eraser; + }, + "E" + } + button { + style: btn(tool_mode == ToolMode::Pixel && pixel_tool == PixelTool::Eyedropper), + onclick: move |_| { + state.write().tool_mode = ToolMode::Pixel; + state.write().active_pixel_tool = PixelTool::Eyedropper; + }, + "I" + } + button { + style: btn(tool_mode == ToolMode::Pixel && pixel_tool == PixelTool::Fill), + onclick: move |_| { + state.write().tool_mode = ToolMode::Pixel; + state.write().active_pixel_tool = PixelTool::Fill; + }, + "F" + } + button { + style: btn(tool_mode == ToolMode::Pixel && pixel_tool == PixelTool::Marquee), + onclick: move |_| { + state.write().tool_mode = ToolMode::Pixel; + state.write().active_pixel_tool = PixelTool::Marquee; + }, + "M" + } + button { style: disabled.clone(), disabled: true, "T" } + + // Colour swatches: foreground over background. + // Click either swatch to swap foreground ↔ background. + // TODO(iris): Phase 4 — click foreground swatch opens full colour picker + div { + style: "margin-top: {SPACE_2}px;", + button { + style: swatch_style(&bg_css), + title: "Background colour (click to swap)", + onclick: move |_| swap_colors(&mut state), + } + button { + style: swatch_style(&fg_css), + title: "Foreground colour (click to swap)", + onclick: move |_| swap_colors(&mut state), + } + } } } } + +/// Approximate linear RGBA → sRGB CSS `rgb(r,g,b)` for display in swatches. +fn srgb_css(linear: [f32; 4]) -> String { + let to_u8 = |v: f32| (v.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0) as u8; + format!("rgb({},{},{})", to_u8(linear[0]), to_u8(linear[1]), to_u8(linear[2])) +} + +fn swap_colors(state: &mut Signal) { + let mut s = state.write(); + let tmp = s.foreground_color; + s.foreground_color = s.background_color; + s.background_color = tmp; + s.pixel_tool_state.brush.settings.color = s.foreground_color; +} diff --git a/crates/iris-canvas/src/canvas_widget.rs b/crates/iris-canvas/src/canvas_widget.rs index fe48066..bf43492 100644 --- a/crates/iris-canvas/src/canvas_widget.rs +++ b/crates/iris-canvas/src/canvas_widget.rs @@ -22,12 +22,14 @@ use std::sync::{Arc, Mutex}; +use dioxus::html::input_data::MouseButton; use dioxus::native::use_wgpu; use dioxus::prelude::*; use iris_pixel::LayerTree; use crate::compositor::Compositor; use crate::paint_bridge::IrisCanvasPaintSource; +use crate::tool_event::{PointerButton, ToolEvent}; use crate::viewport::CanvasViewport; /// Props for the [`IrisCanvas`] component. @@ -41,6 +43,28 @@ pub struct IrisCanvasProps { pub width: u32, /// Canvas height in CSS pixels. pub height: u32, + /// Called for every normalised tool event (down, move, up, scroll, pinch). + pub on_tool_event: EventHandler, +} + +/// Convert CSS element-local coordinates from a mouse event to a `kurbo::Vec2`. +/// +/// `element_coordinates()` returns the position relative to the element's +/// top-left corner — the same coordinate space that `CanvasViewport::screen_to_doc` +/// expects as its `screen` argument. +fn screen_pos(evt: &Event) -> kurbo::Vec2 { + let p = evt.element_coordinates(); + kurbo::Vec2::new(p.x, p.y) +} + +/// Map a Dioxus `MouseButton` to the canvas-internal `PointerButton`. +fn to_pointer_button(btn: MouseButton) -> PointerButton { + match btn { + MouseButton::Primary => PointerButton::Primary, + MouseButton::Secondary => PointerButton::Secondary, + MouseButton::Auxiliary => PointerButton::Middle, + _ => PointerButton::Primary, + } } /// Dioxus component for the Iris infinite canvas. @@ -57,6 +81,14 @@ pub fn IrisCanvas(props: IrisCanvasProps) -> Element { 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()))); + // shared_size: written by the paint source on every render() call with the + // Blitz-reported canvas dimensions. Event handlers read from this so they + // always use the same coordinate space as the compositor. + // Initialised from props as a fallback for the window between component + // creation and the first compositor render (in practice, never hit since + // Blitz paints before the window is interactive). + let shared_size = use_hook(|| Arc::new(Mutex::new((props.width, props.height)))); + // Sync signal values into shared state on every re-render. if let Ok(mut vp) = shared_viewport.try_lock() { *vp = props.viewport.read().clone(); @@ -73,17 +105,138 @@ pub fn IrisCanvas(props: IrisCanvasProps) -> Element { compositor.clone(), shared_viewport.clone(), shared_tree.clone(), + shared_size.clone(), ) }); + let mut vp = props.viewport; + let on_down = props.on_tool_event.clone(); + let on_move = props.on_tool_event.clone(); + let on_up = props.on_tool_event.clone(); + let on_wheel = props.on_tool_event.clone(); + // Clone Arc once per closure — Arc is Clone, not Copy. + let size_down = shared_size.clone(); + let size_move = shared_size.clone(); + let size_up = shared_size.clone(); + let size_wheel = shared_size.clone(); + let size_mounted = shared_size.clone(); + rsx! { - canvas { + div { + // COMPAT(blitz): custom paint canvas elements don't participate in + // Blitz's hit-test tree for mouse events — only standard HTML elements + // receive pointer events. All event handlers live on this wrapper div; + // the inner is purely visual. Same pattern as Loki's PageTile. + // COLOR_SURFACE_BASE (#1e1e1e) — keep in sync with appthere-ui token. + style: "flex: 1; display: block; width: 100%; height: 100%; \ + position: relative; overflow: hidden; \ + background-color: #1e1e1e;", + + onmounted: move |evt| { + // get_client_rect() returns the element's LOGICAL CSS size from + // Taffy final_layout (CSS pixels, not physical). Update shared_size + // as a fallback in case render() hasn't been called yet on first click. + // render() overwrites this with the same logical value (physical/scale) + // so both paths converge to the correct dimensions. + let size_mounted2 = size_mounted.clone(); + spawn(async move { + if let Ok(rect) = evt.get_client_rect().await { + let rw = rect.width().round() as u32; + let rh = rect.height().round() as u32; + tracing::debug!( + rw = rw, + rh = rh, + origin_x = rect.origin.x, + origin_y = rect.origin.y, + "canvas onmounted: logical CSS size" + ); + if rw > 0 && rh > 0 { + if let Ok(mut sz) = size_mounted2.try_lock() { + *sz = (rw, rh); + } + } + } + }); + }, + + onmousedown: move |evt| { + let (w, h) = size_down.lock().map(|g| *g).unwrap_or((props.width, props.height)); + let sp = screen_pos(&evt); + let doc = vp.read().screen_to_doc(sp, w, h); + let client = evt.client_coordinates(); + tracing::debug!( + client_x = client.x, + client_y = client.y, + elem_x = sp.x, + elem_y = sp.y, + rendered_w = w, + rendered_h = h, + doc_x = doc.x, + doc_y = doc.y, + "canvas_widget: onmousedown coordinate pipeline" + ); + let button = evt.trigger_button().map(to_pointer_button) + .unwrap_or(PointerButton::Primary); + on_down.call(ToolEvent::Down { + doc_pos: doc, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, button, + }); + }, + + onmousemove: move |evt| { + if evt.held_buttons().contains(MouseButton::Primary) { + let (w, h) = size_move.lock().map(|g| *g).unwrap_or((props.width, props.height)); + let doc = vp.read().screen_to_doc(screen_pos(&evt), w, h); + on_move.call(ToolEvent::Move { + doc_pos: doc, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, + }); + } + }, + + onmouseup: move |evt| { + let (w, h) = size_up.lock().map(|g| *g).unwrap_or((props.width, props.height)); + let doc = vp.read().screen_to_doc(screen_pos(&evt), w, h); + let button = evt.trigger_button().map(to_pointer_button) + .unwrap_or(PointerButton::Primary); + on_up.call(ToolEvent::Up { doc_pos: doc, button }); + }, + + // TODO(iris): SPEC.md §11.2 — onwheel requires blitz-shell to route + // MouseWheel events to Dioxus (currently they go to CSS scroll only). + // The handler logic below is correct; it will activate once blitz-shell + // is patched to call handle_ui_event for wheel events. + onwheel: move |evt| { + let (w, h) = size_wheel.lock().map(|g| *g).unwrap_or((props.width, props.height)); + let delta = evt.delta().strip_units(); + if evt.modifiers().ctrl() { + let anchor = kurbo::Vec2::new( + evt.element_coordinates().x, + evt.element_coordinates().y, + ); + let new_zoom = vp.read().zoom * (1.0 - delta.y as f32 * 0.001); + vp.write().zoom_to(new_zoom, anchor, w, h); + } else { + let zoom = vp.read().zoom as f64; + let pan_delta = kurbo::Vec2::new(-delta.x / zoom, -delta.y / zoom); + vp.write().pan += pan_delta; + on_wheel.call(ToolEvent::Scroll { + delta_x: delta.x as f32, + delta_y: delta.y as f32, + }); + } + }, + + // TODO(iris): Phase 3 — touch/stylus events require dioxus-native-dom patch + // and blitz-shell multi-touch support. Add ontouchstart, ontouchmove, + // ontouchend + ToolEvent::Pinch when available. + + // Canvas is purely visual — no event handlers. // "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;", + // Blitz derives render() dimensions from CSS layout, not these HTML attrs. + canvas { + "src": "{canvas_id}", + style: "display: block; width: 100%; height: 100%;", + } } } } diff --git a/crates/iris-canvas/src/compositor/mod.rs b/crates/iris-canvas/src/compositor/mod.rs index c00a234..72d7f79 100644 --- a/crates/iris-canvas/src/compositor/mod.rs +++ b/crates/iris-canvas/src/compositor/mod.rs @@ -75,14 +75,29 @@ impl Compositor { viewport: &CanvasViewport, width_px: u32, height_px: u32, + // COMPAT(blitz): blitz-paint passes physical pixel dimensions; scale is the + // DPI factor. Viewport transforms must use logical (CSS) dimensions so they + // match screen_to_doc() in the event handler. Pixel placement uses physical. + scale: f64, device: &wgpu::Device, queue: &wgpu::Queue, ) -> Result { + let logical_w = ((width_px as f64) / scale.max(1.0)).round() as u32; + let logical_h = ((height_px as f64) / scale.max(1.0)).round() as u32; + let pixel_count = (width_px * height_px) as usize; // Premultiplied linear-light f32 RGBA accumulation buffer. let mut acc = vec![0.0_f32; pixel_count * 4]; - let visible_rect = viewport.visible_doc_rect(width_px, height_px); + let doc_w = tree.canvas_width; + let doc_h = tree.canvas_height; + fill_document_background( + &mut acc, viewport, + width_px, height_px, logical_w, logical_h, + doc_w, doc_h, + ); + + let visible_rect = viewport.visible_doc_rect(logical_w, logical_h); let layers: Vec<_> = tree.iter_depth_first().collect(); for layer in layers.iter().rev() { @@ -106,7 +121,9 @@ impl Compositor { blit_tile( &mut acc, tile_data, tx, ty, offset_x, offset_y, - viewport, width_px, height_px, layer.opacity, + viewport, + width_px, height_px, logical_w, logical_h, + layer.opacity, ); } // Absent tile = transparent = no contribution to composite. @@ -134,6 +151,8 @@ impl Compositor { // vello::Renderer::register_texture(), which copies the texture into // Vello's image atlas via a wgpu copy operation (needs COPY_SRC). // COPY_DST is required by queue.write_texture() for the initial upload. + // TEXTURE_BINDING and STORAGE_BINDING are required by anyrender_vello's blit pass + // which samples the texture in a shader (vello::Renderer::register_texture path). let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("iris-canvas-composite"), size: wgpu::Extent3d { @@ -143,7 +162,10 @@ impl Compositor { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); queue.write_texture( @@ -182,9 +204,12 @@ fn blit_tile( tx: u32, ty: u32, offset_x: f64, offset_y: f64, viewport: &CanvasViewport, - width_px: u32, height_px: u32, + physical_w: u32, physical_h: u32, + logical_w: u32, logical_h: u32, opacity: f32, ) { + let scale_x = physical_w as f64 / logical_w as f64; + let scale_y = physical_h as f64 / logical_h as f64; let ts = TILE_SIZE as usize; let bytes = &tile_data.0; for py in 0..ts { @@ -193,10 +218,10 @@ fn blit_tile( offset_x + (tx as f64 * ts as f64) + px_local as f64 + 0.5, offset_y + (ty as f64 * ts as f64) + py as f64 + 0.5, ); - let screen = viewport.doc_to_screen(doc, width_px, height_px); - let sx = screen.x as i64; - let sy = screen.y as i64; - if sx < 0 || sy < 0 || sx >= width_px as i64 || sy >= height_px as i64 { + let screen = viewport.doc_to_screen(doc, logical_w, logical_h); + let sx = (screen.x * scale_x) as i64; + let sy = (screen.y * scale_y) as i64; + if sx < 0 || sy < 0 || sx >= physical_w as i64 || sy >= physical_h as i64 { continue; } let ib = (py * ts + px_local) * 8; // 4 channels × 2 bytes per f16 @@ -204,7 +229,7 @@ fn blit_tile( let sg = f16_to_f32(u16::from_le_bytes([bytes[ib + 2], bytes[ib + 3]])); let sb = f16_to_f32(u16::from_le_bytes([bytes[ib + 4], bytes[ib + 5]])); let sa = f16_to_f32(u16::from_le_bytes([bytes[ib + 6], bytes[ib + 7]])) * opacity; - let ob = (sy as usize * width_px as usize + sx as usize) * 4; + let ob = (sy as usize * physical_w as usize + sx as usize) * 4; let inv = 1.0 - sa; // Porter-Duff "over": dst = src_premul + dst × (1 − src_alpha) acc[ob ] = sr * sa + acc[ob ] * inv; @@ -215,6 +240,42 @@ fn blit_tile( } } +/// Fill the screen-space region corresponding to the document boundary with opaque white. +/// +/// Called before layer compositing so painted tiles composite on top of the white +/// background. Pixels outside the document boundary remain transparent (dark). +fn fill_document_background( + acc: &mut [f32], + viewport: &CanvasViewport, + physical_w: u32, physical_h: u32, + logical_w: u32, logical_h: u32, + doc_w: u32, + doc_h: u32, +) { + let scale_x = physical_w as f64 / logical_w as f64; + let scale_y = physical_h as f64 / logical_h as f64; + // Use logical dims for transform; scale to physical pixel coords. + let top_left = viewport.doc_to_screen(kurbo::Vec2::new(0.0, 0.0), logical_w, logical_h); + let bottom_right = viewport.doc_to_screen( + kurbo::Vec2::new(doc_w as f64, doc_h as f64), + logical_w, logical_h, + ); + let x0 = ((top_left.x * scale_x).floor() as i64).max(0) as usize; + let y0 = ((top_left.y * scale_y).floor() as i64).max(0) as usize; + let x1 = ((bottom_right.x * scale_x).ceil() as i64).min(physical_w as i64) as usize; + let y1 = ((bottom_right.y * scale_y).ceil() as i64).min(physical_h as i64) as usize; + for sy in y0..y1 { + for sx in x0..x1 { + let ob = (sy * physical_w as usize + sx) * 4; + // Premultiplied opaque white: (1, 1, 1, 1). + acc[ob ] = 1.0; + acc[ob + 1] = 1.0; + acc[ob + 2] = 1.0; + acc[ob + 3] = 1.0; + } + } +} + /// Convert IEEE 754 half-precision bits to f32. /// Subnormal f16 values (exp=0, mant≠0) are treated as zero — adequate for pixels. fn f16_to_f32(bits: u16) -> f32 { diff --git a/crates/iris-canvas/src/lib.rs b/crates/iris-canvas/src/lib.rs index 969467b..6efe927 100644 --- a/crates/iris-canvas/src/lib.rs +++ b/crates/iris-canvas/src/lib.rs @@ -10,6 +10,7 @@ pub mod canvas_widget; pub mod key; +pub mod tool_event; pub(crate) mod compositor; pub(crate) mod paint_bridge; pub mod viewport; @@ -26,4 +27,5 @@ pub use appthere_canvas::{ pub use canvas_widget::IrisCanvas; pub use compositor::CompositorError; pub use key::TileKey; +pub use tool_event::{PointerButton, ToolEvent}; 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 index 01aa1de..66f7464 100644 --- a/crates/iris-canvas/src/paint_bridge.rs +++ b/crates/iris-canvas/src/paint_bridge.rs @@ -26,13 +26,19 @@ 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. +/// `viewport`, `tree`, and `rendered_size` are `Arc>` shared with +/// the `IrisCanvas` component body. The Blitz paint loop calls `render()` +/// independently; no Dioxus reactive context is needed. +/// +/// `rendered_size` is written on every `render()` call with the true Blitz +/// layout dimensions, ensuring event handlers always see the correct size +/// regardless of when `onmounted` fires relative to the first click. pub(crate) struct IrisCanvasPaintSource { compositor: Arc>, viewport: Arc>, tree: Arc>, + /// Written each frame by `render()` with the Blitz-reported canvas size. + rendered_size: Arc>, device_handle: Option, last_handle: Option, } @@ -42,8 +48,9 @@ impl IrisCanvasPaintSource { compositor: Arc>, viewport: Arc>, tree: Arc>, + rendered_size: Arc>, ) -> Self { - Self { compositor, viewport, tree, device_handle: None, last_handle: None } + Self { compositor, viewport, tree, rendered_size, device_handle: None, last_handle: None } } } @@ -64,6 +71,26 @@ impl CustomPaintSource for IrisCanvasPaintSource { height: u32, scale: f64, ) -> Option { + // COMPAT(blitz): blitz-paint passes PHYSICAL pixel dimensions to render() + // (content_box.width() = layout.size.width * scale, per blitz-paint/render.rs). + // Event coordinates (element_coordinates()) are in LOGICAL CSS pixels (Winit + // logical cursor position minus Taffy absolute_position, both in CSS px). + // Divide by scale to get the logical canvas size that screen_to_doc() expects. + let logical_w = ((width as f64) / scale.max(1.0)).round() as u32; + let logical_h = ((height as f64) / scale.max(1.0)).round() as u32; + // Write logical size first — must succeed even if compositing fails later. + if let Ok(mut sz) = self.rendered_size.try_lock() { + *sz = (logical_w, logical_h); + } + tracing::debug!( + physical_w = width, + physical_h = height, + scale = scale, + logical_w = logical_w, + logical_h = logical_h, + "IrisCanvasPaintSource::render size" + ); + let dh = self.device_handle.as_ref()?; if let Some(old) = self.last_handle.take() { @@ -82,15 +109,12 @@ impl CustomPaintSource for IrisCanvasPaintSource { // TODO(iris): Phase 4 — replace with GPU compute path that follows Loki's // render_to_texture() pattern so work is submitted through Vello's encoder. let texture = compositor_guard - .composite_to_texture(&tree_guard, &viewport, width, height, &dh.device, &dh.queue) + .composite_to_texture(&tree_guard, &viewport, width, height, scale, &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(texture); self.last_handle = Some(handle.clone()); Some(handle) diff --git a/crates/iris-canvas/src/tool_event.rs b/crates/iris-canvas/src/tool_event.rs new file mode 100644 index 0000000..574d3aa --- /dev/null +++ b/crates/iris-canvas/src/tool_event.rs @@ -0,0 +1,53 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +/// A normalised tool event from the canvas, in document space. +/// +/// All `doc_pos` coordinates are in document pixels — origin at the document +/// top-left, axes identical to screen axes at zero rotation. Callers should +/// use [`crate::viewport::CanvasViewport::screen_to_doc`] to convert from +/// raw pointer positions before constructing these variants. +#[derive(Debug, Clone)] +pub enum ToolEvent { + /// Pointer pressed on the canvas (mouse button down or stylus touch). + Down { + doc_pos: kurbo::Vec2, + /// Normalised pressure 0.0–1.0. Mouse always reports 1.0. + pressure: f32, + /// Stylus tilt in degrees around the X axis. Mouse always reports 0.0. + tilt_x: f32, + /// Stylus tilt in degrees around the Y axis. Mouse always reports 0.0. + tilt_y: f32, + button: PointerButton, + }, + /// Pointer moved while at least one button was held. + Move { + doc_pos: kurbo::Vec2, + pressure: f32, + tilt_x: f32, + tilt_y: f32, + }, + /// Pointer button released. + Up { + doc_pos: kurbo::Vec2, + button: PointerButton, + }, + /// Scroll-wheel or two-finger pan delta, in screen pixels. + /// + /// Positive Y = scroll down. The canvas should translate `pan` in the + /// opposite direction so content follows the finger. + Scroll { delta_x: f32, delta_y: f32 }, + /// Pinch-to-zoom gesture. `scale_factor > 1.0` means zoom in. + /// + /// `anchor_screen` is the screen-space fixed point (midpoint of the two + /// touch contacts). The viewport should zoom around this point. + Pinch { scale_factor: f32, anchor_screen: kurbo::Vec2 }, +} + +/// Which pointer button generated a [`ToolEvent::Down`] or [`ToolEvent::Up`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PointerButton { + Primary, + Secondary, + Middle, +} diff --git a/crates/iris-tools/Cargo.toml b/crates/iris-tools/Cargo.toml new file mode 100644 index 0000000..a81a528 --- /dev/null +++ b/crates/iris-tools/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "iris-tools" +version = "0.1.0" +description = "Iris pixel tool implementations: brush, eraser, and future tools" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +iris-pixel = { workspace = true } +iris-ops = { workspace = true } +iris-canvas = { workspace = true } +kurbo = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } diff --git a/crates/iris-tools/src/brush.rs b/crates/iris-tools/src/brush.rs new file mode 100644 index 0000000..f79ff87 --- /dev/null +++ b/crates/iris-tools/src/brush.rs @@ -0,0 +1,214 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`BrushEngine`] — hard round brush with pressure-sensitive size and +//! sub-pixel spacing interpolation. + +use std::collections::HashSet; + +use iris_canvas::ToolEvent; +use iris_pixel::{Layer, LayerId, TileCoord}; + +mod paint; + +/// Brush configuration. All fields are public so the UI can bind directly. +#[derive(Debug, Clone)] +pub struct BrushSettings { + /// Diameter in document pixels (1.0 – 2000.0). + pub size_px: f32, + /// Layer opacity multiplier (0.0 – 1.0). + pub opacity: f32, + /// Edge falloff: 1.0 = fully hard, 0.0 = fully soft. + pub hardness: f32, + /// Stamp spacing as a fraction of diameter (0.05 – 1.0). + pub spacing: f32, + /// Linear RGBA brush colour. + pub color: [f32; 4], + /// When `true`, pressure scales the effective diameter. + pub pressure_size: bool, + /// When `true`, compositing uses destination-out (erase) instead of over. + pub erase_mode: bool, + /// Active marquee selection. When `Some`, only pixels inside the rect are painted. + pub selection: Option, +} + +impl Default for BrushSettings { + fn default() -> Self { + Self { + size_px: 20.0, + opacity: 1.0, + hardness: 0.8, + spacing: 0.1, + color: [0.0, 0.0, 0.0, 1.0], + pressure_size: true, + erase_mode: false, + selection: None, + } + } +} + +/// Stateful brush that converts [`ToolEvent`]s into painted [`TileData`] mutations. +#[derive(Clone)] +pub struct BrushEngine { + pub settings: BrushSettings, + last_pos: Option, + dist_acc: f64, + dirty_tiles: HashSet<(LayerId, TileCoord)>, +} + +impl BrushEngine { + pub fn new(settings: BrushSettings) -> Self { + Self { settings, last_pos: None, dist_acc: 0.0, dirty_tiles: HashSet::new() } + } + + /// Begin a new stroke. Stamps the brush at the down position. + pub fn on_down(&mut self, event: &ToolEvent, layer: &mut Layer, layer_id: LayerId) { + self.last_pos = None; + self.dist_acc = 0.0; + self.dirty_tiles.clear(); + if let ToolEvent::Down { doc_pos, pressure, .. } = event { + paint::stamp( + &mut self.last_pos, &mut self.dirty_tiles, + &self.settings, *doc_pos, *pressure, layer, layer_id, + ); + } + } + + /// Continue a stroke. Interpolates stamps along the path since last position. + pub fn on_move(&mut self, event: &ToolEvent, layer: &mut Layer, layer_id: LayerId) { + if let ToolEvent::Move { doc_pos, pressure, .. } = event { + paint::interpolate( + &mut self.last_pos, &mut self.dist_acc, &mut self.dirty_tiles, + &self.settings, *doc_pos, *pressure, layer, layer_id, + ); + } + } + + /// End the stroke. Returns the set of dirtied (layer_id, tile_coord) for undo. + pub fn on_up(&mut self) -> Vec<(LayerId, TileCoord)> { + self.last_pos = None; + self.dirty_tiles.drain().collect() + } +} + +impl Default for BrushEngine { + fn default() -> Self { Self::new(BrushSettings::default()) } +} + +#[cfg(test)] +mod tests { + use super::*; + use iris_canvas::PointerButton; + use iris_pixel::{ + BitDepth, BlendMode, ChannelLayout, ExrCompression, Layer, LayerContent, + PixelLayer, TileCache, TileCoord, TileData, TILE_SIZE, LINEAR_SRGB, + }; + + fn make_layer() -> (Layer, LayerId) { + let id = uuid::Uuid::new_v4(); + let layer = Layer { + id, name: "Test".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: TileCache::default(), + }), + }; + (layer, id) + } + + fn alpha_at(tile: &TileData, px: usize, py: usize) -> f32 { + let bi = (py * TILE_SIZE as usize + px) * 8; + paint::f16_to_f32(u16::from_le_bytes([tile.0[bi+6], tile.0[bi+7]])) + } + + fn down_at(x: f64, y: f64) -> ToolEvent { + ToolEvent::Down { + doc_pos: kurbo::Vec2::new(x, y), pressure: 1.0, + tilt_x: 0.0, tilt_y: 0.0, button: PointerButton::Primary, + } + } + + #[test] + fn stamp_paints_pixels_within_radius() { + let (mut layer, id) = make_layer(); + let mut engine = BrushEngine::new(BrushSettings { + size_px: 10.0, opacity: 1.0, hardness: 1.0, pressure_size: false, + ..BrushSettings::default() + }); + engine.on_down(&down_at(128.0, 128.0), &mut layer, id); + let LayerContent::Pixel(ref pl) = layer.content else { panic!() }; + let tile = pl.tiles.get(TileCoord { tx: 0, ty: 0 }).expect("tile created"); + assert!(alpha_at(tile, 128, 128) > 0.9, "center pixel painted"); + assert_eq!(alpha_at(tile, 0, 0), 0.0, "outside pixel untouched"); + } + + #[test] + fn eraser_clears_pixels() { + let (mut layer, id) = make_layer(); + let mut brush = BrushEngine::new(BrushSettings { + size_px: 20.0, opacity: 1.0, hardness: 1.0, + color: [1.0, 1.0, 1.0, 1.0], pressure_size: false, ..BrushSettings::default() + }); + brush.on_down(&down_at(128.0, 128.0), &mut layer, id); + let mut eraser = BrushEngine::new(BrushSettings { + size_px: 20.0, opacity: 1.0, hardness: 1.0, + erase_mode: true, pressure_size: false, ..BrushSettings::default() + }); + eraser.on_down(&down_at(128.0, 128.0), &mut layer, id); + let LayerContent::Pixel(ref pl) = layer.content else { panic!() }; + let tile = pl.tiles.get(TileCoord { tx: 0, ty: 0 }).expect("tile exists"); + assert!(alpha_at(tile, 128, 128) < 0.05, "pixel erased to transparent"); + } + + #[test] + fn pressure_half_shrinks_brush() { + let (mut layer, id) = make_layer(); + let mut engine = BrushEngine::new(BrushSettings { + size_px: 40.0, opacity: 1.0, hardness: 1.0, pressure_size: true, + ..BrushSettings::default() + }); + let event = ToolEvent::Down { + doc_pos: kurbo::Vec2::new(128.0, 128.0), pressure: 0.5, + tilt_x: 0.0, tilt_y: 0.0, button: PointerButton::Primary, + }; + engine.on_down(&event, &mut layer, id); + let LayerContent::Pixel(ref pl) = layer.content else { panic!() }; + let tile = pl.tiles.get(TileCoord { tx: 0, ty: 0 }).expect("tile created"); + assert_eq!(alpha_at(tile, 128 + 12, 128), 0.0, "outside half-pressure radius"); + assert!(alpha_at(tile, 128, 128) > 0.9, "center painted"); + } + + #[test] + fn interpolate_places_multiple_stamps() { + let (mut layer, id) = make_layer(); + let mut engine = BrushEngine::new(BrushSettings { + size_px: 10.0, opacity: 1.0, hardness: 1.0, spacing: 0.5, pressure_size: false, + ..BrushSettings::default() + }); + engine.on_down(&down_at(10.0, 128.0), &mut layer, id); + let move_ev = ToolEvent::Move { + doc_pos: kurbo::Vec2::new(110.0, 128.0), pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, + }; + engine.on_move(&move_ev, &mut layer, id); + assert!(!engine.on_up().is_empty(), "stroke left dirty tiles"); + } + + #[test] + fn selection_restricts_brush_painting() { + let (mut layer, id) = make_layer(); + // Selection covers only x in [200, 400): center at (128, 128) is outside. + let mut engine = BrushEngine::new(BrushSettings { + size_px: 40.0, opacity: 1.0, hardness: 1.0, pressure_size: false, + selection: Some(kurbo::Rect::new(200.0, 0.0, 400.0, 400.0)), + ..BrushSettings::default() + }); + engine.on_down(&down_at(128.0, 128.0), &mut layer, id); + let LayerContent::Pixel(ref pl) = layer.content else { panic!() }; + if let Some(tile) = pl.tiles.get(TileCoord { tx: 0, ty: 0 }) { + assert_eq!(alpha_at(tile, 128, 128), 0.0, "pixel outside selection not painted"); + } + } +} diff --git a/crates/iris-tools/src/brush/paint.rs b/crates/iris-tools/src/brush/paint.rs new file mode 100644 index 0000000..beeae1c --- /dev/null +++ b/crates/iris-tools/src/brush/paint.rs @@ -0,0 +1,153 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Hot-loop painting primitives: stamp, paint_tile, interpolate, f16 codec. +//! Called exclusively from [`super::BrushEngine`]. + +use std::collections::HashSet; + +use iris_pixel::{Layer, LayerContent, LayerId, PixelLayer, TileCoord, TileData, TILE_SIZE}; + +pub(super) fn stamp( + last_pos: &mut Option, + dirty_tiles: &mut HashSet<(LayerId, TileCoord)>, + settings: &super::BrushSettings, + pos: kurbo::Vec2, + pressure: f32, + layer: &mut Layer, + layer_id: LayerId, +) { + let size = if settings.pressure_size { settings.size_px * pressure } else { settings.size_px }; + let radius = (size / 2.0).max(0.5) as f64; + let pixel_layer = match &mut layer.content { + LayerContent::Pixel(pl) => pl, + _ => return, + }; + let off_x = pixel_layer.canvas_offset_x as i64; + let off_y = pixel_layer.canvas_offset_y as i64; + let ts = TILE_SIZE as i64; + let tx0 = ((pos.x - radius) as i64 - off_x).div_euclid(ts) as i32; + let ty0 = ((pos.y - radius) as i64 - off_y).div_euclid(ts) as i32; + let tx1 = ((pos.x + radius) as i64 + 1 - off_x).div_euclid(ts) as i32; + let ty1 = ((pos.y + radius) as i64 + 1 - off_y).div_euclid(ts) as i32; + for ty in ty0..=ty1 { + for tx in tx0..=tx1 { + if tx < 0 || ty < 0 { continue; } + let coord = TileCoord { tx: tx as u32, ty: ty as u32 }; + paint_tile(pos, radius, settings, coord, pixel_layer, layer_id, dirty_tiles); + } + } + *last_pos = Some(pos); +} + +pub(super) fn interpolate( + last_pos: &mut Option, + dist_acc: &mut f64, + dirty_tiles: &mut HashSet<(LayerId, TileCoord)>, + settings: &super::BrushSettings, + new_pos: kurbo::Vec2, + pressure: f32, + layer: &mut Layer, + layer_id: LayerId, +) { + let Some(last) = *last_pos else { + stamp(last_pos, dirty_tiles, settings, new_pos, pressure, layer, layer_id); + return; + }; + let spacing = (settings.size_px as f64 * settings.spacing as f64).max(1.0); + let delta = new_pos - last; + let seg_len = delta.hypot(); + if seg_len < 1e-6 { return; } + let mut covered = spacing - *dist_acc; + while covered <= seg_len { + let t = covered / seg_len; + stamp(last_pos, dirty_tiles, settings, last + delta * t, pressure, layer, layer_id); + covered += spacing; + } + *dist_acc = seg_len - (covered - spacing); + *last_pos = Some(new_pos); +} + +fn paint_tile( + center: kurbo::Vec2, + radius: f64, + settings: &super::BrushSettings, + coord: TileCoord, + pl: &mut PixelLayer, + layer_id: LayerId, + dirty_tiles: &mut HashSet<(LayerId, TileCoord)>, +) { + let mut tile = pl.tiles.get(coord).cloned() + .unwrap_or_else(|| TileData::transparent(TILE_SIZE)); + let tile_ox = pl.canvas_offset_x as f64 + coord.tx as f64 * TILE_SIZE as f64; + let tile_oy = pl.canvas_offset_y as f64 + coord.ty as f64 * TILE_SIZE as f64; + let color = settings.color; + let opacity = settings.opacity; + let hardness = settings.hardness; + let erase = settings.erase_mode; + let selection = settings.selection; + let ts = TILE_SIZE as usize; + { + let bytes = &mut tile.0; + for py in 0..ts { + for px in 0..ts { + let wx = tile_ox + px as f64 + 0.5; + let wy = tile_oy + py as f64 + 0.5; + if let Some(sel) = selection { + if !sel.contains(kurbo::Point::new(wx, wy)) { continue; } + } + let dist = ((wx - center.x).powi(2) + (wy - center.y).powi(2)).sqrt(); + if dist > radius { continue; } + let falloff_start = radius * hardness as f64; + let alpha = if hardness >= 1.0 || dist <= falloff_start { + opacity + } else { + let t = ((dist - falloff_start) / (radius - falloff_start)) as f32; + opacity * (1.0 - t) + }; + let bi = (py * ts + px) * 8; + let dst_r = f16_to_f32(u16::from_le_bytes([bytes[bi], bytes[bi+1]])); + let dst_g = f16_to_f32(u16::from_le_bytes([bytes[bi+2], bytes[bi+3]])); + let dst_b = f16_to_f32(u16::from_le_bytes([bytes[bi+4], bytes[bi+5]])); + let dst_a = f16_to_f32(u16::from_le_bytes([bytes[bi+6], bytes[bi+7]])); + let (out_r, out_g, out_b, out_a) = if erase { + let f = 1.0 - alpha; + (dst_r * f, dst_g * f, dst_b * f, dst_a * f) + } else { + let src_a = color[3] * alpha; + let f = 1.0 - src_a; + (color[0]*src_a + dst_r*f, color[1]*src_a + dst_g*f, + color[2]*src_a + dst_b*f, src_a + dst_a*f) + }; + bytes[bi..bi+2].copy_from_slice(&f32_to_f16(out_r).to_le_bytes()); + bytes[bi+2..bi+4].copy_from_slice(&f32_to_f16(out_g).to_le_bytes()); + bytes[bi+4..bi+6].copy_from_slice(&f32_to_f16(out_b).to_le_bytes()); + bytes[bi+6..bi+8].copy_from_slice(&f32_to_f16(out_a).to_le_bytes()); + } + } + } + pl.tiles.insert(coord, tile); + dirty_tiles.insert((layer_id, coord)); +} + +pub(super) fn f16_to_f32(bits: u16) -> f32 { + let sign = ((bits as u32) & 0x8000) << 16; + let exp = ((bits as u32) & 0x7C00) >> 10; + let mant = (bits as u32) & 0x03FF; + f32::from_bits(match exp { + 0 => sign, + 31 => sign | 0x7F80_0000 | (mant << 13), + e => sign | ((e + 112) << 23) | (mant << 13), + }) +} + +pub(super) fn f32_to_f16(f: f32) -> u16 { + let bits = f.to_bits(); + let sign = ((bits >> 16) & 0x8000) as u16; + let exp = ((bits >> 23) & 0xFF) as i32 - 127 + 15; + let mant = ((bits >> 13) & 0x03FF) as u16; + if f.is_nan() { return sign | 0x7E00; } + if exp <= 0 { return sign; } + if exp >= 31 { return sign | 0x7C00; } + sign | ((exp as u16) << 10) | mant +} diff --git a/crates/iris-tools/src/eraser.rs b/crates/iris-tools/src/eraser.rs new file mode 100644 index 0000000..0476820 --- /dev/null +++ b/crates/iris-tools/src/eraser.rs @@ -0,0 +1,55 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`EraserEngine`] — eraser tool implemented as a BrushEngine in destination-out mode. + +use iris_canvas::ToolEvent; +use iris_pixel::{Layer, LayerId, TileCoord}; + +use crate::brush::{BrushEngine, BrushSettings}; + +/// Eraser tool. Paints transparency (destination-out) over pixels. +/// +/// Wraps [`BrushEngine`] with `erase_mode = true` so compositing uses +/// Porter-Duff destination-out rather than over. +#[derive(Clone)] +pub struct EraserEngine { + inner: BrushEngine, +} + +impl EraserEngine { + /// Create a new eraser with the given diameter (in document pixels). + pub fn new(size_px: f32) -> Self { + Self { + inner: BrushEngine::new(BrushSettings { + size_px, + erase_mode: true, + color: [0.0, 0.0, 0.0, 1.0], // alpha matters; RGB ignored in erase mode + ..BrushSettings::default() + }), + } + } + + /// Mutable access to the underlying brush settings (size, hardness, opacity). + pub fn settings_mut(&mut self) -> &mut BrushSettings { + &mut self.inner.settings + } + + pub fn on_down(&mut self, event: &ToolEvent, layer: &mut Layer, layer_id: LayerId) { + self.inner.on_down(event, layer, layer_id); + } + + pub fn on_move(&mut self, event: &ToolEvent, layer: &mut Layer, layer_id: LayerId) { + self.inner.on_move(event, layer, layer_id); + } + + pub fn on_up(&mut self) -> Vec<(LayerId, TileCoord)> { + self.inner.on_up() + } +} + +impl Default for EraserEngine { + fn default() -> Self { + Self::new(20.0) + } +} diff --git a/crates/iris-tools/src/eyedropper.rs b/crates/iris-tools/src/eyedropper.rs new file mode 100644 index 0000000..e21780e --- /dev/null +++ b/crates/iris-tools/src/eyedropper.rs @@ -0,0 +1,119 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Eyedropper tool: sample the composited colour at a document-space point. + +use iris_pixel::{LayerContent, LayerTree, TileCoord, TILE_SIZE}; + +/// Sample the composited colour at `pos` (document space) by compositing all +/// visible pixel layers bottom-to-top with Porter-Duff over. +/// +/// Returns linear RGBA as `[f32; 4]` (straight alpha). Returns `[0,0,0,0]` +/// if `pos` is outside all painted tile regions. +pub fn sample_color(pos: kurbo::Vec2, tree: &LayerTree) -> [f32; 4] { + let mut acc = [0.0_f32; 4]; // premultiplied linear RGBA accumulator + + // iter_depth_first yields top-to-bottom; reverse for bottom-to-top compositing. + 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 { continue; }; + + let lx = pos.x - px.canvas_offset_x as f64; + let ly = pos.y - px.canvas_offset_y as f64; + if lx < 0.0 || ly < 0.0 { continue; } + + let ts = TILE_SIZE as f64; + let tx = (lx / ts) as u32; + let ty = (ly / ts) as u32; + let px_x = (lx % ts) as usize; + let px_y = (ly % ts) as usize; + + let coord = TileCoord { tx, ty }; + let Some(tile) = px.tiles.get(coord) else { continue; }; + + let ts_u = TILE_SIZE as usize; + let bi = (px_y * ts_u + px_x) * 8; + if bi + 8 > tile.0.len() { continue; } + + let sr = f16_to_f32(u16::from_le_bytes([tile.0[bi], tile.0[bi+1]])); + let sg = f16_to_f32(u16::from_le_bytes([tile.0[bi+2], tile.0[bi+3]])); + let sb = f16_to_f32(u16::from_le_bytes([tile.0[bi+4], tile.0[bi+5]])); + let sa = f16_to_f32(u16::from_le_bytes([tile.0[bi+6], tile.0[bi+7]])) * layer.opacity; + + // Porter-Duff over (premultiplied source) + let inv = 1.0 - sa; + acc[0] = sr * sa + acc[0] * inv; + acc[1] = sg * sa + acc[1] * inv; + acc[2] = sb * sa + acc[2] * inv; + acc[3] = sa + acc[3] * inv; + } + + if acc[3] > f32::EPSILON { + [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3]] + } else { + [0.0, 0.0, 0.0, 0.0] + } +} + +fn f16_to_f32(bits: u16) -> f32 { + let sign = ((bits as u32) & 0x8000) << 16; + let exp = ((bits as u32) & 0x7C00) >> 10; + let mant = (bits as u32) & 0x03FF; + f32::from_bits(match exp { + 0 => sign, + 31 => sign | 0x7F80_0000 | (mant << 13), + e => sign | ((e + 112) << 23) | (mant << 13), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use iris_pixel::{ + BitDepth, BlendMode, ChannelLayout, ExrCompression, Layer, LayerContent, + LayerTree, PixelLayer, TileCache, TileData, TILE_SIZE, LINEAR_SRGB, + }; + + fn tree_with_pixel(px: u32, py: u32, rgba_f16: [u16; 4]) -> LayerTree { + let mut tree = LayerTree::new(512, 512, 96.0, 96.0); + let ts = TILE_SIZE as usize; + let mut tile = TileData::transparent(TILE_SIZE); + let bi = (py as usize % ts * ts + px as usize % ts) * 8; + tile.0[bi..bi+2].copy_from_slice(&rgba_f16[0].to_le_bytes()); + tile.0[bi+2..bi+4].copy_from_slice(&rgba_f16[1].to_le_bytes()); + tile.0[bi+4..bi+6].copy_from_slice(&rgba_f16[2].to_le_bytes()); + tile.0[bi+6..bi+8].copy_from_slice(&rgba_f16[3].to_le_bytes()); + let mut cache = TileCache::default(); + cache.insert(TileCoord { tx: px / TILE_SIZE, ty: py / TILE_SIZE }, tile); + let id = uuid::Uuid::new_v4(); + let layer = Layer { + id, name: "L".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: cache, + }), + }; + tree.add_layer(None, 0, layer).expect("test setup"); + tree + } + + #[test] + fn sample_transparent_returns_zero_alpha() { + let tree = LayerTree::new(256, 256, 96.0, 96.0); + let result = sample_color(kurbo::Vec2::new(10.0, 10.0), &tree); + assert_eq!(result[3], 0.0); + } + + #[test] + fn sample_opaque_pixel_returns_color() { + // f16 1.0 = 0x3C00 + let tree = tree_with_pixel(5, 5, [0x3C00, 0, 0, 0x3C00]); // opaque red + let result = sample_color(kurbo::Vec2::new(5.5, 5.5), &tree); + assert!(result[3] > 0.9, "alpha should be ~1.0, got {}", result[3]); + assert!(result[0] > 0.9, "red should be ~1.0, got {}", result[0]); + assert!(result[1] < 0.1, "green should be ~0.0"); + } +} diff --git a/crates/iris-tools/src/fill.rs b/crates/iris-tools/src/fill.rs new file mode 100644 index 0000000..3c09d4b --- /dev/null +++ b/crates/iris-tools/src/fill.rs @@ -0,0 +1,196 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Flood fill tool: BFS fill of a contiguous region within tolerance. + +use std::collections::{HashSet, VecDeque}; + +use iris_pixel::{Layer, LayerContent, LayerId, PixelLayer, TileCoord, TileData, TILE_SIZE}; + +/// Flood-fill a contiguous region of similar pixels on `layer` with `color`. +/// +/// Starts at `start` in document space. Expands to neighbours whose colour is +/// within Euclidean RGB distance `tolerance` of the seed colour. Fills within +/// `doc_rect` only to prevent unbounded expansion on blank canvases. +/// +/// Returns the set of tile coords that were modified. +pub fn flood_fill( + start: kurbo::Vec2, + color: [f32; 4], + tolerance: f32, + layer: &mut Layer, + _layer_id: LayerId, + doc_rect: kurbo::Rect, +) -> Vec { + let LayerContent::Pixel(ref mut pl) = layer.content else { return Vec::new(); }; + + let sx = start.x.floor() as i64; + let sy = start.y.floor() as i64; + if sx < doc_rect.x0 as i64 || sx >= doc_rect.x1 as i64 + || sy < doc_rect.y0 as i64 || sy >= doc_rect.y1 as i64 + { + return Vec::new(); + } + + let seed = read_pixel(pl, sx, sy); + // No-op: filling with a color already within tolerance of seed. + if color_dist(&seed, &color) <= tolerance { return Vec::new(); } + + let bx0 = doc_rect.x0.floor() as i64; + let by0 = doc_rect.y0.floor() as i64; + let bx1 = doc_rect.x1.ceil() as i64; + let by1 = doc_rect.y1.ceil() as i64; + + let mut visited: HashSet<(i64, i64)> = HashSet::new(); + let mut queue: VecDeque<(i64, i64)> = VecDeque::new(); + let mut dirty: HashSet = HashSet::new(); + + visited.insert((sx, sy)); + queue.push_back((sx, sy)); + + while let Some((px, py)) = queue.pop_front() { + let current = read_pixel(pl, px, py); + if color_dist(&seed, ¤t) > tolerance { continue; } + write_pixel(pl, px, py, color, &mut dirty); + for (nx, ny) in [(px-1,py),(px+1,py),(px,py-1),(px,py+1)] { + if nx < bx0 || nx >= bx1 || ny < by0 || ny >= by1 { continue; } + if visited.contains(&(nx, ny)) { continue; } + visited.insert((nx, ny)); + if color_dist(&seed, &read_pixel(pl, nx, ny)) <= tolerance { + queue.push_back((nx, ny)); + } + } + } + + dirty.into_iter().collect() +} + +fn read_pixel(pl: &PixelLayer, x: i64, y: i64) -> [f32; 4] { + let lx = x - pl.canvas_offset_x as i64; + let ly = y - pl.canvas_offset_y as i64; + if lx < 0 || ly < 0 { return [0.0; 4]; } + let ts = TILE_SIZE as i64; + let tx = (lx / ts) as u32; + let ty = (ly / ts) as u32; + let coord = TileCoord { tx, ty }; + let Some(tile) = pl.tiles.get(coord) else { return [0.0; 4]; }; + let px_x = (lx % ts) as usize; + let px_y = (ly % ts) as usize; + let bi = (px_y * TILE_SIZE as usize + px_x) * 8; + if bi + 8 > tile.0.len() { return [0.0; 4]; } + [ + f16_to_f32(u16::from_le_bytes([tile.0[bi], tile.0[bi+1]])), + f16_to_f32(u16::from_le_bytes([tile.0[bi+2], tile.0[bi+3]])), + f16_to_f32(u16::from_le_bytes([tile.0[bi+4], tile.0[bi+5]])), + f16_to_f32(u16::from_le_bytes([tile.0[bi+6], tile.0[bi+7]])), + ] +} + +fn write_pixel(pl: &mut PixelLayer, x: i64, y: i64, color: [f32; 4], dirty: &mut HashSet) { + let lx = x - pl.canvas_offset_x as i64; + let ly = y - pl.canvas_offset_y as i64; + if lx < 0 || ly < 0 { return; } + let ts = TILE_SIZE as i64; + let coord = TileCoord { tx: (lx / ts) as u32, ty: (ly / ts) as u32 }; + let px_x = (lx % ts) as usize; + let px_y = (ly % ts) as usize; + let mut tile = pl.tiles.get(coord).cloned() + .unwrap_or_else(|| TileData::transparent(TILE_SIZE)); + let bi = (px_y * TILE_SIZE as usize + px_x) * 8; + if bi + 8 > tile.0.len() { return; } + // Porter-Duff over (premultiplied) + let sa = color[3]; + let inv = 1.0 - sa; + let dst_r = f16_to_f32(u16::from_le_bytes([tile.0[bi], tile.0[bi+1]])); + let dst_g = f16_to_f32(u16::from_le_bytes([tile.0[bi+2], tile.0[bi+3]])); + let dst_b = f16_to_f32(u16::from_le_bytes([tile.0[bi+4], tile.0[bi+5]])); + let dst_a = f16_to_f32(u16::from_le_bytes([tile.0[bi+6], tile.0[bi+7]])); + tile.0[bi..bi+2].copy_from_slice(&f32_to_f16(color[0]*sa + dst_r*inv).to_le_bytes()); + tile.0[bi+2..bi+4].copy_from_slice(&f32_to_f16(color[1]*sa + dst_g*inv).to_le_bytes()); + tile.0[bi+4..bi+6].copy_from_slice(&f32_to_f16(color[2]*sa + dst_b*inv).to_le_bytes()); + tile.0[bi+6..bi+8].copy_from_slice(&f32_to_f16(sa + dst_a*inv).to_le_bytes()); + pl.tiles.insert(coord, tile); + dirty.insert(coord); +} + +/// Euclidean RGB distance (alpha ignored). Range: 0.0–sqrt(3) ≈ 1.73. +fn color_dist(a: &[f32; 4], b: &[f32; 4]) -> f32 { + let dr = a[0] - b[0]; + let dg = a[1] - b[1]; + let db = a[2] - b[2]; + (dr*dr + dg*dg + db*db).sqrt() +} + +fn f16_to_f32(bits: u16) -> f32 { + let sign = ((bits as u32) & 0x8000) << 16; + let exp = ((bits as u32) & 0x7C00) >> 10; + let mant = (bits as u32) & 0x03FF; + f32::from_bits(match exp { + 0 => sign, + 31 => sign | 0x7F80_0000 | (mant << 13), + e => sign | ((e + 112) << 23) | (mant << 13), + }) +} + +fn f32_to_f16(f: f32) -> u16 { + let bits = f.to_bits(); + let sign = ((bits >> 16) & 0x8000) as u16; + let exp = ((bits >> 23) & 0xFF) as i32 - 127 + 15; + let mant = ((bits >> 13) & 0x03FF) as u16; + if f.is_nan() { return sign | 0x7E00; } + if exp <= 0 { return sign; } + if exp >= 31 { return sign | 0x7C00; } + sign | ((exp as u16) << 10) | mant +} + +#[cfg(test)] +mod tests { + use super::*; + use iris_pixel::{ + BitDepth, BlendMode, ChannelLayout, ExrCompression, Layer, LayerContent, + PixelLayer, TileCache, LINEAR_SRGB, + }; + + fn blank_layer() -> (Layer, LayerId) { + let id = uuid::Uuid::new_v4(); + let layer = Layer { + id, name: "L".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: TileCache::default(), + }), + }; + (layer, id) + } + + fn alpha_at(layer: &Layer, x: i64, y: i64) -> f32 { + let LayerContent::Pixel(ref pl) = layer.content else { return 0.0 }; + read_pixel(pl, x, y)[3] + } + + #[test] + fn fill_paints_transparent_region() { + let (mut layer, id) = blank_layer(); + let doc = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0); + let dirty = flood_fill( + kurbo::Vec2::new(10.0, 10.0), [1.0, 0.0, 0.0, 1.0], 0.0, &mut layer, id, doc, + ); + assert!(!dirty.is_empty(), "fill should dirty tiles"); + assert!(alpha_at(&layer, 10, 10) > 0.9, "filled pixel is opaque"); + assert!(alpha_at(&layer, 200, 200) < 0.1, "out-of-bounds pixel untouched"); + } + + #[test] + fn fill_same_color_is_noop() { + let (mut layer, id) = blank_layer(); + let doc = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0); + // Seed is transparent [0,0,0,0], fill color within tolerance + let dirty = flood_fill( + kurbo::Vec2::new(10.0, 10.0), [0.0, 0.0, 0.0, 0.0], 0.0, &mut layer, id, doc, + ); + assert!(dirty.is_empty(), "filling with same color is no-op"); + } +} diff --git a/crates/iris-tools/src/lib.rs b/crates/iris-tools/src/lib.rs new file mode 100644 index 0000000..96cb7e6 --- /dev/null +++ b/crates/iris-tools/src/lib.rs @@ -0,0 +1,19 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Pixel tool implementations for AppThere Iris. +//! +//! Phase 3 scope: hard round brush and eraser. Stylus pressure scales size. +//! Phase 4: texture, scatter, wet paint, clone stamp, heal. + +#![forbid(unsafe_code)] + +pub mod brush; +pub mod eraser; +pub mod eyedropper; +pub mod fill; + +pub use brush::{BrushEngine, BrushSettings}; +pub use eraser::EraserEngine; +pub use eyedropper::sample_color; +pub use fill::flood_fill; diff --git a/crates/patches/dioxus-native-dom/src/dioxus_document.rs b/crates/patches/dioxus-native-dom/src/dioxus_document.rs index aa52a26..a2cdae5 100644 --- a/crates/patches/dioxus-native-dom/src/dioxus_document.rs +++ b/crates/patches/dioxus-native-dom/src/dioxus_document.rs @@ -1,5 +1,5 @@ //! Integration between Dioxus and Blitz -use crate::events::{BlitzKeyboardData, NativeClickData, NativeConverter, NativeFormData}; +use crate::events::{BlitzKeyboardData, BlitzMountedData, NativeClickData, NativeConverter, NativeFormData, make_pixels_rect}; use crate::mutation_writer::{DioxusState, MutationWriter}; use crate::qual_name; use crate::NodeId; @@ -71,6 +71,11 @@ pub struct DioxusDocument { pub(crate) body_element_id: NodeId, #[allow(unused)] pub(crate) main_element_id: NodeId, + + /// `onmounted` events collected during the previous mutation pass, deferred + /// until the start of the next `poll()` so that layout has been computed and + /// `node.final_layout.size` reflects actual rendered dimensions. + pub(crate) deferred_mounted_ids: Vec<(ElementId, NodeId)>, } impl DioxusDocument { @@ -133,6 +138,7 @@ impl DioxusDocument { head_element_id, body_element_id, main_element_id, + deferred_mounted_ids: Vec::new(), } } @@ -140,6 +146,11 @@ impl DioxusDocument { pub fn initial_build(&mut self) { let mut writer = MutationWriter::new(&mut self.inner, &mut self.vdom_state); self.vdom.rebuild(&mut writer); + // Do NOT drain pending_mounted_ids here. Layout runs AFTER the first poll(), + // not between initial_build() and poll(). poll() drains pending → deferred + // after render_immediate; the events fire in the SECOND poll() call, by which + // time resolve() has computed final_layout.size. Draining here would cause + // mounted events to fire with final_layout.size = (0, 0). } /// Used to respond to a `CreateHeadElement` event generated by Dioxus. These @@ -193,6 +204,23 @@ impl Document for DioxusDocument { } } + // Fire mounted events deferred from the previous poll/initial_build so that + // node.final_layout.size reflects post-layout dimensions (layout runs between polls). + let to_fire = std::mem::take(&mut self.deferred_mounted_ids); + for (element_id, node_id) in to_fire { + if let Some(node) = self.inner.get_node(node_id) { + let size = node.final_layout.size; + let rect = make_pixels_rect(size.width, size.height); + let data = BlitzMountedData { rect }; + let event = Event::new( + std::rc::Rc::new(PlatformEventData::new(Box::new(data))) + as std::rc::Rc, + false, + ); + self.vdom.runtime().handle_event("mounted", event, element_id); + } + } + // Preserve focus across re-renders: capture the focused node's Dioxus ElementId // (stable across re-renders) before render_immediate may rebuild the DOM tree. let focus_dioxus_id = self.inner.focus_node_id @@ -204,6 +232,10 @@ impl Document for DioxusDocument { self.vdom.render_immediate(&mut writer); } + // Defer newly registered onmounted handlers to the next poll (layout runs between). + let pending = std::mem::take(&mut self.vdom_state.pending_mounted_ids); + self.deferred_mounted_ids.extend(pending); + // Re-apply focus if render_immediate replaced the focused blitz node with a new one // (same Dioxus ElementId, different blitz node ID) or cleared focus entirely. if let Some(dxid) = focus_dioxus_id { @@ -279,11 +311,25 @@ impl EventHandler for DioxusEventHandler<'_> { DomEventData::MouseMove(mevent) | DomEventData::MouseDown(mevent) | DomEventData::MouseUp(mevent) - | DomEventData::Click(mevent) => Some(wrap_event_data(NativeClickData { - element_x: mevent.x - target_origin.x, - element_y: mevent.y - target_origin.y, - inner: mevent.clone(), - })), + | DomEventData::Click(mevent) => { + #[cfg(feature = "tracing")] + tracing::debug!( + event = event.name(), + target_node = event.target, + raw_x = mevent.x, + raw_y = mevent.y, + origin_x = target_origin.x, + origin_y = target_origin.y, + elem_x = mevent.x - target_origin.x, + elem_y = mevent.y - target_origin.y, + "dioxus_document: mouse event coordinate pipeline" + ); + Some(wrap_event_data(NativeClickData { + element_x: mevent.x - target_origin.x, + element_y: mevent.y - target_origin.y, + inner: mevent.clone(), + })) + } DomEventData::KeyDown(kevent) | DomEventData::KeyUp(kevent) diff --git a/crates/patches/dioxus-native-dom/src/events.rs b/crates/patches/dioxus-native-dom/src/events.rs deleted file mode 100644 index a59c00f..0000000 --- a/crates/patches/dioxus-native-dom/src/events.rs +++ /dev/null @@ -1,348 +0,0 @@ -use blitz_traits::events::{BlitzKeyEvent, BlitzMouseButtonEvent, MouseEventButton}; -use dioxus_html::{ - geometry::{ClientPoint, ElementPoint, PagePoint, ScreenPoint}, - input_data::{MouseButton, MouseButtonSet}, - point_interaction::{ - InteractionElementOffset, InteractionLocation, ModifiersInteraction, PointerInteraction, - }, - AnimationData, CancelData, ClipboardData, CompositionData, DragData, FocusData, FormData, - FormValue, HasFileData, HasFocusData, HasFormData, HasKeyboardData, HasMouseData, - HasTouchData, HasTouchPointData, HtmlEventConverter, ImageData, KeyboardData, MediaData, - MountedData, MouseData, PlatformEventData, PointerData, ResizeData, ScrollData, SelectionData, - ToggleData, TouchData, TouchPoint, TransitionData, VisibleData, WheelData, -}; -use keyboard_types::{Code, Key, Location, Modifiers}; -use std::any::Any; - -pub struct NativeConverter {} - -impl HtmlEventConverter for NativeConverter { - fn convert_cancel_data(&self, _event: &PlatformEventData) -> CancelData { - unimplemented!("todo: convert_cancel_data in dioxus-native. requires support in blitz") - } - - fn convert_form_data(&self, event: &PlatformEventData) -> FormData { - event.downcast::().unwrap().clone().into() - } - - fn convert_mouse_data(&self, event: &PlatformEventData) -> MouseData { - event.downcast::().unwrap().clone().into() - } - - fn convert_keyboard_data(&self, event: &PlatformEventData) -> KeyboardData { - event - .downcast::() - .unwrap() - .clone() - .into() - } - - fn convert_focus_data(&self, _event: &PlatformEventData) -> FocusData { - NativeFocusData {}.into() - } - - fn convert_animation_data(&self, _event: &PlatformEventData) -> AnimationData { - unimplemented!("todo: convert_animation_data in dioxus-native. requires support in blitz") - } - - fn convert_clipboard_data(&self, _event: &PlatformEventData) -> ClipboardData { - unimplemented!("todo: convert_clipboard_data in dioxus-native. requires support in blitz") - } - - fn convert_composition_data(&self, _event: &PlatformEventData) -> CompositionData { - unimplemented!("todo: convert_composition_data in dioxus-native. requires support in blitz") - } - - fn convert_drag_data(&self, _event: &PlatformEventData) -> DragData { - unimplemented!("todo: convert_drag_data in dioxus-native. requires support in blitz") - } - - fn convert_image_data(&self, _event: &PlatformEventData) -> ImageData { - unimplemented!("todo: convert_image_data in dioxus-native. requires support in blitz") - } - - fn convert_media_data(&self, _event: &PlatformEventData) -> MediaData { - unimplemented!("todo: convert_media_data in dioxus-native. requires support in blitz") - } - - fn convert_mounted_data(&self, _event: &PlatformEventData) -> MountedData { - unimplemented!("todo: convert_mounted_data in dioxus-native. requires support in blitz") - } - - fn convert_pointer_data(&self, _event: &PlatformEventData) -> PointerData { - unimplemented!("todo: convert_pointer_data in dioxus-native. requires support in blitz") - } - - fn convert_scroll_data(&self, _event: &PlatformEventData) -> ScrollData { - unimplemented!("todo: convert_scroll_data in dioxus-native. requires support in blitz") - } - - fn convert_selection_data(&self, _event: &PlatformEventData) -> SelectionData { - unimplemented!("todo: convert_selection_data in dioxus-native. requires support in blitz") - } - - fn convert_toggle_data(&self, _event: &PlatformEventData) -> ToggleData { - unimplemented!("todo: convert_toggle_data in dioxus-native. requires support in blitz") - } - - fn convert_touch_data(&self, event: &PlatformEventData) -> TouchData { - // Touch events in blitz-shell 0.2.3 are synthesised as mouse events - // (see patches/blitz-shell). The PlatformEventData wraps a - // NativeClickData whose coordinates carry the touch position. We - // extract those coordinates and build a single-point TouchData. - // - // TODO(multi-touch): only single touch points are forwarded. - // Pinch-to-zoom and two-finger gestures require multi-touch support. - if let Some(click) = event.downcast::() { - let point = NativeTouchPoint { - client_x: click.inner.x as f64, - client_y: click.inner.y as f64, - }; - let touch_data = NativeTouchData { - touches: vec![point.clone()], - changed_touches: vec![point.clone()], - target_touches: vec![point], - modifiers: click.inner.mods, - }; - TouchData::new(touch_data) - } else { - // No recognisable payload — return a zero-coordinate single-point - // event rather than panicking, so the handler receives a valid - // object even if coordinates are unusable. - let point = NativeTouchPoint { client_x: 0.0, client_y: 0.0 }; - TouchData::new(NativeTouchData { - touches: vec![point.clone()], - changed_touches: vec![point.clone()], - target_touches: vec![point], - modifiers: Modifiers::default(), - }) - } - } - - fn convert_transition_data(&self, _event: &PlatformEventData) -> TransitionData { - unimplemented!("todo: convert_transition_data in dioxus-native. requires support in blitz") - } - - fn convert_wheel_data(&self, _event: &PlatformEventData) -> WheelData { - unimplemented!("todo: convert_wheel_data in dioxus-native. requires support in blitz") - } - - fn convert_resize_data(&self, _event: &PlatformEventData) -> ResizeData { - unimplemented!("todo: convert_resize_data in dioxus-native. requires support in blitz") - } - - fn convert_visible_data(&self, _event: &PlatformEventData) -> VisibleData { - unimplemented!("todo: convert_visible_data in dioxus-native. requires support in blitz") - } -} - -#[derive(Clone, Debug)] -pub struct NativeFormData { - pub value: String, - pub values: Vec<(String, FormValue)>, -} - -impl HasFormData for NativeFormData { - fn as_any(&self) -> &dyn std::any::Any { - self as &dyn std::any::Any - } - - fn value(&self) -> String { - self.value.clone() - } - - fn values(&self) -> Vec<(String, FormValue)> { - self.values.clone() - } - fn valid(&self) -> bool { - // todo: actually implement validation here. - true - } -} - -impl HasFileData for NativeFormData { - fn files(&self) -> Vec { - vec![] - } -} - -#[derive(Clone, Debug)] -pub(crate) struct BlitzKeyboardData(pub(crate) BlitzKeyEvent); - -impl ModifiersInteraction for BlitzKeyboardData { - fn modifiers(&self) -> Modifiers { - self.0.modifiers - } -} - -impl HasKeyboardData for BlitzKeyboardData { - fn key(&self) -> Key { - self.0.key.clone() - } - - fn code(&self) -> Code { - self.0.code - } - - fn location(&self) -> Location { - self.0.location - } - - fn is_auto_repeating(&self) -> bool { - self.0.is_auto_repeating - } - - fn is_composing(&self) -> bool { - self.0.is_composing - } - - fn as_any(&self) -> &dyn std::any::Any { - self as &dyn Any - } -} - -#[derive(Clone)] -pub struct NativeClickData { - pub(crate) inner: BlitzMouseButtonEvent, - /// Element-local x coordinate (offset from target element's top-left corner). - /// Computed via blitz-dom `Node::absolute_position` at event dispatch time. - pub(crate) element_x: f32, - /// Element-local y coordinate (offset from target element's top-left corner). - /// Computed via blitz-dom `Node::absolute_position` at event dispatch time. - pub(crate) element_y: f32, -} - -impl InteractionLocation for NativeClickData { - fn client_coordinates(&self) -> ClientPoint { - ClientPoint::new(self.inner.x as _, self.inner.y as _) - } - - fn screen_coordinates(&self) -> ScreenPoint { - unimplemented!() - } - - fn page_coordinates(&self) -> PagePoint { - unimplemented!() - } -} - -impl InteractionElementOffset for NativeClickData { - fn element_coordinates(&self) -> ElementPoint { - ElementPoint::new(self.element_x as _, self.element_y as _) - } -} - -impl ModifiersInteraction for NativeClickData { - fn modifiers(&self) -> Modifiers { - self.inner.mods - } -} - -impl PointerInteraction for NativeClickData { - fn trigger_button(&self) -> Option { - Some(match self.inner.button { - MouseEventButton::Main => MouseButton::Primary, - MouseEventButton::Auxiliary => MouseButton::Auxiliary, - MouseEventButton::Secondary => MouseButton::Secondary, - MouseEventButton::Fourth => MouseButton::Fourth, - MouseEventButton::Fifth => MouseButton::Fifth, - }) - } - - fn held_buttons(&self) -> MouseButtonSet { - dioxus_html::input_data::decode_mouse_button_set(self.inner.buttons.bits() as u16) - } -} -impl HasMouseData for NativeClickData { - fn as_any(&self) -> &dyn std::any::Any { - self as &dyn std::any::Any - } -} - -#[derive(Clone)] -pub struct NativeFocusData {} -impl HasFocusData for NativeFocusData { - fn as_any(&self) -> &dyn std::any::Any { - self as &dyn std::any::Any - } -} - -// ── Touch data types ────────────────────────────────────────────────────────── - -/// A single touch contact point, carrying client-coordinate position. -#[derive(Clone, Debug)] -struct NativeTouchPoint { - client_x: f64, - client_y: f64, -} - -impl InteractionLocation for NativeTouchPoint { - fn client_coordinates(&self) -> ClientPoint { - ClientPoint::new(self.client_x, self.client_y) - } - - fn screen_coordinates(&self) -> ScreenPoint { - // Screen coordinates are not available through the synthesised mouse - // event path; return the client coordinates as a reasonable fallback. - ScreenPoint::new(self.client_x, self.client_y) - } - - fn page_coordinates(&self) -> PagePoint { - PagePoint::new(self.client_x, self.client_y) - } -} - -impl HasTouchPointData for NativeTouchPoint { - fn identifier(&self) -> i32 { - 0 - } - - fn force(&self) -> f64 { - 1.0 - } - - fn radius(&self) -> ScreenPoint { - ScreenPoint::new(1.0, 1.0) - } - - fn rotation(&self) -> f64 { - 0.0 - } - - fn as_any(&self) -> &dyn Any { - self as &dyn Any - } -} - -/// Touch event data for a single synthesised touch contact. -#[derive(Clone, Debug)] -struct NativeTouchData { - touches: Vec, - changed_touches: Vec, - target_touches: Vec, - modifiers: Modifiers, -} - -impl ModifiersInteraction for NativeTouchData { - fn modifiers(&self) -> Modifiers { - self.modifiers - } -} - -impl HasTouchData for NativeTouchData { - fn touches(&self) -> Vec { - self.touches.iter().cloned().map(TouchPoint::new).collect() - } - - fn touches_changed(&self) -> Vec { - self.changed_touches.iter().cloned().map(TouchPoint::new).collect() - } - - fn target_touches(&self) -> Vec { - self.target_touches.iter().cloned().map(TouchPoint::new).collect() - } - - fn as_any(&self) -> &dyn Any { - self as &dyn Any - } -} diff --git a/crates/patches/dioxus-native-dom/src/events/form.rs b/crates/patches/dioxus-native-dom/src/events/form.rs new file mode 100644 index 0000000..a4568a2 --- /dev/null +++ b/crates/patches/dioxus-native-dom/src/events/form.rs @@ -0,0 +1,44 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +use dioxus_html::{FormValue, HasFileData, HasFocusData, HasFormData}; +use std::any::Any; + +#[derive(Clone, Debug)] +pub struct NativeFormData { + pub value: String, + pub values: Vec<(String, FormValue)>, +} + +impl HasFormData for NativeFormData { + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } + + fn value(&self) -> String { + self.value.clone() + } + + fn values(&self) -> Vec<(String, FormValue)> { + self.values.clone() + } + + fn valid(&self) -> bool { + true + } +} + +impl HasFileData for NativeFormData { + fn files(&self) -> Vec { + vec![] + } +} + +#[derive(Clone)] +pub struct NativeFocusData {} + +impl HasFocusData for NativeFocusData { + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } +} diff --git a/crates/patches/dioxus-native-dom/src/events/keyboard.rs b/crates/patches/dioxus-native-dom/src/events/keyboard.rs new file mode 100644 index 0000000..0faef76 --- /dev/null +++ b/crates/patches/dioxus-native-dom/src/events/keyboard.rs @@ -0,0 +1,45 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +use blitz_traits::events::BlitzKeyEvent; +use dioxus_html::{ + point_interaction::ModifiersInteraction, + HasKeyboardData, +}; +use keyboard_types::{Code, Key, Location, Modifiers}; +use std::any::Any; + +#[derive(Clone, Debug)] +pub(crate) struct BlitzKeyboardData(pub(crate) BlitzKeyEvent); + +impl ModifiersInteraction for BlitzKeyboardData { + fn modifiers(&self) -> Modifiers { + self.0.modifiers + } +} + +impl HasKeyboardData for BlitzKeyboardData { + fn key(&self) -> Key { + self.0.key.clone() + } + + fn code(&self) -> Code { + self.0.code + } + + fn location(&self) -> Location { + self.0.location + } + + fn is_auto_repeating(&self) -> bool { + self.0.is_auto_repeating + } + + fn is_composing(&self) -> bool { + self.0.is_composing + } + + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } +} diff --git a/crates/patches/dioxus-native-dom/src/events/mod.rs b/crates/patches/dioxus-native-dom/src/events/mod.rs new file mode 100644 index 0000000..f556b3b --- /dev/null +++ b/crates/patches/dioxus-native-dom/src/events/mod.rs @@ -0,0 +1,145 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +mod form; +mod keyboard; +mod mouse; +mod touch; +mod wheel; + +pub(crate) use form::{NativeFocusData, NativeFormData}; +pub(crate) use keyboard::BlitzKeyboardData; +pub(crate) use mouse::NativeClickData; +use wheel::NativeWheelData; + +use std::future::Future; +use std::pin::Pin; + +use dioxus_html::{ + geometry::{euclid, Pixels, PixelsRect}, + AnimationData, CancelData, ClipboardData, CompositionData, DragData, FocusData, FormData, + HtmlEventConverter, ImageData, KeyboardData, MediaData, MountedData, MountedResult, + MouseData, PlatformEventData, PointerData, RenderedElementBacking, ResizeData, ScrollData, + SelectionData, ToggleData, TouchData, TransitionData, VisibleData, WheelData, +}; +use keyboard_types::Modifiers; +use touch::{NativeTouchData, NativeTouchPoint}; + +/// Platform-side mounted data for the Blitz renderer. +/// +/// Created by [`DioxusDocument::poll`] after layout has been computed for the +/// mounted element, so [`get_client_rect`] returns the post-layout border-box +/// size. Origin is set to (0, 0); absolute position traversal is a TODO. +/// +/// [`get_client_rect`]: BlitzMountedData::get_client_rect +#[derive(Clone)] +pub(crate) struct BlitzMountedData { + /// Border-box size of the mounted element after the preceding layout pass. + pub(crate) rect: PixelsRect, +} + +impl RenderedElementBacking for BlitzMountedData { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn get_client_rect( + &self, + ) -> Pin>>> { + let rect = self.rect; + Box::pin(async move { Ok(rect) }) + } +} + +pub struct NativeConverter {} + +impl HtmlEventConverter for NativeConverter { + fn convert_cancel_data(&self, _: &PlatformEventData) -> CancelData { unimplemented!() } + fn convert_animation_data(&self, _: &PlatformEventData) -> AnimationData { unimplemented!() } + fn convert_clipboard_data(&self, _: &PlatformEventData) -> ClipboardData { unimplemented!() } + fn convert_composition_data(&self, _: &PlatformEventData) -> CompositionData { unimplemented!() } + fn convert_drag_data(&self, _: &PlatformEventData) -> DragData { unimplemented!() } + fn convert_image_data(&self, _: &PlatformEventData) -> ImageData { unimplemented!() } + fn convert_media_data(&self, _: &PlatformEventData) -> MediaData { unimplemented!() } + + fn convert_mounted_data(&self, event: &PlatformEventData) -> MountedData { + // COMPAT(blitz): PlatformEventData wraps BlitzMountedData when fired from + // DioxusDocument::poll after layout; downcast and forward the rect. + event.downcast::() + .map(|d| MountedData::from(d.clone())) + .unwrap_or_else(|| MountedData::from(())) + } + + fn convert_scroll_data(&self, _: &PlatformEventData) -> ScrollData { unimplemented!() } + fn convert_selection_data(&self, _: &PlatformEventData) -> SelectionData { unimplemented!() } + fn convert_toggle_data(&self, _: &PlatformEventData) -> ToggleData { unimplemented!() } + fn convert_transition_data(&self, _: &PlatformEventData) -> TransitionData { unimplemented!() } + fn convert_wheel_data(&self, event: &PlatformEventData) -> WheelData { + // COMPAT(dioxus): blitz-shell 0.2.x does not route MouseWheel through + // Dioxus events; this converter is invoked only if a future blitz + // version adds wheel routing. Fall back to zero delta if no payload. + if let Some(w) = event.downcast::() { + WheelData::new(w.clone()) + } else { + WheelData::new(NativeWheelData { delta_x: 0.0, delta_y: 0.0 }) + } + } + fn convert_resize_data(&self, _: &PlatformEventData) -> ResizeData { unimplemented!() } + fn convert_visible_data(&self, _: &PlatformEventData) -> VisibleData { unimplemented!() } + + fn convert_form_data(&self, event: &PlatformEventData) -> FormData { + event.downcast::().unwrap().clone().into() + } + + fn convert_mouse_data(&self, event: &PlatformEventData) -> MouseData { + event.downcast::().unwrap().clone().into() + } + + fn convert_keyboard_data(&self, event: &PlatformEventData) -> KeyboardData { + event.downcast::().unwrap().clone().into() + } + + fn convert_focus_data(&self, _: &PlatformEventData) -> FocusData { + NativeFocusData {}.into() + } + + fn convert_pointer_data(&self, event: &PlatformEventData) -> PointerData { + PointerData::new(event.downcast::().unwrap().clone()) + } + + fn convert_touch_data(&self, event: &PlatformEventData) -> TouchData { + // Touch events in blitz-shell 0.2.3 are synthesised as mouse events + // (see patches/blitz-shell). NativeClickData carries the touch position. + // + // TODO(iris): SPEC.md §8.4 — only single touch points are forwarded. + if let Some(click) = event.downcast::() { + let pt = NativeTouchPoint { client_x: click.inner.x as f64, client_y: click.inner.y as f64 }; + TouchData::new(NativeTouchData { + touches: vec![pt.clone()], + changed_touches: vec![pt.clone()], + target_touches: vec![pt], + modifiers: click.inner.mods, + }) + } else { + let pt = NativeTouchPoint { client_x: 0.0, client_y: 0.0 }; + TouchData::new(NativeTouchData { + touches: vec![pt.clone()], + changed_touches: vec![pt.clone()], + target_touches: vec![pt], + modifiers: Modifiers::default(), + }) + } + } +} + +/// Build a [`PixelsRect`] with origin (0, 0) and the given border-box dimensions. +/// +/// Origin is set to zero because absolute-position traversal (summing parent +/// `final_layout.location` values) is not yet implemented. +/// TODO(iris): SPEC.md §11.3 — compute absolute origin by walking ancestor chain. +pub(crate) fn make_pixels_rect(width: f32, height: f32) -> PixelsRect { + euclid::Rect::new( + euclid::Point2D::::new(0.0, 0.0), + euclid::Size2D::::new(width as f64, height as f64), + ) +} diff --git a/crates/patches/dioxus-native-dom/src/events/mouse.rs b/crates/patches/dioxus-native-dom/src/events/mouse.rs new file mode 100644 index 0000000..1519d71 --- /dev/null +++ b/crates/patches/dioxus-native-dom/src/events/mouse.rs @@ -0,0 +1,122 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +use blitz_traits::events::{BlitzMouseButtonEvent, MouseEventButton}; +use dioxus_html::{ + geometry::{ClientPoint, ElementPoint, PagePoint, ScreenPoint}, + input_data::{MouseButton, MouseButtonSet}, + point_interaction::{ + InteractionElementOffset, InteractionLocation, ModifiersInteraction, PointerInteraction, + }, + HasMouseData, HasPointerData, +}; +use keyboard_types::Modifiers; +use std::any::Any; + +#[derive(Clone)] +pub struct NativeClickData { + pub(crate) inner: BlitzMouseButtonEvent, + /// Element-local x coordinate (offset from target element's top-left corner). + /// Computed via blitz-dom `Node::absolute_position` at event dispatch time. + pub(crate) element_x: f32, + /// Element-local y coordinate (offset from target element's top-left corner). + /// Computed via blitz-dom `Node::absolute_position` at event dispatch time. + pub(crate) element_y: f32, +} + +impl InteractionLocation for NativeClickData { + fn client_coordinates(&self) -> ClientPoint { + ClientPoint::new(self.inner.x as _, self.inner.y as _) + } + + fn screen_coordinates(&self) -> ScreenPoint { + // Screen coordinates unavailable through the synthesised mouse event path. + ScreenPoint::new(self.inner.x as _, self.inner.y as _) + } + + fn page_coordinates(&self) -> PagePoint { + PagePoint::new(self.inner.x as _, self.inner.y as _) + } +} + +impl InteractionElementOffset for NativeClickData { + fn element_coordinates(&self) -> ElementPoint { + ElementPoint::new(self.element_x as _, self.element_y as _) + } +} + +impl ModifiersInteraction for NativeClickData { + fn modifiers(&self) -> Modifiers { + self.inner.mods + } +} + +impl PointerInteraction for NativeClickData { + fn trigger_button(&self) -> Option { + Some(match self.inner.button { + MouseEventButton::Main => MouseButton::Primary, + MouseEventButton::Auxiliary => MouseButton::Auxiliary, + MouseEventButton::Secondary => MouseButton::Secondary, + MouseEventButton::Fourth => MouseButton::Fourth, + MouseEventButton::Fifth => MouseButton::Fifth, + }) + } + + fn held_buttons(&self) -> MouseButtonSet { + dioxus_html::input_data::decode_mouse_button_set(self.inner.buttons.bits() as u16) + } +} + +impl HasMouseData for NativeClickData { + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } +} + +// COMPAT(dioxus): blitz-shell 0.2.x synthesises pointer events from mouse +// events; we forward the click data as a mouse-type primary pointer. +impl HasPointerData for NativeClickData { + fn pointer_id(&self) -> i32 { + 1 + } + + fn width(&self) -> f64 { + 1.0 + } + + fn height(&self) -> f64 { + 1.0 + } + + fn pressure(&self) -> f32 { + if self.inner.buttons.is_empty() { 0.0 } else { 0.5 } + } + + fn tangential_pressure(&self) -> f32 { + 0.0 + } + + fn tilt_x(&self) -> i32 { + 0 + } + + fn tilt_y(&self) -> i32 { + 0 + } + + fn twist(&self) -> i32 { + 0 + } + + fn pointer_type(&self) -> String { + "mouse".to_string() + } + + fn is_primary(&self) -> bool { + true + } + + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } +} diff --git a/crates/patches/dioxus-native-dom/src/events/touch.rs b/crates/patches/dioxus-native-dom/src/events/touch.rs new file mode 100644 index 0000000..5e37621 --- /dev/null +++ b/crates/patches/dioxus-native-dom/src/events/touch.rs @@ -0,0 +1,88 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +use dioxus_html::{ + geometry::{ClientPoint, PagePoint, ScreenPoint}, + point_interaction::{InteractionLocation, ModifiersInteraction}, + HasTouchData, HasTouchPointData, TouchPoint, +}; +use keyboard_types::Modifiers; +use std::any::Any; + +/// A single touch contact point, carrying client-coordinate position. +#[derive(Clone, Debug)] +pub(crate) struct NativeTouchPoint { + pub(crate) client_x: f64, + pub(crate) client_y: f64, +} + +impl InteractionLocation for NativeTouchPoint { + fn client_coordinates(&self) -> ClientPoint { + ClientPoint::new(self.client_x, self.client_y) + } + + fn screen_coordinates(&self) -> ScreenPoint { + // Screen coordinates unavailable through synthesised mouse event path; + // return client coordinates as a reasonable fallback. + ScreenPoint::new(self.client_x, self.client_y) + } + + fn page_coordinates(&self) -> PagePoint { + PagePoint::new(self.client_x, self.client_y) + } +} + +impl HasTouchPointData for NativeTouchPoint { + fn identifier(&self) -> i32 { + 0 + } + + fn force(&self) -> f64 { + 1.0 + } + + fn radius(&self) -> ScreenPoint { + ScreenPoint::new(1.0, 1.0) + } + + fn rotation(&self) -> f64 { + 0.0 + } + + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } +} + +/// Touch event data for a single synthesised touch contact. +#[derive(Clone, Debug)] +pub(crate) struct NativeTouchData { + pub(crate) touches: Vec, + pub(crate) changed_touches: Vec, + pub(crate) target_touches: Vec, + pub(crate) modifiers: Modifiers, +} + +impl ModifiersInteraction for NativeTouchData { + fn modifiers(&self) -> Modifiers { + self.modifiers + } +} + +impl HasTouchData for NativeTouchData { + fn touches(&self) -> Vec { + self.touches.iter().cloned().map(TouchPoint::new).collect() + } + + fn touches_changed(&self) -> Vec { + self.changed_touches.iter().cloned().map(TouchPoint::new).collect() + } + + fn target_touches(&self) -> Vec { + self.target_touches.iter().cloned().map(TouchPoint::new).collect() + } + + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } +} diff --git a/crates/patches/dioxus-native-dom/src/events/wheel.rs b/crates/patches/dioxus-native-dom/src/events/wheel.rs new file mode 100644 index 0000000..abd7a2c --- /dev/null +++ b/crates/patches/dioxus-native-dom/src/events/wheel.rs @@ -0,0 +1,63 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +// COMPAT(dioxus): blitz-shell 0.2.x routes MouseWheel events directly to +// CSS scroll via scroll_node_by_has_changed — it never fires a Dioxus +// onwheel event. This type exists so convert_wheel_data does not panic +// when/if blitz adds wheel event routing to Dioxus. All coordinate and +// modifier fields default to zero/empty until blitz passes real data. + +use dioxus_html::{ + geometry::{ClientPoint, ElementPoint, PagePoint, ScreenPoint, WheelDelta}, + input_data::{MouseButton, MouseButtonSet}, + point_interaction::{ + InteractionElementOffset, InteractionLocation, ModifiersInteraction, PointerInteraction, + }, + HasMouseData, HasWheelData, +}; +use keyboard_types::Modifiers; +use std::any::Any; + +/// Stub wheel-event data produced when blitz routes a wheel event to Dioxus. +/// +/// `delta_x` and `delta_y` are in screen pixels (positive = scroll down/right). +/// All pointer fields are zero because blitz does not include cursor position +/// in the raw wheel event payload. +#[derive(Clone, Debug)] +pub(crate) struct NativeWheelData { + pub(crate) delta_x: f64, + pub(crate) delta_y: f64, +} + +impl InteractionLocation for NativeWheelData { + fn client_coordinates(&self) -> ClientPoint { ClientPoint::new(0.0, 0.0) } + fn screen_coordinates(&self) -> ScreenPoint { ScreenPoint::new(0.0, 0.0) } + fn page_coordinates(&self) -> PagePoint { PagePoint::new(0.0, 0.0) } +} + +impl InteractionElementOffset for NativeWheelData { + fn element_coordinates(&self) -> ElementPoint { ElementPoint::new(0.0, 0.0) } +} + +impl ModifiersInteraction for NativeWheelData { + fn modifiers(&self) -> Modifiers { Modifiers::default() } +} + +impl PointerInteraction for NativeWheelData { + fn trigger_button(&self) -> Option { None } + fn held_buttons(&self) -> MouseButtonSet { + dioxus_html::input_data::decode_mouse_button_set(0) + } +} + +impl HasMouseData for NativeWheelData { + fn as_any(&self) -> &dyn Any { self as &dyn Any } +} + +impl HasWheelData for NativeWheelData { + fn delta(&self) -> WheelDelta { + WheelDelta::pixels(self.delta_x, self.delta_y, 0.0) + } + + fn as_any(&self) -> &dyn Any { self as &dyn Any } +} diff --git a/crates/patches/dioxus-native-dom/src/mutation_writer.rs b/crates/patches/dioxus-native-dom/src/mutation_writer.rs index 104df09..2eb0494 100644 --- a/crates/patches/dioxus-native-dom/src/mutation_writer.rs +++ b/crates/patches/dioxus-native-dom/src/mutation_writer.rs @@ -19,6 +19,9 @@ pub struct DioxusState { pub(crate) node_id_mapping: Vec>, /// Count of each handler type pub(crate) event_handler_counts: [u32; 32], + /// Elements that registered `onmounted` during the current mutation pass. + /// Drained by [`DioxusDocument::poll`] after layout has been computed. + pub(crate) pending_mounted_ids: Vec<(ElementId, NodeId)>, } impl DioxusState { @@ -29,6 +32,7 @@ impl DioxusState { stack: vec![root_id], node_id_mapping: vec![Some(root_id)], event_handler_counts: [0; 32], + pending_mounted_ids: Vec::new(), } } @@ -255,6 +259,14 @@ impl WriteMutations for MutationWriter<'_> { let idx = kind.discriminant() as usize; self.state.event_handler_counts[idx] += 1; } + + // COMPAT(blitz): `mounted` is not a DomEventKind — it is not routed through + // the normal Blitz event dispatch path. Queue it so DioxusDocument::poll can + // fire it after the next layout pass with the element's actual dimensions. + if name == "mounted" { + let node_id = self.state.element_to_node_id(id); + self.state.pending_mounted_ids.push((id, node_id)); + } } fn remove_event_listener(&mut self, name: &'static str, _id: ElementId) { diff --git a/crates/patches/wgpu-context/.cargo-ok b/crates/patches/wgpu-context/.cargo-ok new file mode 100644 index 0000000..5f8b795 --- /dev/null +++ b/crates/patches/wgpu-context/.cargo-ok @@ -0,0 +1 @@ +{"v":1} \ No newline at end of file diff --git a/crates/patches/wgpu-context/.cargo_vcs_info.json b/crates/patches/wgpu-context/.cargo_vcs_info.json new file mode 100644 index 0000000..072b645 --- /dev/null +++ b/crates/patches/wgpu-context/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "03ae9a4204583735e74ec95a13465cc65d32b63d" + }, + "path_in_vcs": "crates/wgpu_context" +} \ No newline at end of file diff --git a/crates/patches/wgpu-context/Cargo.lock b/crates/patches/wgpu-context/Cargo.lock new file mode 100644 index 0000000..bf5f00a --- /dev/null +++ b/crates/patches/wgpu-context/Cargo.lock @@ -0,0 +1,1171 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags", + "core-foundation", + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "naga" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916cbc7cb27db60be930a4e2da243cf4bc39569195f22fd8ee419cd31d5b662c" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash", + "spirv", + "thiserror 2.0.17", + "unicode-ident", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "ordered-float" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2c1f9f56e534ac6a9b8a4600bdf0f530fb393b5f393e7b4d03489c3cf0c3f01" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "slotmap" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70b6ff82bbf6e9206828e1a3178e851f8c20f1c9028e74dd3a8090741ccd5798" +dependencies = [ + "arrayvec", + "bitflags", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f62f1053bd28c2268f42916f31588f81f64796e2ff91b81293515017ca8bd9" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror 2.0.17", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18ae5fbde6a4cbebae38358aa73fcd6e0f15c6144b67ef5dc91ded0db125dbdf" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7670e390f416006f746b4600fdd9136455e3627f5bd763abf9a65daa216dd2d" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720a5cb9d12b3d337c15ff0e24d3e97ed11490ff3f7506e7f3d98c68fa5d6f14" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "26.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d0e67224cc7305b3b4eb2cc57ca4c4c3afc665c1d1bee162ea806e19c47bdd" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.15.5", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.17", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-types" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.17", + "web-sys", +] + +[[package]] +name = "wgpu_context" +version = "0.1.2" +dependencies = [ + "futures-intrusive", + "wgpu", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/crates/patches/wgpu-context/Cargo.toml b/crates/patches/wgpu-context/Cargo.toml new file mode 100644 index 0000000..e3b2ea0 --- /dev/null +++ b/crates/patches/wgpu-context/Cargo.toml @@ -0,0 +1,41 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "wgpu_context" +version = "0.1.2" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Context for managing WGPU surfaces" +homepage = "https://github.com/dioxuslabs/anyrender" +documentation = "https://docs.rs/wgpu_context" +readme = false +license = "MIT OR Apache-2.0" +repository = "https://github.com/dioxuslabs/anyrender" +resolver = "2" + +[lib] +name = "wgpu_context" +path = "src/lib.rs" + +[dependencies.futures-intrusive] +version = "0.5.0" + +[dependencies.tracing] +version = "0.1" + +[dependencies.wgpu] +version = "26" diff --git a/crates/patches/wgpu-context/Cargo.toml.orig b/crates/patches/wgpu-context/Cargo.toml.orig new file mode 100644 index 0000000..bd33d45 --- /dev/null +++ b/crates/patches/wgpu-context/Cargo.toml.orig @@ -0,0 +1,13 @@ +[package] +name = "wgpu_context" +description = "Context for managing WGPU surfaces" +version = "0.1.2" +documentation = "https://docs.rs/wgpu_context" +homepage.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true + +[dependencies] +wgpu = { workspace = true } +futures-intrusive = { workspace = true } diff --git a/crates/patches/wgpu-context/src/buffer_renderer.rs b/crates/patches/wgpu-context/src/buffer_renderer.rs new file mode 100644 index 0000000..13dd082 --- /dev/null +++ b/crates/patches/wgpu-context/src/buffer_renderer.rs @@ -0,0 +1,179 @@ +use crate::{DeviceHandle, block_on_wgpu, util::create_texture}; +use wgpu::{ + BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Device, Extent3d, Queue, + TexelCopyBufferInfo, TexelCopyBufferLayout, TextureFormat, TextureUsages, TextureView, +}; + +#[derive(Clone, Debug)] +pub struct BufferRendererConfig { + pub width: u32, + pub height: u32, + pub usage: TextureUsages, +} + +/// Utility struct for rendering to `Vec` +pub struct BufferRenderer { + // The device and queue for rendering to the surface + pub dev_id: usize, + pub device_handle: DeviceHandle, + + config: BufferRendererConfig, + texture_view: wgpu::TextureView, + gpu_buffer: wgpu::Buffer, +} + +impl std::fmt::Debug for BufferRenderer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SurfaceRenderer") + .field("dev_id", &self.dev_id) + .field("config", &self.config) + .finish() + } +} + +impl BufferRenderer { + /// Creates a new render surface for the specified window and dimensions. + pub fn new(config: BufferRendererConfig, device_handle: DeviceHandle, dev_id: usize) -> Self { + let texture_view = create_texture( + config.width, + config.height, + TextureFormat::Rgba8Unorm, + config.usage | TextureUsages::COPY_SRC, + &device_handle.device, + ); + + let padded_byte_width = (config.width * 4).next_multiple_of(256); + let buffer_size = padded_byte_width as u64 * config.height as u64; + let gpu_buffer = device_handle.device.create_buffer(&BufferDescriptor { + label: None, + size: buffer_size, + usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + Self { + dev_id, + device_handle, + config, + texture_view, + gpu_buffer, + } + } + + pub fn device(&self) -> &Device { + &self.device_handle.device + } + + pub fn queue(&self) -> &Queue { + &self.device_handle.queue + } + + pub fn size(&self) -> Extent3d { + Extent3d { + width: self.config.width, + height: self.config.height, + depth_or_array_layers: 1, + } + } + + pub fn resize(&mut self, width: u32, height: u32) { + self.config.width = width; + self.config.height = height; + self.texture_view = create_texture( + self.config.width, + self.config.height, + TextureFormat::Rgba8Unorm, + self.config.usage | TextureUsages::COPY_SRC, + &self.device_handle.device, + ); + + let padded_byte_width = (width * 4).next_multiple_of(256); + let buffer_size = padded_byte_width as u64 * height as u64; + self.gpu_buffer = self.device_handle.device.create_buffer(&BufferDescriptor { + label: None, + size: buffer_size, + usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + // /// Resizes the surface to the new dimensions. + // pub fn resize(&mut self, width: u32, height: u32) { + // // TODO: Use clever resize semantics to avoid thrashing the memory allocator during a resize + // // especially important on metal. + // if let Some(intermediate_texture_stuff) = &mut self.intermediate_texture { + // intermediate_texture_stuff.texture_view = create_intermediate_texture( + // width, + // height, + // intermediate_texture_stuff.config.usage, + // &self.device_handle.device, + // ); + // } + // self.config.width = width; + // self.config.height = height; + // self.configure(); + // } + + pub fn target_texture_view(&self) -> TextureView { + self.texture_view.clone() + } + + pub fn copy_texture_to_vec(&self, cpu_buffer: &mut Vec) { + cpu_buffer.clear(); + cpu_buffer.reserve((self.config.width * self.config.height * 4) as usize); + self.copy_texture_to_buffer(&mut *cpu_buffer); + } + + pub fn copy_texture_to_buffer(&self, cpu_buffer: &mut [u8]) { + let mut encoder = self + .device() + .create_command_encoder(&CommandEncoderDescriptor { + label: Some("Copy out buffer"), + }); + let row_byte_width = self.config.width as usize * 4; + let padded_row_byte_width = row_byte_width.next_multiple_of(256); + + let texture = self.texture_view.texture(); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + TexelCopyBufferInfo { + buffer: &self.gpu_buffer, + layout: TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_row_byte_width as u32), + rows_per_image: None, + }, + }, + texture.size(), + ); + + self.queue().submit([encoder.finish()]); + let buf_slice = self.gpu_buffer.slice(..); + + let (sender, receiver) = futures_intrusive::channel::shared::oneshot_channel(); + buf_slice.map_async(wgpu::MapMode::Read, move |v| sender.send(v).unwrap()); + + if let Ok(recv_result) = + block_on_wgpu(self.device(), receiver.receive()).inspect_err(|err| { + panic!("channel inaccessible: {:#}", err); + }) + { + let _ = recv_result.unwrap(); + } + + let data = buf_slice.get_mapped_range(); + + // Pad result + for row in 0..(self.config.height as usize) { + let src_start = row * padded_row_byte_width; + let src = &data[src_start..(src_start + row_byte_width)]; + + let dest_start = row * row_byte_width; + cpu_buffer[dest_start..(dest_start + row_byte_width)].clone_from_slice(src); + } + + // Unmap buffer + drop(data); + self.gpu_buffer.unmap(); + } +} diff --git a/crates/patches/wgpu-context/src/error.rs b/crates/patches/wgpu-context/src/error.rs new file mode 100644 index 0000000..26cab3b --- /dev/null +++ b/crates/patches/wgpu-context/src/error.rs @@ -0,0 +1,79 @@ +//! Error type for WGPU Context + +use std::error::Error; +use std::fmt::Display; +use wgpu::{PollError, RequestAdapterError, RequestDeviceError}; + +/// Errors that can occur in WgpuContext. +#[derive(Debug)] +pub enum WgpuContextError { + /// There is no available device with the features required by Vello. + NoCompatibleDevice, + /// Failed to create surface. + /// See [`wgpu::CreateSurfaceError`] for more information. + WgpuCreateSurfaceError(wgpu::CreateSurfaceError), + /// Surface doesn't support the required texture formats. + /// Make sure that you have a surface which provides one of + /// `TextureFormat::Rgba8Unorm` + /// or `TextureFormat::Bgra8Unorm` as texture formats. + // TODO: Why does this restriction exist? + UnsupportedSurfaceFormat, + /// Wgpu failed to request an adapter + RequestAdapterError(RequestAdapterError), + /// Wgpu failed to request a device + RequestDeviceError(RequestDeviceError), + /// Wgpu failed to poll a device + PollError(PollError), +} + +impl Display for WgpuContextError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoCompatibleDevice => writeln!(f, "Couldn't find suitable device"), + Self::WgpuCreateSurfaceError(inner) => { + writeln!(f, "Couldn't create wgpu surface")?; + inner.fmt(f) + } + Self::UnsupportedSurfaceFormat => { + writeln!( + f, + "Couldn't find `Rgba8Unorm` or `Bgra8Unorm` texture formats for surface" + ) + } + Self::RequestAdapterError(inner) => { + writeln!(f, "Couldn't request an adapter: {:#}", inner) + } + Self::RequestDeviceError(inner) => { + writeln!(f, "Couldn't request a device: {:#}", inner) + } + Self::PollError(inner) => { + writeln!(f, "Couldn't poll a device: {:#}", inner) + } + } + } +} + +impl Error for WgpuContextError {} +impl From for WgpuContextError { + fn from(value: wgpu::CreateSurfaceError) -> Self { + Self::WgpuCreateSurfaceError(value) + } +} + +impl From for WgpuContextError { + fn from(value: RequestAdapterError) -> Self { + Self::RequestAdapterError(value) + } +} + +impl From for WgpuContextError { + fn from(value: RequestDeviceError) -> Self { + Self::RequestDeviceError(value) + } +} + +impl From for WgpuContextError { + fn from(value: PollError) -> Self { + Self::PollError(value) + } +} diff --git a/crates/patches/wgpu-context/src/lib.rs b/crates/patches/wgpu-context/src/lib.rs new file mode 100644 index 0000000..567319e --- /dev/null +++ b/crates/patches/wgpu-context/src/lib.rs @@ -0,0 +1,178 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Simple helpers for managing wgpu state and surfaces. + +use wgpu::{ + Adapter, Device, Features, Instance, Limits, MemoryHints, Queue, Surface, SurfaceTarget, +}; + +mod buffer_renderer; +mod error; +mod surface_renderer; +mod util; + +pub use buffer_renderer::{BufferRenderer, BufferRendererConfig}; +pub use error::WgpuContextError; +pub use surface_renderer::{SurfaceRenderer, SurfaceRendererConfiguration, TextureConfiguration}; +pub use util::block_on_wgpu; + +/// A wgpu `Device`, it's associated `Queue`, and the `Adapter` and `Instance` used to create them +#[derive(Clone, Debug)] +pub struct DeviceHandle { + pub instance: Instance, + pub adapter: Adapter, + pub device: Device, + pub queue: Queue, +} + +/// Simple render context that maintains wgpu state for rendering the pipeline. +pub struct WGPUContext { + /// A WGPU `Instance`. This only needs to be created once per application. + pub instance: Instance, + /// A pool of already-created devices so that we can avoid recreating devices + /// when we already have a suitable one available + pub device_pool: Vec, + + // Config + extra_features: Option, + override_limits: Option, +} + +impl Default for WGPUContext { + fn default() -> Self { + Self::new() + } +} + +impl WGPUContext { + pub fn new() -> Self { + Self::with_features_and_limits(None, None) + } + + pub fn with_features_and_limits( + extra_features: Option, + override_limits: Option, + ) -> Self { + Self { + instance: Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::from_env().unwrap_or_default(), + flags: wgpu::InstanceFlags::from_build_config().with_env(), + backend_options: wgpu::BackendOptions::from_env_or_default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + }), + device_pool: Vec::new(), + extra_features, + override_limits, + } + } + + /// Creates a new surface for the specified window and dimensions. + pub async fn create_surface<'w>( + &mut self, + window: impl Into>, + surface_config: SurfaceRendererConfiguration, + intermediate_texture_config: Option, + ) -> Result, WgpuContextError> { + // Create a surface from the window handle + let surface = self.instance.create_surface(window.into())?; + + // Find or create a suitable device for rendering to the surface + let dev_id = self + .find_or_create_device(Some(&surface)) + .await + .or(Err(WgpuContextError::NoCompatibleDevice))?; + let device_handle = self.device_pool[dev_id].clone(); + + SurfaceRenderer::new( + surface, + surface_config, + intermediate_texture_config, + device_handle, + dev_id, + ) + .await + } + + /// Creates a new `BufferRenderer` for the specified dimensions. + pub async fn create_buffer_renderer( + &mut self, + config: BufferRendererConfig, + ) -> Result { + // Find or create a suitable device for rendering to the surface + let dev_id = self + .find_or_create_device(None) + .await + .or(Err(WgpuContextError::NoCompatibleDevice))?; + let device_handle = self.device_pool[dev_id].clone(); + + Ok(BufferRenderer::new(config, device_handle, dev_id)) + } + + /// Finds or creates a compatible device handle id. + pub async fn find_or_create_device( + &mut self, + compatible_surface: Option<&Surface<'_>>, + ) -> Result { + match self.find_existing_device(compatible_surface) { + Some(device_id) => Ok(device_id), + None => self.create_device(compatible_surface).await, + } + } + + /// Finds or creates a compatible device handle id. + fn find_existing_device(&self, compatible_surface: Option<&Surface<'_>>) -> Option { + match compatible_surface { + Some(s) => self + .device_pool + .iter() + .enumerate() + .find(|(_, d)| d.adapter.is_surface_supported(s)) + .map(|(i, _)| i), + None => (!self.device_pool.is_empty()).then_some(0), + } + } + + /// Creates a compatible device handle id. + async fn create_device( + &mut self, + compatible_surface: Option<&Surface<'_>>, + ) -> Result { + let instance = self.instance.clone(); + let adapter = + wgpu::util::initialize_adapter_from_env_or_default(&instance, compatible_surface) + .await?; + + // Determine features to request + // The user may request additional features + let requested_features = self.extra_features.unwrap_or(Features::empty()); + let available_features = adapter.features(); + let required_features = requested_features & available_features; + + // Determine limits to request + // The user may override the limits + let required_limits = self.override_limits.clone().unwrap_or_default(); + + // Create the device and the queue + let descripter = wgpu::DeviceDescriptor { + label: None, + required_features, + required_limits, + memory_hints: MemoryHints::MemoryUsage, + trace: wgpu::Trace::default(), + }; + let (device, queue) = adapter.request_device(&descripter).await?; + + // Create the device handle and store in the pool + let device_handle = DeviceHandle { + instance, + adapter, + device, + queue, + }; + self.device_pool.push(device_handle); + + // Return the ID + Ok(self.device_pool.len() - 1) + } +} diff --git a/crates/patches/wgpu-context/src/surface_renderer.rs b/crates/patches/wgpu-context/src/surface_renderer.rs new file mode 100644 index 0000000..854243e --- /dev/null +++ b/crates/patches/wgpu-context/src/surface_renderer.rs @@ -0,0 +1,271 @@ +use crate::{DeviceHandle, WgpuContextError, util::create_texture}; +use wgpu::{ + CommandEncoderDescriptor, CompositeAlphaMode, Device, PresentMode, Queue, Surface, + SurfaceConfiguration, SurfaceTexture, TextureFormat, TextureUsages, TextureView, + TextureViewDescriptor, util::TextureBlitter, +}; + +#[derive(Clone)] +pub struct TextureConfiguration { + pub usage: TextureUsages, +} + +#[derive(Clone)] +pub struct SurfaceRendererConfiguration { + /// The usage of the swap chain. The only usage guaranteed to be supported is [`TextureUsages::RENDER_ATTACHMENT`]. + pub usage: TextureUsages, + /// The texture format of the swap chain. The only formats that are guaranteed are + /// [`TextureFormat::Bgra8Unorm`] and [`TextureFormat::Bgra8UnormSrgb`]. + pub formats: Vec, + /// Width of the swap chain. Must be the same size as the surface, and nonzero. + /// + /// If this is not the same size as the underlying surface (e.g. if it is + /// set once, and the window is later resized), the behaviour is defined + /// but platform-specific, and may change in the future (currently macOS + /// scales the surface, other platforms may do something else). + pub width: u32, + /// Height of the swap chain. Must be the same size as the surface, and nonzero. + /// + /// If this is not the same size as the underlying surface (e.g. if it is + /// set once, and the window is later resized), the behaviour is defined + /// but platform-specific, and may change in the future (currently macOS + /// scales the surface, other platforms may do something else). + pub height: u32, + /// Presentation mode of the swap chain. Fifo is the only mode guaranteed to be supported. + /// `FifoRelaxed`, `Immediate`, and `Mailbox` will crash if unsupported, while `AutoVsync` and + /// `AutoNoVsync` will gracefully do a designed sets of fallbacks if their primary modes are + /// unsupported. + pub present_mode: PresentMode, + /// Desired maximum number of frames that the presentation engine should queue in advance. + /// + /// This is a hint to the backend implementation and will always be clamped to the supported range. + /// As a consequence, either the maximum frame latency is set directly on the swap chain, + /// or waits on present are scheduled to avoid exceeding the maximum frame latency if supported, + /// or the swap chain size is set to (max-latency + 1). + /// + /// Defaults to 2 when created via `Surface::get_default_config`. + /// + /// Typical values range from 3 to 1, but higher values are possible: + /// * Choose 2 or higher for potentially smoother frame display, as it allows to be at least one frame + /// to be queued up. This typically avoids starving the GPU's work queue. + /// Higher values are useful for achieving a constant flow of frames to the display under varying load. + /// * Choose 1 for low latency from frame recording to frame display. + /// ⚠️ If the backend does not support waiting on present, this will cause the CPU to wait for the GPU + /// to finish all work related to the previous frame when calling `Surface::get_current_texture`, + /// causing CPU-GPU serialization (i.e. when `Surface::get_current_texture` returns, the GPU might be idle). + /// It is currently not possible to query this. See . + /// * A value of 0 is generally not supported and always clamped to a higher value. + pub desired_maximum_frame_latency: u32, + /// Specifies how the alpha channel of the textures should be handled during compositing. + pub alpha_mode: CompositeAlphaMode, + /// Specifies what view formats will be allowed when calling `Texture::create_view` on the texture returned by `Surface::get_current_texture`. + /// + /// View formats of the same format as the texture are always allowed. + /// + /// Note: currently, only the srgb-ness is allowed to change. (ex: `Rgba8Unorm` texture + `Rgba8UnormSrgb` view) + pub view_formats: Vec, +} + +struct IntermediateTextureStuff { + pub config: TextureConfiguration, + // TextureView for the intermediate Texture which we sometimes render to because compute shaders + // cannot always render directly to surfaces. Since WGPU 26, the underlying Texture can be accessed + // from the TextureView so we don't need to store both. + pub texture_view: TextureView, + // Blitter for blitting from the intermediate texture to the surface. + pub blitter: TextureBlitter, +} + +/// Combination of surface and its configuration. +pub struct SurfaceRenderer<'s> { + // The device and queue for rendering to the surface + pub dev_id: usize, + pub device_handle: DeviceHandle, + + // The surface and it's configuration + pub surface: Surface<'s>, + pub config: SurfaceConfiguration, + + intermediate_texture: Option>, +} + +impl std::fmt::Debug for SurfaceRenderer<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SurfaceRenderer") + .field("dev_id", &self.dev_id) + .field("surface_config", &self.config) + .field("has_intermediate_texture", &true) + .finish() + } +} + +impl<'s> SurfaceRenderer<'s> { + /// Creates a new render surface for the specified window and dimensions. + pub async fn new<'w>( + surface: Surface<'w>, + surface_renderer_config: SurfaceRendererConfiguration, + intermediate_texture_config: Option, + device_handle: DeviceHandle, + dev_id: usize, + ) -> Result, WgpuContextError> { + // Convert SurfaceRendererConfiguration to SurfaceConfiguration. + // The difference is that `format` is a Vec in SurfaceRendererConfiguration and a single value in SurfaceConfiguration + let surface_config = SurfaceConfiguration { + usage: surface_renderer_config.usage, + format: surface + .get_capabilities(&device_handle.adapter) + .formats + .into_iter() + .find(|it| surface_renderer_config.formats.contains(it)) + .ok_or(WgpuContextError::UnsupportedSurfaceFormat)?, + width: surface_renderer_config.width, + height: surface_renderer_config.height, + present_mode: surface_renderer_config.present_mode, + desired_maximum_frame_latency: surface_renderer_config.desired_maximum_frame_latency, + alpha_mode: surface_renderer_config.alpha_mode, + view_formats: surface_renderer_config.view_formats, + }; + + let intermediate_texture = intermediate_texture_config.map(|texture_config| { + Box::new(IntermediateTextureStuff { + config: texture_config.clone(), + texture_view: create_texture( + surface_renderer_config.width, + surface_renderer_config.height, + TextureFormat::Rgba8Unorm, + texture_config.usage, + &device_handle.device, + ), + blitter: TextureBlitter::new(&device_handle.device, surface_config.format), + }) + }); + + let surface = SurfaceRenderer { + dev_id, + device_handle, + surface, + config: surface_config, + intermediate_texture, + }; + surface.configure(); + Ok(surface) + } + + pub fn device(&self) -> &Device { + &self.device_handle.device + } + + pub fn queue(&self) -> &Queue { + &self.device_handle.queue + } + + /// Resizes the surface to the new dimensions. + pub fn resize(&mut self, width: u32, height: u32) { + // TODO: Use clever resize semantics to avoid thrashing the memory allocator during a resize + // especially important on metal. + if let Some(intermediate_texture_stuff) = &mut self.intermediate_texture { + intermediate_texture_stuff.texture_view = create_texture( + width, + height, + TextureFormat::Rgba8Unorm, + intermediate_texture_stuff.config.usage, + &self.device_handle.device, + ); + } + self.config.width = width; + self.config.height = height; + self.configure(); + } + + pub fn set_present_mode(&mut self, present_mode: wgpu::PresentMode) { + self.config.present_mode = present_mode; + self.configure(); + } + + fn configure(&self) { + self.surface + .configure(&self.device_handle.device, &self.config); + } + + pub fn current_surface_texture(&self) -> SurfaceTexture { + self.surface + .get_current_texture() + .expect("failed to get surface texture") + } + + pub fn target_texture_view(&self) -> TextureView { + match &self.intermediate_texture { + Some(intermediate_texture) => intermediate_texture.texture_view.clone(), + None => { + let surface_texture = self + .surface + .get_current_texture() + .expect("failed to get surface texture"); + surface_texture + .texture + .create_view(&TextureViewDescriptor::default()) + } + } + } + + pub fn maybe_blit_and_present(&self) { + // PATCH(iris): handle Outdated and Lost gracefully instead of panicking. + // On Windows/DX12, registering a CustomPaintSource via use_wgpu triggers + // an extra render cycle during window init that returns Outdated before + // the swap chain is ready. Skipping the frame is correct — it will retry + // on the next redraw event. Lost requires surface recreation (handled by + // the caller on the next frame via resize). See docs/patches.md. + let surface_texture = match self.surface.get_current_texture() { + Ok(t) => t, + Err(wgpu::SurfaceError::Outdated) | Err(wgpu::SurfaceError::Lost) => { + tracing::warn!( + "wgpu_context: surface {:?} — skipping frame", + self.surface.get_current_texture().err() + ); + return; + } + Err(e) => panic!("failed to get surface texture: {e}"), + }; + + if let Some(its) = &self.intermediate_texture { + self.blit_from_intermediate_texture_to_surface(&surface_texture, its); + } + + surface_texture.present(); + } + + /// Blit from the intermediate texture to the surface texture + fn blit_from_intermediate_texture_to_surface( + &self, + surface_texture: &SurfaceTexture, + intermediate_texture_stuff: &IntermediateTextureStuff, + ) { + // TODO: verify that handling of SurfaceError::Outdated is no longer required + // + // let surface_texture = match state.surface.surface.get_current_texture() { + // Ok(surface) => surface, + // // When resizing too aggresively, the surface can get outdated (another resize) before being rendered into + // Err(SurfaceError::Outdated) => return, + // Err(_) => panic!("failed to get surface texture"), + // }; + + // Perform the copy + // (TODO: Does it improve throughput to acquire the surface after the previous texture render has happened?) + let mut encoder = + self.device_handle + .device + .create_command_encoder(&CommandEncoderDescriptor { + label: Some("Surface Blit"), + }); + + intermediate_texture_stuff.blitter.copy( + &self.device_handle.device, + &mut encoder, + &intermediate_texture_stuff.texture_view, + &surface_texture + .texture + .create_view(&TextureViewDescriptor::default()), + ); + self.device_handle.queue.submit([encoder.finish()]); + } +} diff --git a/crates/patches/wgpu-context/src/util.rs b/crates/patches/wgpu-context/src/util.rs new file mode 100644 index 0000000..95a72f2 --- /dev/null +++ b/crates/patches/wgpu-context/src/util.rs @@ -0,0 +1,59 @@ +use crate::WgpuContextError; +use wgpu::{Device, TextureFormat, TextureUsages, TextureView}; + +/// Block on a future, polling the device as needed. +/// +/// This will deadlock if the future is awaiting anything other than GPU progress. +#[cfg_attr(docsrs, doc(hidden))] +pub fn block_on_wgpu(device: &Device, fut: F) -> Result { + if cfg!(target_arch = "wasm32") { + panic!("WGPU is inherently async on WASM, so blocking doesn't work."); + } + + // Dummy waker + struct NullWake; + impl std::task::Wake for NullWake { + fn wake(self: std::sync::Arc) {} + } + + // Create context to poll future with + let waker = std::task::Waker::from(std::sync::Arc::new(NullWake)); + let mut context = std::task::Context::from_waker(&waker); + + // Same logic as `pin_mut!` macro from `pin_utils`. + let mut fut = std::pin::pin!(fut); + loop { + match fut.as_mut().poll(&mut context) { + std::task::Poll::Pending => { + device.poll(wgpu::PollType::Wait)?; + } + std::task::Poll::Ready(item) => break Ok(item), + } + } +} + +/// Create a WGPU Texture, returning a default TextureView +pub(crate) fn create_texture( + width: u32, + height: u32, + format: TextureFormat, + usage: TextureUsages, + device: &Device, +) -> TextureView { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + usage, + format, + view_formats: &[], + }); + + texture.create_view(&wgpu::TextureViewDescriptor::default()) +} diff --git a/docs/patches.md b/docs/patches.md new file mode 100644 index 0000000..97bcd09 --- /dev/null +++ b/docs/patches.md @@ -0,0 +1,85 @@ +# Vendored Crate Patches + +This document describes every patched dependency in `crates/patches/`. +Each entry explains what was wrong, what was changed, and when to remove it. + +--- + +## blitz-dom (v0.2.4) + +**File:** `crates/patches/blitz-dom/` +**Upstream:** + +**Problem:** `tabindex` focus-on-click did not work for non-`` elements, +so the wgpu canvas element could not receive keyboard focus after a click. + +**Fix:** Override `handle_click` to set focus on any focusable element. + +**Remove when:** upstream blitz-dom makes tabindex focus on click work for +non-input elements. + +--- + +## blitz-shell (v0.2.3) + +**File:** `crates/patches/blitz-shell/` +**Upstream:** + +**Problem:** `WindowEvent::Touch` was silently discarded — Dioxus +`ontouchstart` / `ontouchmove` / `ontouchend` handlers never fired on +touch-screen devices. + +**Fix:** Synthesise `UiEvent::MouseDown` / `MouseMove` / `MouseUp` events +from touch contacts so that Dioxus touch handlers receive the correct +coordinates. Only single-contact touch is forwarded; multi-touch requires +native `UiEvent::Touch*` variants which do not exist in blitz-traits 0.2.x. + +**Remove when:** blitz-shell implements native touch event routing. + +--- + +## dioxus-native-dom (v0.7.9) + +**File:** `crates/patches/dioxus-native-dom/` +**Upstream:** + +**Problem:** `HtmlEventConverter` methods for touch, pointer, and wheel +events were all `unimplemented!()`, causing runtime panics when Dioxus +dispatched those event types. + +**Fix:** +- `convert_touch_data` — extracts coordinates from the synthesised + `NativeClickData` produced by the blitz-shell touch patch. +- `convert_pointer_data` — delegates to `NativeClickData`, synthesising a + `"mouse"` type primary pointer (pressure 0.5 when buttons held, 0.0 + otherwise). +- `convert_wheel_data` — returns a zero-delta `WheelData` via `NativeWheelData` + instead of panicking. The event itself is not yet routed by blitz-shell + (see TODO in `canvas_widget.rs §11.2`); this prevents crashes if routing + is added in future. + +**Remove when:** upstream dioxus-native-dom implements these converters. + +--- + +## wgpu_context (v0.1.2) + +**File:** `crates/patches/wgpu-context/` +**Upstream:** + +**Problem:** `maybe_blit_and_present()` calls `.expect()` on +`Surface::get_current_texture()`, panicking on `SurfaceError::Outdated` +and `SurfaceError::Lost`. + +On Windows/DX12, registering a `CustomPaintSource` via `use_wgpu` triggers +an extra render cycle during window initialisation that returns `Outdated` +before the swap chain is ready. The original author noted this as a known +gap with a TODO comment at `surface_renderer.rs:228`. + +**Fix:** Match on the error and return early (skip the frame) for `Outdated` +and `Lost`. The next redraw event retries and succeeds. +`SurfaceError::Timeout` and unknown errors still panic. + +**Remove when:** wgpu_context upstream fixes the TODO comment at +`surface_renderer.rs:228` ("verify that handling of SurfaceError::Outdated +is no longer required").