From 4699ed668e8ea211704d53ea978451140179b82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 25 Sep 2026 15:55:53 -0700 Subject: [PATCH 1/8] A few misc fixes. --- crates/processing_pyo3/mewnala/__init__.py | 26 ++++++++++++---------- crates/processing_pyo3/mewnala/math.py | 6 +++++ crates/processing_render/src/render/mod.rs | 2 +- src/lib.rs | 6 ++++- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/crates/processing_pyo3/mewnala/__init__.py b/crates/processing_pyo3/mewnala/__init__.py index f3d6f78a..44d2cbae 100644 --- a/crates/processing_pyo3/mewnala/__init__.py +++ b/crates/processing_pyo3/mewnala/__init__.py @@ -5,22 +5,18 @@ # through an explicit alias rather than the shadowed names. import builtins as _builtins -# re-export the native submodules as submodules of this module, if they exist -# this allows users to import from `mewnala.math` without needing to know about -# the internal structure of the native module +# re-export native submodules that have no Python counterpart as submodules of +# this module, so users can import from `mewnala.color` without knowing about +# the internal structure of the native module. import sys as _sys from . import mewnala as _native -for _name in ("math",): - _sub = getattr(_native, _name, None) - if _sub is not None: - _sys.modules[f"{__name__}.{_name}"] = _sub - _color = getattr(_native, "color", None) if _color is not None: _sys.modules[f"{__name__}.color"] = _color -from . import math # noqa: E402 (Python submodule, extends native math) +import importlib as _importlib +math = _importlib.import_module(f"{__name__}.math") # noqa: E402 from .math import * # noqa: E402,F401,F403 # global var handling. for wildcard import of our module, we copy into globals, otherwise @@ -111,7 +107,11 @@ def __getattr__(name): g = _get_graphics() if g is None: return 0 - x, y = g.surface.position + # offscreen canvases (notebooks, embedding hosts) have no window position + try: + x, y = g.surface.position + except (AttributeError, RuntimeError): + return 0 return x if name == "window_x" else y raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -120,7 +120,9 @@ def __dir__(): return sorted(_builtins.set(list(globals().keys()) + list(_DYNAMIC))) __all__ = sorted( - {n for n in dir(_native) if not n.startswith("_")} | _builtins.set(_DYNAMIC) + {n for n in dir(_native) if not n.startswith("_")} + | {n for n in dir(math) if not n.startswith("_")} + | _builtins.set(_DYNAMIC) ) -del _sys, _name, _sub +del _sys, _importlib diff --git a/crates/processing_pyo3/mewnala/math.py b/crates/processing_pyo3/mewnala/math.py index c399e292..b910500a 100644 --- a/crates/processing_pyo3/mewnala/math.py +++ b/crates/processing_pyo3/mewnala/math.py @@ -1,6 +1,12 @@ """Processing math methods and vector/quaternion types.""" import math as _math from .mewnala import math as _native_math + +# extend native math so glob imports work +for _name in dir(_native_math): + if not _name.startswith("_"): + globals()[_name] = getattr(_native_math, _name) +del _name from math import ( sin, cos, tan, atan, atan2, diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 685f6f80..e841461b 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -47,7 +47,7 @@ pub(crate) const BATCH_INDEX_STEP: f32 = 0.001; pub struct BelongsToGraphics(pub Entity); #[derive(Component, Default)] -#[relationship_target(relationship = BelongsToGraphics)] +#[relationship_target(relationship = BelongsToGraphics, linked_spawn)] pub struct TransientMeshes(Vec); #[derive(SystemParam)] diff --git a/src/lib.rs b/src/lib.rs index 9f00d064..7620f21c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -157,13 +157,17 @@ fn setup_tracing(log_level: Option<&str>) -> error::Result<()> { #[cfg(not(target_arch = "wasm32"))] { use tracing_subscriber::EnvFilter; + use tracing_subscriber::util::SubscriberInitExt; let filter = EnvFilter::try_new(log_level.unwrap_or("info")) .unwrap_or_else(|_| EnvFilter::new("info")); let subscriber = tracing_subscriber::FmtSubscriber::builder() .with_env_filter(filter) .finish(); - tracing::subscriber::set_global_default(subscriber)?; + // `try_init` also installs the `log` bridge, so wgpu/naga reach this subscriber. + if subscriber.try_init().is_err() { + tracing::debug!("global tracing subscriber already set by host; keeping it"); + } } Ok(()) } From 1b8ad1d63055c8810c24479ac8372a54db8e9638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 25 Sep 2026 16:38:01 -0700 Subject: [PATCH 2/8] Add functionality for embedding mewnala in external apps (i.e. touch). --- Cargo.lock | 1 + crates/processing_pyo3/Cargo.toml | 3 +- crates/processing_pyo3/src/compute.rs | 10 + crates/processing_pyo3/src/cuda.rs | 5 + crates/processing_pyo3/src/gltf.rs | 5 + crates/processing_pyo3/src/graphics.rs | 37 +++ crates/processing_pyo3/src/host.rs | 192 +++++++++++++++ crates/processing_pyo3/src/lib.rs | 218 +++++++++++++++++- crates/processing_pyo3/src/material.rs | 5 + crates/processing_pyo3/src/monitor.rs | 5 + crates/processing_pyo3/src/particles.rs | 10 + crates/processing_pyo3/src/shader.rs | 8 + crates/processing_pyo3/src/surface.rs | 5 + crates/processing_pyo3/src/webcam.rs | 5 + crates/processing_render/src/lib.rs | 77 ++++++- crates/processing_render/src/particles/mod.rs | 19 ++ crates/processing_render/src/time.rs | 4 + 17 files changed, 588 insertions(+), 21 deletions(-) create mode 100644 crates/processing_pyo3/src/host.rs diff --git a/Cargo.lock b/Cargo.lock index c1bf6e52..c6b5e7ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6205,6 +6205,7 @@ dependencies = [ "bevy", "png", "processing", + "processing_core", "processing_cuda", "processing_glfw", "processing_render", diff --git a/crates/processing_pyo3/Cargo.toml b/crates/processing_pyo3/Cargo.toml index 2eb65a94..0b8ab77d 100644 --- a/crates/processing_pyo3/Cargo.toml +++ b/crates/processing_pyo3/Cargo.toml @@ -8,7 +8,7 @@ workspace = true [lib] name = "mewnala" -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [features] default = ["wayland", "static-link"] @@ -21,6 +21,7 @@ cuda = ["dep:processing_cuda", "processing_cuda/cuda", "processing/cuda"] [dependencies] pyo3 = { workspace = true, features = ["experimental-inspect", "multiple-pymethods"] } processing = { workspace = true } +processing_core = { workspace = true } processing_render = { workspace = true } processing_webcam = { workspace = true, optional = true } processing_glfw = { workspace = true } diff --git a/crates/processing_pyo3/src/compute.rs b/crates/processing_pyo3/src/compute.rs index e1f392b3..bac55d3b 100644 --- a/crates/processing_pyo3/src/compute.rs +++ b/crates/processing_pyo3/src/compute.rs @@ -63,6 +63,11 @@ impl Buffer { #[pymethods] impl Buffer { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + pub fn __len__(&self) -> usize { match &self.element_type { Some(et) => et @@ -325,6 +330,11 @@ pub(crate) fn set_compute_kwargs( #[pymethods] impl Compute { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + #[pyo3(signature = (**kwargs))] pub fn set(&self, kwargs: Option<&Bound<'_, pyo3::types::PyDict>>) -> PyResult<()> { match kwargs { diff --git a/crates/processing_pyo3/src/cuda.rs b/crates/processing_pyo3/src/cuda.rs index e8064c20..6716b297 100644 --- a/crates/processing_pyo3/src/cuda.rs +++ b/crates/processing_pyo3/src/cuda.rs @@ -17,6 +17,11 @@ impl CudaImage { #[pymethods] impl CudaImage { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + pub fn sync(&self) -> PyResult<()> { cuda_write_back(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } diff --git a/crates/processing_pyo3/src/gltf.rs b/crates/processing_pyo3/src/gltf.rs index 6fcc6fda..081c213d 100644 --- a/crates/processing_pyo3/src/gltf.rs +++ b/crates/processing_pyo3/src/gltf.rs @@ -18,6 +18,11 @@ impl Gltf { #[pymethods] impl Gltf { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + pub fn geometry(&self, name: &str) -> PyResult { let entity = gltf_geometry(self.entity, name) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index e801fad8..3a627e18 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -419,6 +419,11 @@ pub struct Light { #[pymethods] impl Light { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + #[pyo3(signature = (*args))] pub fn position(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { let v = extract_vec3(args)?; @@ -447,6 +452,11 @@ pub struct Font { #[pymethods] impl Font { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + /// Query variable font axes. /// /// Returns a list of `(tag, min, max, default)` tuples. @@ -551,6 +561,23 @@ impl<'a, 'py> FromPyObject<'a, 'py> for ImageRef { #[pymethods] impl Image { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + + /// Replace the whole image from raw pixel bytes in the image's format + /// (RGBA, 8 bits per channel for images from `create_image`), tightly + /// packed, top row first. Accepts anything with the buffer protocol: + /// `bytes`, `bytearray`, a C-contiguous `uint8` numpy array, ... This is + /// the fast path for streaming frames in; `pixels`/`update_pixels` go + /// through Python objects per pixel. + pub fn update_raw(&self, data: &Bound<'_, PyAny>) -> PyResult<()> { + let buffer = pyo3::buffer::PyBuffer::::get(data)?; + let bytes = buffer.to_vec(data.py())?; + image_update_raw(self.entity, &bytes).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + /// Applies a `Sampler` to this image, controlling filtering and wrapping. /// /// ```python @@ -715,6 +742,11 @@ impl Geometry { #[pymethods] impl Geometry { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + #[pyo3(signature = (*args))] pub fn color(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { let v = extract_vec4(args)?; @@ -853,6 +885,11 @@ impl Graphics { #[pymethods] impl Graphics { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + #[new] #[allow(clippy::too_many_arguments)] pub fn new( diff --git a/crates/processing_pyo3/src/host.rs b/crates/processing_pyo3/src/host.rs new file mode 100644 index 00000000..fb7071f7 --- /dev/null +++ b/crates/processing_pyo3/src/host.rs @@ -0,0 +1,192 @@ +//! Host API for applications that embed these bindings +//! and drive several sketches over the one app. +//! +//! Two rules make multiple host binaries and multiple sketches coexist: +//! +//! 1. All engine access goes through this module. The app lives in a +//! thread-local of whichever binary registered the lib in sys.modules. +//! A second binary that links its own copy of libprocessing must not call +//! it directly but call these functions on the registered module instead. +//! Entities cross as raw bits. +//! 2. Each sketch has a [`SketchContext`]. The canvas graphics, extra +//! windows, `loop()`/`no_loop()` state, the tracked-globals cache and the +//! frame counter are otherwise process-global. The host enters a sketch's +//! context before touching it and exits after. + +use std::collections::HashMap; + +use bevy::prelude::Entity; +use processing::prelude::*; +use processing_render::geometry::AttributeFormat; +use pyo3::buffer::PyBuffer; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyBytes; + +use crate::{LAST_GLOBALS, LOOP_STATE, LoopState}; + +fn err(e: impl std::fmt::Display) -> PyErr { + PyRuntimeError::new_err(format!("{e}")) +} + +fn bytes_of(data: &Bound<'_, PyAny>) -> PyResult> { + let buffer = PyBuffer::::get(data)?; + buffer.to_vec(data.py()) +} + +/// Per-sketch state swapped in and out by `_context_enter` / `_context_exit`. +#[pyclass(unsendable)] +pub struct SketchContext { + graphics: Option>, + windows: Option>, + loop_state: LoopState, + last_globals: HashMap<&'static str, Py>, + frame_count: u32, + entered: bool, +} + +pub(crate) fn context_new() -> SketchContext { + SketchContext { + graphics: None, + windows: None, + loop_state: LoopState::default(), + last_globals: HashMap::new(), + frame_count: 0, + entered: false, + } +} + +fn take_attr(module: &Bound<'_, PyModule>, name: &str) -> PyResult>> { + let value = match module.getattr(name) { + Ok(v) if !v.is_none() => Some(v.unbind()), + _ => None, + }; + module.setattr(name, module.py().None())?; + Ok(value) +} + +pub(crate) fn context_enter(module: &Bound<'_, PyModule>, ctx: &mut SketchContext) -> PyResult<()> { + if ctx.entered { + return Err(PyRuntimeError::new_err("sketch context entered twice")); + } + let py = module.py(); + module.setattr( + "_graphics", + ctx.graphics.take().unwrap_or_else(|| py.None()), + )?; + module.setattr("_windows", ctx.windows.take().unwrap_or_else(|| py.None()))?; + LOOP_STATE.with(|s| s.set(ctx.loop_state)); + LAST_GLOBALS.with(|c| *c.borrow_mut() = std::mem::take(&mut ctx.last_globals)); + // The app may not exist yet (first sketch, before `size()`). + let _ = set_frame_count(ctx.frame_count); + ctx.entered = true; + Ok(()) +} + +pub(crate) fn context_exit(module: &Bound<'_, PyModule>, ctx: &mut SketchContext) -> PyResult<()> { + if !ctx.entered { + return Ok(()); + } + ctx.graphics = take_attr(module, "_graphics")?; + ctx.windows = take_attr(module, "_windows")?; + ctx.loop_state = LOOP_STATE.with(|s| s.replace(LoopState::default())); + ctx.last_globals = LAST_GLOBALS.with(|c| std::mem::take(&mut *c.borrow_mut())); + ctx.frame_count = frame_count().unwrap_or(ctx.frame_count); + ctx.entered = false; + Ok(()) +} + +pub(crate) fn readback<'py>( + py: Python<'py>, + graphics: u64, +) -> PyResult<(Bound<'py, PyBytes>, u32, u32, &'static str)> { + let frame = graphics_readback_raw(Entity::from_bits(graphics)).map_err(err)?; + let format = match frame.format { + TextureFormat::Rgba8UnormSrgb => "rgba8_srgb", + TextureFormat::Rgba8Unorm => "rgba8", + TextureFormat::Bgra8UnormSrgb => "bgra8_srgb", + TextureFormat::Bgra8Unorm => "bgra8", + TextureFormat::Rgba16Float => "rgba16f", + TextureFormat::Rgba32Float => "rgba32f", + other => { + return Err(PyValueError::new_err(format!( + "unsupported canvas format {other:?}" + ))); + } + }; + Ok(( + PyBytes::new(py, &frame.bytes), + frame.width, + frame.height, + format, + )) +} + +pub(crate) fn mouse_move(surface: u64, x: f32, y: f32) -> PyResult<()> { + input_set_mouse_move(Entity::from_bits(surface), x, y).map_err(err) +} + +pub(crate) fn mouse_button(surface: u64, button: u8, pressed: bool) -> PyResult<()> { + let button = match button { + 0 => MouseButton::Left, + 1 => MouseButton::Middle, + 2 => MouseButton::Right, + other => { + return Err(PyValueError::new_err(format!( + "unknown mouse button {other}" + ))); + } + }; + input_set_mouse_button(Entity::from_bits(surface), button, pressed).map_err(err) +} + +pub(crate) fn scroll(surface: u64, x: f32, y: f32) -> PyResult<()> { + input_set_scroll(Entity::from_bits(surface), x, y).map_err(err) +} + +pub(crate) fn flush_input() -> PyResult<()> { + input_flush().map_err(err) +} + +pub(crate) fn image_update(image: u64, data: &Bound<'_, PyAny>) -> PyResult<()> { + image_update_raw(Entity::from_bits(image), &bytes_of(data)?).map_err(err) +} + +pub(crate) fn capacity(particles: u64) -> PyResult { + particles_capacity(Entity::from_bits(particles)).map_err(err) +} + +pub(crate) fn attributes(particles: u64) -> PyResult> { + Ok(particles_attributes(Entity::from_bits(particles)) + .map_err(err)? + .into_iter() + .map(|(name, format, buffer)| (name, format.components() as u32, buffer.to_bits())) + .collect()) +} + +pub(crate) fn attribute_buffer(particles: u64, name: String, components: u32) -> PyResult { + let format = match components { + 1 => AttributeFormat::Float, + 2 => AttributeFormat::Float2, + 3 => AttributeFormat::Float3, + 4 => AttributeFormat::Float4, + other => { + return Err(PyValueError::new_err(format!( + "attributes have 1..4 components, got {other}" + ))); + } + }; + let attribute = geometry_attribute_create(name, format).map_err(err)?; + let buffer = + particles_ensure_attribute(Entity::from_bits(particles), attribute).map_err(err)?; + Ok(buffer.to_bits()) +} + +pub(crate) fn read_buffer<'py>(py: Python<'py>, buffer: u64) -> PyResult> { + let bytes = buffer_read(Entity::from_bits(buffer)).map_err(err)?; + Ok(PyBytes::new(py, &bytes)) +} + +pub(crate) fn write_buffer(buffer: u64, data: &Bound<'_, PyAny>) -> PyResult<()> { + buffer_write(Entity::from_bits(buffer), bytes_of(data)?).map_err(err) +} diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index f8378bbe..a21a2f59 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -17,6 +17,7 @@ pub(crate) mod filter; mod glfw; mod gltf; mod graphics; +mod host; mod input; pub(crate) mod material; pub(crate) mod math; @@ -113,7 +114,7 @@ use std::collections::HashMap; use std::env; #[derive(Clone, Copy)] -struct LoopState { +pub(crate) struct LoopState { looping: bool, redraw_requested: bool, } @@ -128,8 +129,8 @@ impl Default for LoopState { } thread_local! { - static LAST_GLOBALS: RefCell>> = RefCell::new(HashMap::new()); - static LOOP_STATE: Cell = Cell::new(LoopState::default()); + pub(crate) static LAST_GLOBALS: RefCell>> = RefCell::new(HashMap::new()); + pub(crate) static LOOP_STATE: Cell = Cell::new(LoopState::default()); } fn update_loop_state(f: impl FnOnce(&mut LoopState)) { @@ -185,8 +186,10 @@ fn sync_globals(module: &Bound<'_, PyModule>, globals: &Bound<'_, PyAny>) -> PyR get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; let width = ::processing::prelude::surface_width(graphics.surface.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let width = if width == 0 { graphics.width } else { width }; let height = ::processing::prelude::surface_height(graphics.surface.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let height = if height == 0 { graphics.height } else { height }; input::sync_globals(globals, graphics.surface.entity, width, height)?; surface::sync_globals(globals, &graphics.surface, width, height)?; time::sync_globals(globals)?; @@ -240,7 +243,7 @@ fn create_graphics_context( transparent: bool, ) -> PyResult<()> { let py = module.py(); - let env = detect_environment(py)?; + let env = detect_environment(module)?; let interactive = env != "script"; let log_level = if interactive { Some("error") } else { None }; @@ -255,6 +258,18 @@ fn create_graphics_context( } match env.as_str() { + "embedded" => { + let asset_path = match embedded_asset_root(module)? { + Some(root) => root, + None => get_asset_root()?, + }; + let hdr = embedded_hdr(module)?; + let host_log_level = embedded_log_level(module)?; + let log_level = host_log_level.as_deref().or(log_level); + let graphics = + Graphics::new_offscreen(width, height, asset_path.as_str(), log_level, hdr)?; + module.setattr("_graphics", graphics)?; + } "jupyter" => { let asset_path = get_asset_root()?; let graphics = @@ -385,7 +400,34 @@ const REGISTER_INPUTHOOK_CODE: &str = include_str!("python/register_inputhook.py const IPYTHON_POST_EXECUTE_CODE: &str = include_str!("python/ipython_post_execute.py"); const JUPYTER_POST_EXECUTE_CODE: &str = include_str!("python/jupyter_post_execute.py"); -fn detect_environment(py: Python<'_>) -> PyResult { +fn embedded_asset_root(module: &Bound<'_, PyModule>) -> PyResult> { + match module.getattr("_asset_root") { + Ok(attr) if !attr.is_none() => Ok(Some(attr.extract::()?)), + _ => Ok(None), + } +} + +fn embedded_log_level(module: &Bound<'_, PyModule>) -> PyResult> { + match module.getattr("_embedded_log_level") { + Ok(attr) if !attr.is_none() => Ok(Some(attr.extract::()?)), + _ => Ok(None), + } +} + +fn embedded_hdr(module: &Bound<'_, PyModule>) -> PyResult { + match module.getattr("_embedded_hdr") { + Ok(attr) if !attr.is_none() => attr.is_truthy(), + _ => Ok(false), + } +} + +fn detect_environment(module: &Bound<'_, PyModule>) -> PyResult { + let py = module.py(); + if let Ok(flag) = module.getattr("_embedded") + && flag.is_truthy()? + { + return Ok("embedded".to_string()); + } let locals = PyDict::new(py); let code = CString::new(DETECT_ENV_CODE)?; py.run(code.as_c_str(), None, Some(&locals))?; @@ -396,7 +438,7 @@ fn detect_environment(py: Python<'_>) -> PyResult { } #[pymodule] -mod mewnala { +pub mod mewnala { use super::*; #[pymodule_export] @@ -652,6 +694,167 @@ mod mewnala { Ok(()) } + /// Register an embedding host. From now on `size()` allocates an offscreen + /// canvas instead of opening a window, `run()` is a no-op, and the host is + /// expected to drive frames itself with `_tick()` + `_host_frame()` and to + /// read the canvas back. + #[pyfunction] + #[pyo3(pass_module, signature = (asset_root=None, *, hdr=false, log_level=None))] + fn _embed( + module: &Bound<'_, PyModule>, + asset_root: Option<&str>, + hdr: bool, + log_level: Option<&str>, + ) -> PyResult<()> { + let py = module.py(); + module.setattr("_embedded", true)?; + match asset_root { + Some(root) => module.setattr("_asset_root", root)?, + None => module.setattr("_asset_root", py.None())?, + } + module.setattr("_embedded_hdr", hdr)?; + match log_level { + Some(level) => module.setattr("_embedded_log_level", level)?, + None => module.setattr("_embedded_log_level", py.None())?, + } + Ok(()) + } + + #[pymodule_export] + use super::host::SketchContext; + + #[pyfunction] + fn _context_new() -> host::SketchContext { + host::context_new() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn _context_enter( + module: &Bound<'_, PyModule>, + ctx: &Bound<'_, host::SketchContext>, + ) -> PyResult<()> { + host::context_enter(module, &mut ctx.borrow_mut()) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn _context_exit( + module: &Bound<'_, PyModule>, + ctx: &Bound<'_, host::SketchContext>, + ) -> PyResult<()> { + host::context_exit(module, &mut ctx.borrow_mut()) + } + + /// Read a canvas back: `(bytes, width, height, format)`, rows top-first. + #[pyfunction] + fn _host_readback( + py: Python<'_>, + graphics: u64, + ) -> PyResult<(Bound<'_, pyo3::types::PyBytes>, u32, u32, &'static str)> { + host::readback(py, graphics) + } + + #[pyfunction] + fn _host_mouse_move(surface: u64, x: f32, y: f32) -> PyResult<()> { + host::mouse_move(surface, x, y) + } + + /// `button`: 0 left, 1 middle, 2 right. + #[pyfunction] + fn _host_mouse_button(surface: u64, button: u8, pressed: bool) -> PyResult<()> { + host::mouse_button(surface, button, pressed) + } + + #[pyfunction] + fn _host_scroll(surface: u64, x: f32, y: f32) -> PyResult<()> { + host::scroll(surface, x, y) + } + + #[pyfunction] + fn _host_input_flush() -> PyResult<()> { + host::flush_input() + } + + #[pyfunction] + fn _host_image_update(image: u64, data: &Bound<'_, PyAny>) -> PyResult<()> { + host::image_update(image, data) + } + + #[pyfunction] + fn _host_particles_capacity(particles: u64) -> PyResult { + host::capacity(particles) + } + + /// `[(name, components, buffer_bits)]` for every attribute buffer. + #[pyfunction] + fn _host_particles_attributes(particles: u64) -> PyResult> { + host::attributes(particles) + } + + /// The buffer for attribute `name` (created if missing), as raw bits. + #[pyfunction] + fn _host_particles_buffer(particles: u64, name: String, components: u32) -> PyResult { + host::attribute_buffer(particles, name, components) + } + + #[pyfunction] + fn _host_buffer_read(py: Python<'_>, buffer: u64) -> PyResult> { + host::read_buffer(py, buffer) + } + + #[pyfunction] + fn _host_buffer_write(buffer: u64, data: &Bound<'_, PyAny>) -> PyResult<()> { + host::write_buffer(buffer, data) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn _ensure_graphics(module: &Bound<'_, PyModule>) -> PyResult<()> { + ensure_graphics(module) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (ns, force=false))] + fn _host_frame( + module: &Bound<'_, PyModule>, + ns: &Bound<'_, PyAny>, + force: bool, + ) -> PyResult { + let py = module.py(); + ensure_graphics(module)?; + + let should_draw = force + || LOOP_STATE.with(|s| { + let state = s.get(); + state.looping || state.redraw_requested + }); + if !should_draw { + return Ok(false); + } + + processing::prelude::advance_frame_count() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + + let windows = collect_windows(module)?; + for wg in &windows { + wg.bind(py).borrow().begin_draw()?; + } + sync_globals(module, ns)?; + let draw_result = match ns.get_item("draw") { + Ok(draw) if draw.is_callable() => draw.call0().map(|_| ()), + _ => Ok(()), + }; + // Finish the frame even when draw() raised + for wg in &windows { + wg.bind(py).borrow().end_draw()?; + } + draw_result?; + + update_loop_state(|s| s.redraw_requested = false); + Ok(true) + } + #[pyfunction] fn redraw() -> PyResult<()> { update_loop_state(|s| { @@ -835,8 +1038,7 @@ mod mewnala { #[pyfunction] #[pyo3(pass_module)] fn run(module: &Bound<'_, PyModule>) -> PyResult<()> { - let py = module.py(); - let env = detect_environment(py)?; + let env = detect_environment(module)?; if env != "script" { warn!("run() was called, but we're in an interactive environment ({env})."); diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index 8e033674..e0d4c8b1 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -114,6 +114,11 @@ fn apply_kwargs(entity: Entity, kwargs: &Bound<'_, PyDict>) -> PyResult<()> { #[pymethods] impl Material { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + #[new] #[pyo3(signature = (shader=None, **kwargs))] pub fn new(shader: Option<&Shader>, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { diff --git a/crates/processing_pyo3/src/monitor.rs b/crates/processing_pyo3/src/monitor.rs index e30686e7..c4826434 100644 --- a/crates/processing_pyo3/src/monitor.rs +++ b/crates/processing_pyo3/src/monitor.rs @@ -9,6 +9,11 @@ pub struct Monitor { #[pymethods] impl Monitor { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + #[getter] pub fn width(&self) -> PyResult { monitor_width(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) diff --git a/crates/processing_pyo3/src/particles.rs b/crates/processing_pyo3/src/particles.rs index 5b706f10..128b32e6 100644 --- a/crates/processing_pyo3/src/particles.rs +++ b/crates/processing_pyo3/src/particles.rs @@ -319,6 +319,11 @@ pub struct Attribute { #[pymethods] impl Attribute { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + #[new] pub fn new(name: &str, format: AttributeFormat) -> PyResult { let entity = geometry_attribute_create(name, format.to_inner()) @@ -718,6 +723,11 @@ impl Particles { #[pymethods] impl Particles { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + #[getter] pub fn capacity(&self) -> PyResult { particles_capacity(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) diff --git a/crates/processing_pyo3/src/shader.rs b/crates/processing_pyo3/src/shader.rs index 42bef234..60817010 100644 --- a/crates/processing_pyo3/src/shader.rs +++ b/crates/processing_pyo3/src/shader.rs @@ -19,6 +19,14 @@ impl Shader { } } +#[pymethods] +impl Shader { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } +} + impl Drop for Shader { fn drop(&mut self) { let _ = shader_destroy(self.entity); diff --git a/crates/processing_pyo3/src/surface.rs b/crates/processing_pyo3/src/surface.rs index 906ccda1..6aa70901 100644 --- a/crates/processing_pyo3/src/surface.rs +++ b/crates/processing_pyo3/src/surface.rs @@ -36,6 +36,11 @@ impl Surface { #[pymethods] impl Surface { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + pub fn poll_events(&mut self) -> bool { match &mut self.glfw_ctx { Some(ctx) => ctx.poll_events(), diff --git a/crates/processing_pyo3/src/webcam.rs b/crates/processing_pyo3/src/webcam.rs index 3cc06ba7..4d35f890 100644 --- a/crates/processing_pyo3/src/webcam.rs +++ b/crates/processing_pyo3/src/webcam.rs @@ -14,6 +14,11 @@ pub struct Webcam { #[pymethods] impl Webcam { + /// Opaque id for this object. + pub fn id(&self) -> u64 { + self.entity.to_bits() + } + #[new] #[pyo3(signature = (width=None, height=None, framerate=None))] pub fn new(width: Option, height: Option, framerate: Option) -> PyResult { diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 6359c86f..1113411e 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -34,18 +34,18 @@ pub use particles::{ BOUNDS_CLAMP, BOUNDS_REFLECT, BOUNDS_SOFT, BOUNDS_WRAP, COMBINE_ADD, COMBINE_DIV, COMBINE_MAX, COMBINE_MIN, COMBINE_MUL, COMBINE_POW, COMBINE_SUB, FALLOFF_CONST, FALLOFF_CUBIC, FALLOFF_INVERSE, FALLOFF_LINEAR, FALLOFF_QUADRATIC, FALLOFF_SMOOTHSTEP, particles_apply, - particles_attribute_add, particles_buffer, particles_capacity, particles_connectivity_indirect, - particles_create, particles_create_from_geometry, particles_destroy, particles_emit, - particles_emit_gpu, particles_ensure_attribute, particles_flock, particles_gather, - particles_kernel_age, particles_kernel_attr_combine, particles_kernel_attr_linear, - particles_kernel_attr_lookup1d, particles_kernel_attr_lookup2d, particles_kernel_attr_mix, - particles_kernel_attract, particles_kernel_bounds_box, particles_kernel_bounds_geometry, - particles_kernel_bounds_sphere, particles_kernel_drag, particles_kernel_field, - particles_kernel_flock, particles_kernel_force, particles_kernel_impulse, - particles_kernel_integrate, particles_kernel_noise, particles_kernel_orient, - particles_kernel_transform, particles_kernel_vortex, particles_reset_indices, - particles_scatter_create, particles_scatter_volume_create, particles_set_connectivity, - prefix_sum_u32, + particles_attribute_add, particles_attributes, particles_buffer, particles_capacity, + particles_connectivity_indirect, particles_create, particles_create_from_geometry, + particles_destroy, particles_emit, particles_emit_gpu, particles_ensure_attribute, + particles_flock, particles_gather, particles_kernel_age, particles_kernel_attr_combine, + particles_kernel_attr_linear, particles_kernel_attr_lookup1d, particles_kernel_attr_lookup2d, + particles_kernel_attr_mix, particles_kernel_attract, particles_kernel_bounds_box, + particles_kernel_bounds_geometry, particles_kernel_bounds_sphere, particles_kernel_drag, + particles_kernel_field, particles_kernel_flock, particles_kernel_force, + particles_kernel_impulse, particles_kernel_integrate, particles_kernel_noise, + particles_kernel_orient, particles_kernel_transform, particles_kernel_vortex, + particles_reset_indices, particles_scatter_create, particles_scatter_volume_create, + particles_set_connectivity, prefix_sum_u32, }; use std::path::PathBuf; @@ -1247,6 +1247,50 @@ pub fn image_update(entity: Entity, pixels: &[LinearRgba]) -> error::Result<()> }) } +/// Replace an image's contents from raw bytes already laid out in its texture +/// format. +pub fn image_update_raw(entity: Entity, data: &[u8]) -> error::Result<()> { + app_mut(|app| { + if gpu_image(app, entity).is_err() && app.world().get::(entity).is_some() { + app.update(); + } + let texture = gpu_image(app, entity)?.texture.clone(); + let world = app.world_mut(); + let (size, texture_format) = { + let p_image = world + .get::(entity) + .ok_or(error::ProcessingError::ImageNotFound)?; + (p_image.size, p_image.texture_format) + }; + let px_size = image::pixel_size(texture_format)? as u32; + let expected = (size.width * size.height * px_size) as usize; + if data.len() != expected { + return Err(error::ProcessingError::InvalidArgument(format!( + "expected {expected} bytes for a {}x{} {:?} image, got {}", + size.width, + size.height, + texture_format, + data.len() + ))); + } + world + .run_system_cached_with( + image::update_region_write, + ( + entity, + texture, + 0, + 0, + size.width, + size.height, + data.to_vec(), + px_size, + ), + ) + .unwrap() + }) +} + /// Update a region of an existing image with new pixel data. pub fn image_update_region( entity: Entity, @@ -2149,6 +2193,15 @@ pub fn frame_count() -> error::Result { }) } +pub fn set_frame_count(n: u32) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(time::set_frame_count, n) + .unwrap(); + Ok(()) + }) +} + pub fn advance_frame_count() -> error::Result<()> { app_mut(|app| { app.world_mut() diff --git a/crates/processing_render/src/particles/mod.rs b/crates/processing_render/src/particles/mod.rs index 38034d73..c415c9b4 100644 --- a/crates/processing_render/src/particles/mod.rs +++ b/crates/processing_render/src/particles/mod.rs @@ -525,6 +525,25 @@ pub fn particles_destroy(entity: Entity) -> error::Result<()> { }) } +pub fn particles_attributes( + entity: Entity, +) -> error::Result> { + app_mut(|app| { + let world = app.world(); + let particles = world + .get::(entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let mut out = Vec::with_capacity(particles.buffers.len()); + for (&attribute_entity, &buffer_entity) in &particles.buffers { + if let Some(attribute) = world.get::(attribute_entity) { + out.push((attribute.name.to_string(), attribute.format, buffer_entity)); + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(out) + }) +} + pub fn particles_capacity(entity: Entity) -> error::Result { app_mut(|app| { Ok(app diff --git a/crates/processing_render/src/time.rs b/crates/processing_render/src/time.rs index 180533a4..4c7d156e 100644 --- a/crates/processing_render/src/time.rs +++ b/crates/processing_render/src/time.rs @@ -8,6 +8,10 @@ pub fn frame_count(frame: Option>) -> u32 { frame.map(|f| f.0).unwrap_or(0) } +pub fn set_frame_count(In(n): In, mut frame: ResMut) { + frame.0 = n; +} + pub fn advance_frame_count(mut frame: ResMut) { frame.0 = frame.0.wrapping_add(1); } From d011f39e22fdb99a7d6fe6965500135414a1210f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 25 Sep 2026 18:05:08 -0700 Subject: [PATCH 3/8] Create visual testing ci. --- .github/actions/install-mesa/action.yml | 39 +++ .github/workflows/visual-comment.yml | 65 ++++ .github/workflows/visual.yml | 152 +++++++++ Cargo.lock | 1 + crates/processing_glfw/src/lib.rs | 2 +- crates/processing_pyo3/src/lib.rs | 3 + crates/processing_pyo3/src/math.rs | 21 +- crates/processing_render/Cargo.toml | 1 + crates/processing_render/src/ci.rs | 178 +++++++++++ crates/processing_render/src/graphics.rs | 3 +- crates/processing_render/src/lib.rs | 4 +- examples/particles_noise.rs | 11 +- examples/primitives_3d.rs | 4 +- examples/rectangle.rs | 4 +- justfile | 6 + tests/visual/cases.toml | 178 +++++++++++ tests/visual/visual.py | 385 +++++++++++++++++++++++ 17 files changed, 1040 insertions(+), 17 deletions(-) create mode 100644 .github/actions/install-mesa/action.yml create mode 100644 .github/workflows/visual-comment.yml create mode 100644 .github/workflows/visual.yml create mode 100644 crates/processing_render/src/ci.rs create mode 100644 tests/visual/cases.toml create mode 100644 tests/visual/visual.py diff --git a/.github/actions/install-mesa/action.yml b/.github/actions/install-mesa/action.yml new file mode 100644 index 00000000..6fec957b --- /dev/null +++ b/.github/actions/install-mesa/action.yml @@ -0,0 +1,39 @@ +name: 'Install Mesa' +description: 'Install a pinned Mesa build and select lavapipe as the only Vulkan driver' +inputs: + # Prebuilt by wgpu's CI (https://github.com/gfx-rs/ci-build/releases). Pinned so screenshots + # don't drift with the runner image's Mesa. + version: + default: '26.1.3' + ci-binary-build: + default: 'build29' +runs: + using: 'composite' + steps: + - name: Install Mesa + shell: bash + env: + MESA_VERSION: ${{ inputs.version }} + CI_BINARY_BUILD: ${{ inputs.ci-binary-build }} + run: | + set -e + dir="$RUNNER_TEMP/mesa" + mkdir -p "$dir" + curl -L --retry 5 "https://github.com/gfx-rs/ci-build/releases/download/$CI_BINARY_BUILD/mesa-$MESA_VERSION-linux-x86_64.tar.xz" -o "$RUNNER_TEMP/mesa.tar.xz" + tar xpf "$RUNNER_TEMP/mesa.tar.xz" -C "$dir" + + # The ICD shipped in the tarball points at the build machine's paths. + cat <<- EOF > "$dir/icd.json" + { + "ICD": { + "api_version": "1.4.348", + "library_arch": "64", + "library_path": "$dir/lib/x86_64-linux-gnu/libvulkan_lvp.so" + }, + "file_format_version": "1.0.1" + } + EOF + + echo "VK_DRIVER_FILES=$dir/icd.json" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$dir/lib/x86_64-linux-gnu/:$LD_LIBRARY_PATH" >> "$GITHUB_ENV" + echo "LIBGL_DRIVERS_PATH=$dir/lib/x86_64-linux-gnu/dri" >> "$GITHUB_ENV" diff --git a/.github/workflows/visual-comment.yml b/.github/workflows/visual-comment.yml new file mode 100644 index 00000000..b8503469 --- /dev/null +++ b/.github/workflows/visual-comment.yml @@ -0,0 +1,65 @@ +name: Visual comment + +# Posts visual.yml's PR summary as a sticky comment. Runs from the base repository so it can +# write to PRs from forks; it never checks out PR code and treats the artifact as untrusted. + +on: + workflow_run: + workflows: [ Visual ] + types: [ completed ] + +permissions: {} + +jobs: + comment: + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion != 'cancelled' && + github.event.workflow_run.conclusion != 'skipped' + runs-on: ubuntu-latest + permissions: + actions: read + pull-requests: write + steps: + - uses: actions/download-artifact@v8 + id: download + continue-on-error: true + with: + name: visual-comment + path: comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Validate + id: pr + if: steps.download.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + pr=$(cat comment/pr) + notable=$(cat comment/notable) + [[ "$pr" =~ ^[0-9]+$ ]] || { echo "invalid PR number"; exit 1; } + [[ "$notable" == true || "$notable" == false ]] || { echo "invalid notable flag"; exit 1; } + pr_head=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" --jq .head.sha) + [[ "$pr_head" == "$HEAD_SHA" ]] || { echo "PR #$pr is not at $HEAD_SHA; skipping"; exit 0; } + echo "number=$pr" >> "$GITHUB_OUTPUT" + echo "notable=$notable" >> "$GITHUB_OUTPUT" + + - name: Post comment + if: steps.pr.outputs.notable == 'true' + uses: marocchino/sticky-pull-request-comment@v3.0.5 + with: + header: visual-regression + number_force: ${{ steps.pr.outputs.number }} + path: comment/comment.md + + # A clean run only refreshes an earlier comment, so passing PRs stay quiet. + - name: Update comment + if: steps.pr.outputs.notable == 'false' + uses: marocchino/sticky-pull-request-comment@v3.0.5 + with: + header: visual-regression + number_force: ${{ steps.pr.outputs.number }} + path: comment/comment.md + only_update: true diff --git a/.github/workflows/visual.yml b/.github/workflows/visual.yml new file mode 100644 index 00000000..978740d3 --- /dev/null +++ b/.github/workflows/visual.yml @@ -0,0 +1,152 @@ +name: Visual + +# Renders tests/visual/cases.toml under lavapipe + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + types: [ opened, synchronize, reopened, labeled, unlabeled ] + +permissions: + contents: read + actions: read + +concurrency: + group: visual-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + CARGO_PROFILE_RELEASE_DEBUG: 0 + WGPU_BACKEND: vulkan + WGPU_ADAPTER_NAME: llvmpipe + # Catch reads of uninitialized GPU memory instead of rendering whatever was there + LVP_POISON_MEMORY: "true" + # llvmpipe JITs for the host CPU + LP_NATIVE_VECTOR_WIDTH: "256" + ODIFF_VERSION: 4.5.0 + +jobs: + visual: + name: Visual regression + # Only re-run on label changes that involve the override label. + if: >- + github.event_name == 'push' || + (github.event.action != 'labeled' && github.event.action != 'unlabeled') || + github.event.label.name == 'deliberate-rendering-change' + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + # PR checkouts are merge commits; HEAD^1 is the main commit they're compared against. + fetch-depth: 2 + persist-credentials: false + + - uses: ./.github/actions/setup + + - uses: ./.github/actions/install-mesa + + - uses: astral-sh/setup-uv@v6 + + - name: Install xvfb and odiff + run: | + sudo apt-get install -y --no-install-recommends xvfb + npm install -g "odiff-bin@$ODIFF_VERSION" + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-visual-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-visual- + ${{ runner.os }}-cargo- + + - name: Render + run: xvfb-run -a -s "-screen 0 1920x1080x24" python3 tests/visual/visual.py render --out out/actual + + - uses: actions/upload-artifact@v7 + with: + name: visual-screenshots + path: out/actual + retention-days: ${{ github.event_name == 'push' && 90 || 14 }} + + - name: Fetch baseline from main + id: baseline + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha=$(git rev-parse HEAD^1) + echo "sha=$sha" >> "$GITHUB_OUTPUT" + run_id=$(gh run list -R "$GITHUB_REPOSITORY" --workflow visual.yml --branch main --event push \ + --commit "$sha" --status success --limit 1 --json databaseId --jq '.[0].databaseId // empty') + if [ -n "$run_id" ] && gh run download "$run_id" -R "$GITHUB_REPOSITORY" -n visual-screenshots -D out/baseline; then + echo "Using screenshots from run $run_id" + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "No screenshots published for $sha; rendering it here" + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Render baseline + if: steps.baseline.outputs.found == 'false' + # A base that predates the harness can't render; its cases are then reported as new. + continue-on-error: true + env: + BASE_SHA: ${{ steps.baseline.outputs.sha }} + CARGO_TARGET_DIR: ${{ github.workspace }}/target + run: | + git worktree add --detach "$RUNNER_TEMP/base" "$BASE_SHA" + git -C "$RUNNER_TEMP/base" submodule update --init --depth 1 + xvfb-run -a -s "-screen 0 1920x1080x24" \ + python3 tests/visual/visual.py render --root "$RUNNER_TEMP/base" --out out/baseline + + - name: Compare + id: compare + if: github.event_name == 'pull_request' + env: + ALLOW_CHANGES: ${{ contains(github.event.pull_request.labels.*.name, 'deliberate-rendering-change') && '--allow-changes' || '' }} + run: | + mkdir -p out/baseline + python3 tests/visual/visual.py compare --baseline out/baseline --actual out/actual --out out/report $ALLOW_CHANGES + + - uses: actions/upload-artifact@v7 + id: report + if: always() && hashFiles('out/report/report.html') != '' + with: + path: out/report/report.html + archive: false + retention-days: 14 + + - name: Summarize + if: always() && hashFiles('out/report/summary.md') != '' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + REPORT_URL: ${{ steps.report.outputs.artifact-url }} + run: | + mkdir -p out/comment + { + cat out/report/summary.md + echo + echo "[Open the visual report]($REPORT_URL) · [workflow run]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)" + } > out/comment/comment.md + cat out/comment/comment.md >> "$GITHUB_STEP_SUMMARY" + echo "$PR_NUMBER" > out/comment/pr + jq '[.results[] | select(.status != "pass")] | length > 0' out/report/results.json > out/comment/notable + + - uses: actions/upload-artifact@v7 + if: always() && hashFiles('out/comment/comment.md') != '' + with: + name: visual-comment + path: out/comment + retention-days: 1 diff --git a/Cargo.lock b/Cargo.lock index c6b5e7ff..350f0e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6231,6 +6231,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "parley 0.7.0", + "png", "processing_core", "raw-window-handle", "skrifa 0.37.0", diff --git a/crates/processing_glfw/src/lib.rs b/crates/processing_glfw/src/lib.rs index 9bf6a2f2..6e296cd9 100644 --- a/crates/processing_glfw/src/lib.rs +++ b/crates/processing_glfw/src/lib.rs @@ -319,7 +319,7 @@ impl GlfwContext { if input_flush().is_err() { return false; } - main_open + main_open && !processing_render::ci::done() } /// Content scale (DPI) of the main window. diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index a21a2f59..32eb77c0 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -493,6 +493,9 @@ pub mod mewnala { #[pymodule_init] fn init(module: &Bound<'_, PyModule>) -> PyResult<()> { + if processing_render::ci::enabled() { + PyModule::import(module.py(), "random")?.call_method1("seed", (0,))?; + } super::constants::register(module) } diff --git a/crates/processing_pyo3/src/math.rs b/crates/processing_pyo3/src/math.rs index 81bc61cf..8f86c519 100644 --- a/crates/processing_pyo3/src/math.rs +++ b/crates/processing_pyo3/src/math.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::hash::{Hash, Hasher}; use bevy::math::{Affine2, EulerRot, Mat2, Quat, Vec2, Vec3, Vec4}; @@ -6,6 +7,20 @@ use pyo3::{ prelude::*, types::PyTuple, }; +use rand::{SeedableRng, rngs::StdRng}; +use rand_distr::Distribution; + +thread_local! { + static CI_RNG: RefCell> = + RefCell::new(processing_render::ci::enabled().then(|| StdRng::seed_from_u64(0))); +} + +fn sample(dist: impl Distribution) -> T { + CI_RNG.with_borrow_mut(|rng| match rng { + Some(rng) => dist.sample(rng), + None => dist.sample(&mut rand::rng()), + }) +} pub fn hash_f32(val: f32, state: &mut impl Hasher) { if val == 0.0 { @@ -537,8 +552,7 @@ impl_py_vec!(PyVec2, "Vec2", 2, [(x, set_x, 0), (y, set_y, 1)], Vec2, extra { #[staticmethod] fn random() -> Self { - use rand_distr::{Distribution, UnitCircle}; - let [x, y]: [f32; 2] = UnitCircle.sample(&mut rand::rng()); + let [x, y]: [f32; 2] = sample(rand_distr::UnitCircle); Self(Vec2::new(x, y)) } @@ -560,8 +574,7 @@ impl_py_vec!(PyVec3, "Vec3", 3, [(x, set_x, 0), (y, set_y, 1), (z, set_z, 2)], V #[staticmethod] fn random() -> Self { - use rand_distr::{Distribution, UnitSphere}; - let [x, y, z]: [f32; 3] = UnitSphere.sample(&mut rand::rng()); + let [x, y, z]: [f32; 3] = sample(rand_distr::UnitSphere); Self(Vec3::new(x, y, z)) } diff --git a/crates/processing_render/Cargo.toml b/crates/processing_render/Cargo.toml index 146a9bbe..6dddeabe 100644 --- a/crates/processing_render/Cargo.toml +++ b/crates/processing_render/Cargo.toml @@ -27,6 +27,7 @@ notosans = "0.1" raw-window-handle = "0.6" half = "2.7" crossbeam-channel = "0.5" +png = "0.18" processing_core = { workspace = true } [build-dependencies] diff --git a/crates/processing_render/src/ci.rs b/crates/processing_render/src/ci.rs new file mode 100644 index 00000000..c929c39e --- /dev/null +++ b/crates/processing_render/src/ci.rs @@ -0,0 +1,178 @@ +//! Deterministic screenshot capture for visual regression CI. +use std::path::PathBuf; +use std::time::Duration; + +use bevy::{ + camera::RenderTarget, platform::time::Instant, prelude::*, + render::render_resource::TextureFormat, time::TimeUpdateStrategy, +}; +use processing_core::app_mut; +use processing_core::error::{ProcessingError, Result}; + +pub const SCREENSHOT_ENV: &str = "PROCESSING_CI_SCREENSHOT"; +pub const FRAME_ENV: &str = "PROCESSING_CI_FRAME"; +pub const DEFAULT_FRAME: u32 = 10; +pub const FIXED_TIMESTEP: Duration = Duration::from_micros(16_667); + +#[derive(Resource, Debug)] +pub struct CiCapture { + path: PathBuf, + frame: u32, + epoch: Instant, + target: Option, + frames_ended: u32, + done: bool, +} + +pub fn enabled() -> bool { + std::env::var_os(SCREENSHOT_ENV).is_some_and(|p| !p.is_empty()) +} + +impl CiCapture { + fn from_env() -> Result> { + let Some(path) = std::env::var_os(SCREENSHOT_ENV).filter(|p| !p.is_empty()) else { + return Ok(None); + }; + let frame = match std::env::var(FRAME_ENV) { + Ok(s) => s.parse::().ok().filter(|n| *n > 0).ok_or_else(|| { + ProcessingError::InvalidArgument(format!( + "{FRAME_ENV} must be a positive integer, got {s:?}" + )) + })?, + Err(_) => DEFAULT_FRAME, + }; + Ok(Some(Self { + path: path.into(), + frame, + epoch: Instant::now(), + target: None, + frames_ended: 0, + done: false, + })) + } +} + +pub struct CiPlugin; + +impl Plugin for CiPlugin { + fn build(&self, app: &mut App) { + match CiCapture::from_env() { + Ok(Some(capture)) => { + info!( + "CI capture enabled: frame {} -> {}", + capture.frame, + capture.path.display() + ); + let epoch = capture.epoch; + app.insert_resource(capture) + .insert_resource(TimeUpdateStrategy::ManualInstant(epoch)); + } + Ok(None) => {} + Err(e) => panic!("{e}"), + } + } +} + +pub(crate) fn after_end_draw(app: &mut App, entity: Entity) -> Result<()> { + let world = app.world_mut(); + let Some(capture) = world.get_resource::() else { + return Ok(()); + }; + if capture.done { + return Ok(()); + } + match capture.target { + Some(target) if target != entity => return Ok(()), + Some(_) => {} + None => { + let is_window = matches!( + world.get::(entity), + Some(RenderTarget::Window(_)) + ); + if !is_window { + return Ok(()); + } + world.resource_mut::().target = Some(entity); + } + } + + let mut capture = world.resource_mut::(); + capture.frames_ended += 1; + let now = capture.epoch + FIXED_TIMESTEP * capture.frames_ended; + let reached = capture.frames_ended >= capture.frame; + world.insert_resource(TimeUpdateStrategy::ManualInstant(now)); + if !reached { + return Ok(()); + } + let mut capture = world.resource_mut::(); + let path = capture.path.clone(); + capture.done = true; + + let (width, height, rgba) = readback_srgba8(app, entity)?; + write_png(&path, width, height, &rgba)?; + info!("CI capture written to {}", path.display()); + Ok(()) +} + +pub fn done() -> bool { + app_mut(|app| { + Ok(app + .world() + .get_resource::() + .is_some_and(|c| c.done)) + }) + .unwrap_or(false) +} + +pub(crate) fn readback_srgba8(app: &mut App, entity: Entity) -> Result<(u32, u32, Vec)> { + crate::graphics::flush(app, entity)?; + let vt = crate::graphics::view_target(app, entity)?; + let texture = vt.main_texture().clone(); + let raw = app + .world_mut() + .run_system_cached_with(crate::graphics::readback_raw, (entity, texture)) + .unwrap()?; + let rgba = match raw.format { + TextureFormat::Rgba8UnormSrgb => raw.bytes, + format => { + let px_size = crate::image::pixel_size(format)?; + crate::image::bytes_to_pixels( + &raw.bytes, + format, + raw.width, + raw.height, + raw.width as usize * px_size, + )? + .iter() + .flat_map(|pixel| Srgba::from(*pixel).to_u8_array()) + .collect() + } + }; + Ok((raw.width, raw.height, rgba)) +} + +pub(crate) fn write_png( + path: &std::path::Path, + width: u32, + height: u32, + rgba: &[u8], +) -> Result<()> { + let io_err = |e: std::io::Error| { + ProcessingError::InvalidArgument(format!("write {}: {e}", path.display())) + }; + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(io_err)?; + } + let file = std::fs::File::create(path).map_err(io_err)?; + let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); + let png_err = + |e: png::EncodingError| ProcessingError::InvalidArgument(format!("PNG encode: {e}")); + encoder + .write_header() + .map_err(png_err)? + .write_image_data(rgba) + .map_err(png_err) +} diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index 4fc9071b..cd84f41c 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -581,7 +581,8 @@ pub fn present(app: &mut App, entity: Entity) -> Result<()> { /// End the current draw pub fn end_draw(app: &mut App, entity: Entity) -> Result<()> { - present(app, entity) + present(app, entity)?; + crate::ci::after_end_draw(app, entity) } /// Do some work on the GPU to ensure that the render target texture is initialized and can be read diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 1113411e..8634690c 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1,6 +1,7 @@ #![allow(clippy::module_inception)] pub mod camera; +pub mod ci; pub mod color; pub mod compute; pub mod geometry; @@ -101,7 +102,8 @@ impl Plugin for ProcessingRenderPlugin { bevy::camera_controller::free_camera::FreeCameraPlugin, bevy::camera_controller::pan_camera::PanCameraPlugin, text::font::TextPlugin, - )); + )) + .add_plugins(ci::CiPlugin); app.add_systems(First, (clear_transient_meshes, activate_cameras)) .add_systems( diff --git a/examples/particles_noise.rs b/examples/particles_noise.rs index 2ed4bbd3..1dc2125e 100644 --- a/examples/particles_noise.rs +++ b/examples/particles_noise.rs @@ -1,5 +1,4 @@ use processing_glfw::GlfwContext; -use std::time::Instant; use bevy::math::Vec3; use processing::prelude::*; @@ -11,10 +10,10 @@ fn main() { } fn sketch() -> error::Result<()> { - let mut glfw_ctx = GlfwContext::new(900, 700)?; + let mut glfw_ctx = GlfwContext::new(900, 700, false)?; init(Config::default())?; - let surface = glfw_ctx.create_surface(900, 700)?; + let surface = glfw_ctx.create_surface(900, 700, false)?; let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; graphics_mode_3d(graphics)?; @@ -53,7 +52,6 @@ fn sketch() -> error::Result<()> { }; let noise = particles_kernel_noise()?; - let start = Instant::now(); while glfw_ctx.poll_events() { graphics_begin_draw(graphics)?; graphics_record_command( @@ -65,12 +63,13 @@ fn sketch() -> error::Result<()> { graphics, DrawCommand::Particles { particles: p, - geometry: particle, + geometry: Some(particle), + topology: geometry::Topology::PointList, }, )?; graphics_end_draw(graphics)?; - let t = start.elapsed().as_secs_f32(); + let t = elapsed_time()?; compute_set(noise, "scale", shader_value::ShaderValue::Float(0.25))?; compute_set(noise, "strength", shader_value::ShaderValue::Float(0.02))?; compute_set(noise, "time", shader_value::ShaderValue::Float(t * 0.5))?; diff --git a/examples/primitives_3d.rs b/examples/primitives_3d.rs index a93c8fd2..4838c7db 100644 --- a/examples/primitives_3d.rs +++ b/examples/primitives_3d.rs @@ -10,10 +10,10 @@ fn main() { } fn sketch() -> error::Result<()> { - let mut glfw_ctx = GlfwContext::new(900, 400)?; + let mut glfw_ctx = GlfwContext::new(900, 400, false)?; init(Config::default())?; - let surface = glfw_ctx.create_surface(900, 400)?; + let surface = glfw_ctx.create_surface(900, 400, false)?; let graphics = graphics_create(surface, 900, 400, TextureFormat::Rgba16Float)?; graphics_mode_3d(graphics)?; diff --git a/examples/rectangle.rs b/examples/rectangle.rs index 7ea00936..1f75466c 100644 --- a/examples/rectangle.rs +++ b/examples/rectangle.rs @@ -17,12 +17,12 @@ fn main() { } fn sketch() -> error::Result<()> { - let mut glfw_ctx = GlfwContext::new(400, 400)?; + let mut glfw_ctx = GlfwContext::new(400, 400, false)?; init(Config::default())?; let width = 400; let height = 400; - let surface = glfw_ctx.create_surface(width, height)?; + let surface = glfw_ctx.create_surface(width, height, false)?; let graphics = graphics_create(surface, width, height, TextureFormat::Rgba16Float)?; while glfw_ctx.poll_events() { diff --git a/justfile b/justfile index 4af5b1d1..6cd2a1a9 100644 --- a/justfile +++ b/justfile @@ -35,3 +35,9 @@ wasm-release: wasm-serve: wasm-build python3 -m http.server 8000 + +visual-render out="target/visual/actual" *args: + python3 tests/visual/visual.py render --out {{out}} {{args}} + +visual-compare baseline actual="target/visual/actual" out="target/visual/report": + python3 tests/visual/visual.py compare --baseline {{baseline}} --actual {{actual}} --out {{out}} diff --git a/tests/visual/cases.toml b/tests/visual/cases.toml new file mode 100644 index 00000000..9353fdaf --- /dev/null +++ b/tests/visual/cases.toml @@ -0,0 +1,178 @@ +# Visual regression cases. + +[defaults] +frame = 30 +threshold = 0.1 +max_diff_percent = 0.05 + +[[case]] +name = "rectangle" +rust = "rectangle" + +[[case]] +name = "primitives_3d" +rust = "primitives_3d" + +[[case]] +name = "particles_noise" +rust = "particles_noise" + +[[case]] +name = "py_primitives_3d" +python = "primitives_3d.py" + +[[case]] +name = "py_particles_noise" +python = "particles_noise.py" + +[[case]] +name = "py_animated_mesh" +python = "animated_mesh.py" + +[[case]] +name = "py_background_image" +python = "background_image.py" + +[[case]] +name = "py_blend_modes" +python = "blend_modes.py" + +[[case]] +name = "py_box" +python = "box.py" + +[[case]] +name = "py_camera_controllers" +python = "camera_controllers.py" + +[[case]] +name = "py_curves" +python = "curves.py" + +[[case]] +name = "py_custom_material" +python = "custom_material.py" + +[[case]] +name = "py_feedback" +python = "feedback.py" + +[[case]] +name = "py_flocking" +python = "flocking.py" + +[[case]] +name = "py_flocking_duck" +python = "flocking_duck.py" + +[[case]] +name = "py_flocking_gpu" +python = "flocking_gpu.py" + +[[case]] +name = "py_geometry_methods" +python = "geometry_methods.py" + +[[case]] +name = "py_gltf_load" +python = "gltf_load.py" + +[[case]] +name = "py_lights" +python = "lights.py" + +[[case]] +name = "py_materials" +python = "materials.py" + +[[case]] +name = "py_multi_window" +python = "multi_window.py" + +[[case]] +name = "py_particles_animated" +python = "particles_animated.py" + +[[case]] +name = "py_particles_basic" +python = "particles_basic.py" + +[[case]] +name = "py_particles_density" +python = "particles_density.py" + +[[case]] +name = "py_particles_emit" +python = "particles_emit.py" + +[[case]] +name = "py_particles_emit_gpu" +python = "particles_emit_gpu.py" + +[[case]] +name = "py_particles_from_mesh" +python = "particles_from_mesh.py" + +[[case]] +name = "py_particles_gpu_surface" +python = "particles_gpu_surface.py" + +[[case]] +name = "py_particles_gpu_surface_lit" +python = "particles_gpu_surface_lit.py" + +[[case]] +name = "py_particles_lifecycle" +python = "particles_lifecycle.py" + +[[case]] +name = "py_particles_lines" +python = "particles_lines.py" + +[[case]] +name = "py_particles_lissajous" +python = "particles_lissajous.py" + +[[case]] +name = "py_particles_points" +python = "particles_points.py" + +[[case]] +name = "py_particles_scatter_volume" +python = "particles_scatter_volume.py" + +[[case]] +name = "py_particles_sphere" +python = "particles_sphere.py" + +[[case]] +name = "py_particles_stress" +python = "particles_stress.py" + +[[case]] +name = "py_particles_surface" +python = "particles_surface.py" + +[[case]] +name = "py_primitives_2d" +python = "primitives_2d.py" + +[[case]] +name = "py_rectangle" +python = "rectangle.py" + +[[case]] +name = "py_shapes" +python = "shapes.py" + +[[case]] +name = "py_style_stack" +python = "style_stack.py" + +[[case]] +name = "py_text" +python = "text.py" + +[[case]] +name = "py_window_controls" +python = "window_controls.py" diff --git a/tests/visual/visual.py b/tests/visual/visual.py new file mode 100644 index 00000000..0ea9b9e1 --- /dev/null +++ b/tests/visual/visual.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +"""Visual regression harness. + + visual.py render --out DIR [--root CHECKOUT] [--only NAME ...] + visual.py compare --baseline DIR --actual DIR --out DIR [--allow-changes] +""" + +from __future__ import annotations + +import argparse +import base64 +import html +import json +import os +import re +import shutil +import subprocess +import sys +import time +import tomllib +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] +CASES_FILE = HERE / "cases.toml" +RENDER_TIMEOUT_SECS = 300 +LOG_TAIL_LINES = 40 + + +def load_cases(only: list[str] | None = None) -> list[dict]: + data = tomllib.loads(CASES_FILE.read_text()) + defaults = data.get("defaults", {}) + cases, seen = [], set() + for raw in data.get("case", []): + case = {**defaults, **raw} + name = case.get("name") + if not name or not re.fullmatch(r"[A-Za-z0-9_-]+", name): + sys.exit(f"cases.toml: invalid case name {name!r}") + if name in seen: + sys.exit(f"cases.toml: duplicate case {name!r}") + if ("rust" in case) == ("python" in case): + sys.exit(f"cases.toml: case {name!r} must set exactly one of `rust` or `python`") + seen.add(name) + cases.append(case) + if only: + unknown = set(only) - seen + if unknown: + sys.exit(f"unknown case(s): {', '.join(sorted(unknown))}") + cases = [c for c in cases if c["name"] in only] + return cases + + +def run(cmd: list[str], cwd: Path, env: dict | None = None) -> None: + print(f"$ {' '.join(cmd)} (in {cwd})", flush=True) + subprocess.run(cmd, cwd=cwd, env=env, check=True) + + +def git_head(root: Path) -> str | None: + try: + out = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, check=True + ) + return out.stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return None + + +def adapter_from_log(log: str) -> str | None: + match = re.search(r"AdapterInfo \{[^}]*\}", log) + if not match: + return None + fields = dict(re.findall(r'(\w+): ("[^"]*"|[^,}]+)', match.group(0))) + name, backend, driver = (fields.get(k, "").strip().strip('"') for k in ("name", "backend", "driver_info")) + return f"{name} ({backend}{', ' + driver if driver else ''})" + + +def cmd_render(args: argparse.Namespace) -> int: + root = Path(args.root).resolve() + out = Path(args.out).resolve() + logs = out / "logs" + logs.mkdir(parents=True, exist_ok=True) + cases = load_cases(args.only) + + rust = [c for c in cases if "rust" in c] + python = [c for c in cases if "python" in c] + py_dir = root / "crates" / "processing_pyo3" + + if rust and not args.skip_build: + examples = [arg for c in rust for arg in ("--example", c["rust"])] + run(["cargo", "build", "--release", *examples], cwd=root) + if python and not args.skip_build: + run(["uv", "run", "maturin", "develop", "--release"], cwd=py_dir) + + manifest = {"commit": git_head(root), "adapter": None, "cases": {}} + for case in cases: + name = case["name"] + png = out / f"{name}.png" + png.unlink(missing_ok=True) + env = { + **os.environ, + "PROCESSING_CI_SCREENSHOT": str(png), + "PROCESSING_CI_FRAME": str(case["frame"]), + } + env.setdefault("PROCESSING_ASSET_ROOT", str(root / "assets")) + if "rust" in case: + cmd, cwd = ["cargo", "run", "--quiet", "--release", "--example", case["rust"]], root + else: + cmd, cwd = ["uv", "run", "python", f"examples/{case['python']}"], py_dir + + print(f"--- {name}", flush=True) + started = time.monotonic() + try: + proc = subprocess.run( + cmd, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + errors="replace", + timeout=RENDER_TIMEOUT_SECS, + ) + log, code = proc.stdout, proc.returncode + except subprocess.TimeoutExpired as e: + log = (e.stdout or b"").decode(errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "") + log += f"\n[visual.py] timed out after {RENDER_TIMEOUT_SECS}s\n" + code = None + elapsed = time.monotonic() - started + (logs / f"{name}.log").write_text(log) + + if code == 0 and png.exists(): + status = "ok" + elif code == 0: + status = "no-capture" + else: + status = "timeout" if code is None else f"exit {code}" + manifest["adapter"] = manifest["adapter"] or adapter_from_log(log) + manifest["cases"][name] = {"status": status, "seconds": round(elapsed, 1)} + print(f" {status} in {elapsed:.1f}s", flush=True) + if status != "ok": + print("\n".join(log.splitlines()[-LOG_TAIL_LINES:]), flush=True) + + (out / "manifest.json").write_text(json.dumps(manifest, indent=2)) + failed = [n for n, c in manifest["cases"].items() if c["status"] != "ok"] + if failed: + print(f"failed to render: {', '.join(failed)}", file=sys.stderr) + # Render failures are recorded in the manifest and surfaced by `compare`. + return 0 + + +def odiff(exe: str, base: Path, actual: Path, diff: Path, threshold: float) -> tuple[str, float]: + """Returns (outcome, differing percent) where outcome is match, pixels or layout.""" + proc = subprocess.run( + [ + exe, str(base), str(actual), str(diff), + "--antialiasing", "--fail-on-layout", "--parsable-stdout", "--diff-mask", + f"--threshold={threshold}", + ], + capture_output=True, + text=True, + ) + stdout = proc.stdout.strip() + if proc.returncode == 0: + return "match", 0.0 + if proc.returncode == 21: + return "layout", 100.0 + if proc.returncode == 22: + _count, percent = stdout.split(";") + return "pixels", float(percent) + raise RuntimeError(f"odiff exited {proc.returncode}: {stdout} {proc.stderr.strip()}") + + +def read_manifest(directory: Path) -> dict: + path = directory / "manifest.json" + return json.loads(path.read_text()) if path.exists() else {"cases": {}} + + +def cmd_compare(args: argparse.Namespace) -> int: + baseline, actual, out = Path(args.baseline), Path(args.actual), Path(args.out) + diffs = out / "diff" + diffs.mkdir(parents=True, exist_ok=True) + exe = args.odiff or shutil.which("odiff") + if not exe: + sys.exit("odiff not found; install it with `npm install -g odiff-bin` or pass --odiff") + + base_manifest, actual_manifest = read_manifest(baseline), read_manifest(actual) + results = [] + for case in load_cases(): + name = case["name"] + base_png, actual_png, diff_png = ( + baseline / f"{name}.png", + actual / f"{name}.png", + diffs / f"{name}.png", + ) + render_status = actual_manifest["cases"].get(name, {}).get("status", "not rendered") + result = { + "name": name, + "source": case.get("rust") or case.get("python"), + "kind": "rust" if "rust" in case else "python", + "max_diff_percent": case["max_diff_percent"], + "diff_percent": None, + } + if not actual_png.exists(): + result.update(status="error", detail=render_status) + elif not base_png.exists(): + result.update(status="new", detail="no baseline on main") + else: + outcome, percent = odiff(exe, base_png, actual_png, diff_png, case["threshold"]) + result["diff_percent"] = percent + if outcome == "layout": + result.update(status="changed", detail="image size changed") + elif percent > case["max_diff_percent"]: + result.update(status="changed", detail=f"{percent:g}% of pixels differ") + else: + result.update(status="pass", detail="identical" if outcome == "match" else f"{percent:g}% (within tolerance)") + results.append(result) + + report = { + "baseline_commit": base_manifest.get("commit"), + "actual_commit": actual_manifest.get("commit"), + "adapter": actual_manifest.get("adapter"), + "allow_changes": args.allow_changes, + "results": results, + } + (out / "results.json").write_text(json.dumps(report, indent=2)) + (out / "summary.md").write_text(render_summary(report)) + (out / "report.html").write_text(render_html(report, baseline, actual, diffs, actual / "logs")) + + counts = {s: sum(r["status"] == s for r in results) for s in ("pass", "changed", "new", "error")} + print(" ".join(f"{k}={v}" for k, v in counts.items())) + if counts["error"]: + return 1 + if counts["changed"] and not args.allow_changes: + return 1 + return 0 + + +STATUS_ICON = {"pass": "✅", "changed": "❌", "new": "🆕", "error": "💥"} + + +def render_summary(report: dict) -> str: + results = report["results"] + changed = [r for r in results if r["status"] == "changed"] + notable = [r for r in results if r["status"] != "pass"] + total = len(results) + if not notable: + headline = f"**Visual regression: no changes** across {total} cases" + else: + parts = [] + for status, label in (("changed", "changed"), ("error", "failed to render"), ("new", "new")): + n = sum(r["status"] == status for r in results) + if n: + parts.append(f"{n} {label}") + headline = f"**Visual regression: {', '.join(parts)}** of {total} cases" + lines = [headline, ""] + if notable: + lines += ["| case | status | detail |", "|---|---|---|"] + for r in notable: + lines.append(f"| `{r['name']}` | {STATUS_ICON[r['status']]} {r['status']} | {r['detail']} |") + lines.append("") + if changed: + if report["allow_changes"]: + lines.append("Changes accepted by the `deliberate-rendering-change` label.") + else: + lines.append("If these changes are intentional, add the `deliberate-rendering-change` label.") + lines.append("") + base = (report.get("baseline_commit") or "unknown")[:10] + lines.append(f"baseline `{base}` · adapter `{report.get('adapter') or 'unknown'}`") + return "\n".join(lines) + "\n" + + +def data_uri(path: Path) -> str | None: + if not path.exists(): + return None + return "data:image/png;base64," + base64.b64encode(path.read_bytes()).decode() + + +def figure(label: str, path: Path, css_class: str = "") -> str: + uri = data_uri(path) + body = f'{label}' if uri else '
none
' + return f'
{body}
{label}
' + + +def render_html(report: dict, baseline: Path, actual: Path, diffs: Path, logs: Path) -> str: + order = {"error": 0, "changed": 1, "new": 2, "pass": 3} + sections = [] + for r in sorted(report["results"], key=lambda r: (order[r["status"]], r["name"])): + name, status = r["name"], r["status"] + title = ( + f'

{status} {html.escape(name)} ' + f'{html.escape(r["kind"])}: {html.escape(r["source"])} · ' + f'{html.escape(r["detail"])}

' + ) + if status == "pass": + sections.append(f'
{title}
') + continue + figures = "" + if status == "changed": + figures = ( + figure("baseline (main)", baseline / f"{name}.png") + + figure("this PR", actual / f"{name}.png") + + figure("changed pixels", diffs / f"{name}.png", "mask") + ) + elif status == "new": + figures = figure("this PR", actual / f"{name}.png") + log = "" + if status == "error": + log_path = logs / f"{name}.log" + tail = "\n".join(log_path.read_text().splitlines()[-LOG_TAIL_LINES:]) if log_path.exists() else "" + log = f"
{html.escape(tail or 'no log captured')}
" + sections.append(f'
{title}
{figures}
{log}
') + + meta = ( + f"baseline {html.escape(str(report.get('baseline_commit')))} · " + f"PR {html.escape(str(report.get('actual_commit')))} · " + f"adapter {html.escape(str(report.get('adapter')))}" + ) + return f""" + + + + +Visual regression report + + + +
+

Visual regression report

+

{meta}

+
+{"".join(sections)} + + +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + + render = sub.add_parser("render", help="render every case to PNG") + render.add_argument("--out", required=True) + render.add_argument("--root", default=str(REPO), help="checkout to build and render") + render.add_argument("--only", nargs="+", metavar="NAME") + render.add_argument("--skip-build", action="store_true", help="reuse existing builds") + render.set_defaults(func=cmd_render) + + compare = sub.add_parser("compare", help="diff rendered cases against a baseline") + compare.add_argument("--baseline", required=True) + compare.add_argument("--actual", required=True) + compare.add_argument("--out", required=True) + compare.add_argument("--odiff", help="path to the odiff binary") + compare.add_argument("--allow-changes", action="store_true", help="report changes without failing") + compare.set_defaults(func=cmd_compare) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) From dfcc748329585b017455897a9d6fd5b0380627a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 25 Sep 2026 19:47:02 -0700 Subject: [PATCH 4/8] Don't assume x11 in python. --- Cargo.toml | 2 +- crates/processing_glfw/src/lib.rs | 131 ++++++++++++++++++------------ crates/processing_pyo3/Cargo.toml | 2 +- 3 files changed, 79 insertions(+), 56 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2bae50b6..be9f10df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,7 +94,7 @@ processing_glfw = { workspace = true } rand = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] -processing_glfw = { workspace = true, features = ["wayland"] } +processing_glfw = { workspace = true, features = ["wayland", "x11"] } ## TODO: Remove these patches once we've moved back to depending on upstream bevy [patch."https://github.com/bevyengine/bevy"] diff --git a/crates/processing_glfw/src/lib.rs b/crates/processing_glfw/src/lib.rs index 6e296cd9..a6a30891 100644 --- a/crates/processing_glfw/src/lib.rs +++ b/crates/processing_glfw/src/lib.rs @@ -17,6 +17,10 @@ use processing_input::{ }; use processing_render::surface::{MonitorWorkarea, WindowControls}; +fn is_wayland(glfw: &Glfw) -> bool { + cfg!(target_os = "linux") && glfw.get_platform() == glfw::Platform::Wayland +} + /// A single GLFW instance drives every window (GLFW's event pump is global). The /// main window is `windows[0]`; `create_window` appends more. pub struct GlfwContext { @@ -96,30 +100,33 @@ impl GlfwContext { // Set _NET_WM_WINDOW_TYPE_DIALOG so tiling WMs (i3, sway) float the window #[cfg(all(target_os = "linux", feature = "x11"))] - unsafe { + if glfw.get_platform() == glfw::Platform::X11 { use std::ffi::CString; - let display = window.glfw.get_x11_display() as *mut x11::xlib::Display; - let xwindow = window.get_x11_window() as x11::xlib::Window; - let net_wm_window_type = x11::xlib::XInternAtom( - display, - CString::new("_NET_WM_WINDOW_TYPE").unwrap().as_ptr(), - 0, - ); - let net_wm_window_type_dialog = x11::xlib::XInternAtom( - display, - CString::new("_NET_WM_WINDOW_TYPE_DIALOG").unwrap().as_ptr(), - 0, - ); - x11::xlib::XChangeProperty( - display, - xwindow, - net_wm_window_type, - x11::xlib::XA_ATOM, - 32, - x11::xlib::PropModeReplace, - &net_wm_window_type_dialog as *const _ as *const u8, - 1, - ); + // SAFETY: GLFW is on X11, so the display and window handles are live Xlib objects. + unsafe { + let display = window.glfw.get_x11_display() as *mut x11::xlib::Display; + let xwindow = window.get_x11_window() as x11::xlib::Window; + let net_wm_window_type = x11::xlib::XInternAtom( + display, + CString::new("_NET_WM_WINDOW_TYPE").unwrap().as_ptr(), + 0, + ); + let net_wm_window_type_dialog = x11::xlib::XInternAtom( + display, + CString::new("_NET_WM_WINDOW_TYPE_DIALOG").unwrap().as_ptr(), + 0, + ); + x11::xlib::XChangeProperty( + display, + xwindow, + net_wm_window_type, + x11::xlib::XA_ATOM, + 32, + x11::xlib::PropModeReplace, + &net_wm_window_type_dialog as *const _ as *const u8, + 1, + ); + } } window.show(); @@ -275,19 +282,41 @@ impl GlfwContext { let handle = self.windows[idx].window.get_win32_window() as u64; surface_create_windows(handle, width, height, scale_factor, transparent)? }; - #[cfg(all(target_os = "linux", feature = "wayland"))] - let entity = { - use processing_render::surface_create_wayland; - let wh = self.windows[idx].window.get_wayland_window() as u64; - let dh = self.glfw.get_wayland_display() as u64; - surface_create_wayland(wh, dh, width, height, scale_factor, transparent)? - }; - #[cfg(all(target_os = "linux", feature = "x11", not(feature = "wayland")))] - let entity = { - use processing_render::surface_create_x11; - let wh = self.windows[idx].window.get_x11_window() as u64; - let dh = self.glfw.get_x11_display() as u64; - surface_create_x11(wh, dh, width, height, scale_factor, transparent)? + #[cfg(target_os = "linux")] + let entity = match self.glfw.get_platform() { + #[cfg(feature = "wayland")] + glfw::Platform::Wayland => { + let wh = self.windows[idx].window.get_wayland_window() as u64; + let dh = self.glfw.get_wayland_display() as u64; + processing_render::surface_create_wayland( + wh, + dh, + width, + height, + scale_factor, + transparent, + )? + } + #[cfg(feature = "x11")] + glfw::Platform::X11 => { + let wh = self.windows[idx].window.get_x11_window() as u64; + let dh = self.glfw.get_x11_display() as u64; + processing_render::surface_create_x11( + wh, + dh, + width, + height, + scale_factor, + transparent, + )? + } + platform => { + return Err(processing_core::error::ProcessingError::InvalidArgument( + format!( + "GLFW is running on {platform:?}, which this build doesn't support; enable the matching `x11`/`wayland` feature" + ), + )); + } }; self.windows[idx].surface = Some(entity); @@ -438,8 +467,7 @@ impl ManagedWindow { if desired.maximize { self.window.maximize(); } - #[cfg(not(all(target_os = "linux", feature = "wayland")))] - if desired.focus { + if desired.focus && !is_wayland(glfw) { self.window.focus(); } @@ -463,18 +491,15 @@ impl ManagedWindow { self.last_applied.size = bevy::math::UVec2::new(w.max(0) as u32, h.max(0) as u32); } - #[cfg(not(feature = "wayland"))] fn frame_pos(&self) -> IVec2 { + if is_wayland(&self.window.glfw) { + return self.last_applied.position; + } let (cx, cy) = self.window.get_pos(); let (inset_l, inset_t, _, _) = self.window.get_frame_size(); IVec2::new(cx - inset_l, cy - inset_t) } - #[cfg(feature = "wayland")] - fn frame_pos(&self) -> IVec2 { - self.last_applied.position - } - fn apply_window(&mut self, glfw: &mut Glfw, desired: &DesiredWindow) { let last = &mut self.last_applied; @@ -482,9 +507,9 @@ impl ManagedWindow { self.window.set_title(&desired.title); last.title.clone_from(&desired.title); } - #[cfg(not(feature = "wayland"))] if let Some(pos) = desired.position && pos != last.position + && !is_wayland(glfw) { let (inset_l, inset_t, _, _) = self.window.get_frame_size(); self.window.set_pos(pos.x + inset_l, pos.y + inset_t); @@ -512,16 +537,18 @@ impl ManagedWindow { last.decorations = desired.decorations; } if desired.window_level != last.window_level { - #[cfg(not(all(target_os = "linux", feature = "wayland")))] - self.window - .set_floating(matches!(desired.window_level, BevyWindowLevel::AlwaysOnTop)); + if !is_wayland(glfw) { + self.window + .set_floating(matches!(desired.window_level, BevyWindowLevel::AlwaysOnTop)); + } last.window_level = desired.window_level; } if let Some(opacity) = desired.opacity && (opacity - last.opacity).abs() > f32::EPSILON { - #[cfg(not(all(target_os = "linux", feature = "wayland")))] - self.window.set_opacity(opacity); + if !is_wayland(glfw) { + self.window.set_opacity(opacity); + } last.opacity = opacity; } if desired.fullscreen_on != last.fullscreen_on { @@ -590,7 +617,6 @@ impl ManagedWindow { #[derive(Clone, Debug)] struct DesiredWindow { title: String, - #[cfg(not(feature = "wayland"))] position: Option, size: bevy::math::UVec2, visible: bool, @@ -602,7 +628,6 @@ struct DesiredWindow { iconify: bool, restore: bool, maximize: bool, - #[cfg(not(all(target_os = "linux", feature = "wayland")))] focus: bool, } @@ -624,7 +649,6 @@ fn read_desired_window(surface: Entity) -> Option { }; Ok(Some(DesiredWindow { title: window.title.clone(), - #[cfg(not(feature = "wayland"))] position: match window.position { WindowPosition::At(p) => Some(p), _ => None, @@ -642,7 +666,6 @@ fn read_desired_window(surface: Entity) -> Option { iconify: controls.pending_iconify, restore: controls.pending_restore, maximize: controls.pending_maximize, - #[cfg(not(all(target_os = "linux", feature = "wayland")))] focus: controls.pending_focus, })) }) diff --git a/crates/processing_pyo3/Cargo.toml b/crates/processing_pyo3/Cargo.toml index 0b8ab77d..ce5c3449 100644 --- a/crates/processing_pyo3/Cargo.toml +++ b/crates/processing_pyo3/Cargo.toml @@ -11,7 +11,7 @@ name = "mewnala" crate-type = ["cdylib", "rlib"] [features] -default = ["wayland", "static-link"] +default = ["wayland", "x11", "static-link"] wayland = ["processing/wayland", "processing_glfw/wayland"] static-link = ["processing_glfw/static-link"] x11 = ["processing/x11", "processing_glfw/x11"] From c6d1ff2c4f970d0bea08e4c22b1e9d8e5fa66ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 25 Sep 2026 20:33:51 -0700 Subject: [PATCH 5/8] mesa. --- .github/actions/install-mesa/action.yml | 39 +++++-------------------- 1 file changed, 8 insertions(+), 31 deletions(-) diff --git a/.github/actions/install-mesa/action.yml b/.github/actions/install-mesa/action.yml index 6fec957b..5c31f9d9 100644 --- a/.github/actions/install-mesa/action.yml +++ b/.github/actions/install-mesa/action.yml @@ -1,39 +1,16 @@ name: 'Install Mesa' -description: 'Install a pinned Mesa build and select lavapipe as the only Vulkan driver' -inputs: - # Prebuilt by wgpu's CI (https://github.com/gfx-rs/ci-build/releases). Pinned so screenshots - # don't drift with the runner image's Mesa. - version: - default: '26.1.3' - ci-binary-build: - default: 'build29' +description: "Install the runner release's lavapipe and select it as the only Vulkan driver" runs: using: 'composite' steps: + # Distro Mesa rather than gfx-rs/ci-build: those builds have no window-system support + # (-Dplatforms=), so they can't create the X11 surfaces sketches render to. - name: Install Mesa shell: bash - env: - MESA_VERSION: ${{ inputs.version }} - CI_BINARY_BUILD: ${{ inputs.ci-binary-build }} run: | set -e - dir="$RUNNER_TEMP/mesa" - mkdir -p "$dir" - curl -L --retry 5 "https://github.com/gfx-rs/ci-build/releases/download/$CI_BINARY_BUILD/mesa-$MESA_VERSION-linux-x86_64.tar.xz" -o "$RUNNER_TEMP/mesa.tar.xz" - tar xpf "$RUNNER_TEMP/mesa.tar.xz" -C "$dir" - - # The ICD shipped in the tarball points at the build machine's paths. - cat <<- EOF > "$dir/icd.json" - { - "ICD": { - "api_version": "1.4.348", - "library_arch": "64", - "library_path": "$dir/lib/x86_64-linux-gnu/libvulkan_lvp.so" - }, - "file_format_version": "1.0.1" - } - EOF - - echo "VK_DRIVER_FILES=$dir/icd.json" >> "$GITHUB_ENV" - echo "LD_LIBRARY_PATH=$dir/lib/x86_64-linux-gnu/:$LD_LIBRARY_PATH" >> "$GITHUB_ENV" - echo "LIBGL_DRIVERS_PATH=$dir/lib/x86_64-linux-gnu/dri" >> "$GITHUB_ENV" + sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1 + icd=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json + test -f "$icd" + echo "VK_DRIVER_FILES=$icd" >> "$GITHUB_ENV" + dpkg-query -W mesa-vulkan-drivers From 8faca2d988e2e30b8c66e94002c4cd630d19ada6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 25 Sep 2026 21:05:07 -0700 Subject: [PATCH 6/8] . --- .github/actions/install-mesa/action.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/actions/install-mesa/action.yml b/.github/actions/install-mesa/action.yml index 5c31f9d9..75848168 100644 --- a/.github/actions/install-mesa/action.yml +++ b/.github/actions/install-mesa/action.yml @@ -10,7 +10,12 @@ runs: run: | set -e sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1 - icd=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json - test -f "$icd" + icd=$(dpkg -L mesa-vulkan-drivers | grep -E '/lvp_icd[^/]*\.json$' | head -1) + if [ -z "$icd" ]; then + echo "::error::no lavapipe ICD in mesa-vulkan-drivers" + dpkg -L mesa-vulkan-drivers | grep -E '\.json$' + exit 1 + fi + echo "Using $icd" echo "VK_DRIVER_FILES=$icd" >> "$GITHUB_ENV" dpkg-query -W mesa-vulkan-drivers From 42d376f12cad54e007f5049abfcec0b0aca702a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 25 Sep 2026 21:28:50 -0700 Subject: [PATCH 7/8] Remove missing case logic. --- .github/workflows/visual.yml | 37 +++++++++++++++--------------------- tests/visual/visual.py | 5 ++--- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/.github/workflows/visual.yml b/.github/workflows/visual.yml index 978740d3..2a2f8174 100644 --- a/.github/workflows/visual.yml +++ b/.github/workflows/visual.yml @@ -81,36 +81,29 @@ jobs: retention-days: ${{ github.event_name == 'push' && 90 || 14 }} - name: Fetch baseline from main - id: baseline if: github.event_name == 'pull_request' env: GH_TOKEN: ${{ github.token }} run: | sha=$(git rev-parse HEAD^1) - echo "sha=$sha" >> "$GITHUB_OUTPUT" - run_id=$(gh run list -R "$GITHUB_REPOSITORY" --workflow visual.yml --branch main --event push \ - --commit "$sha" --status success --limit 1 --json databaseId --jq '.[0].databaseId // empty') - if [ -n "$run_id" ] && gh run download "$run_id" -R "$GITHUB_REPOSITORY" -n visual-screenshots -D out/baseline; then - echo "Using screenshots from run $run_id" - echo "found=true" >> "$GITHUB_OUTPUT" + latest() { + gh run list -R "$GITHUB_REPOSITORY" --workflow visual.yml --branch main --event push \ + --commit "$sha" --limit 1 --json databaseId,status,conclusion --jq '.[0] // empty' + } + run=$(latest) + if [ -n "$run" ] && [ "$(jq -r .status <<< "$run")" != completed ]; then + id=$(jq -r .databaseId <<< "$run") + echo "Waiting for main's run $id to publish screenshots" + gh run watch "$id" -R "$GITHUB_REPOSITORY" --interval 30 > /dev/null || true + run=$(latest) + fi + if [ "$(jq -r '.conclusion // empty' <<< "$run")" = success ] && + gh run download "$(jq -r .databaseId <<< "$run")" -R "$GITHUB_REPOSITORY" -n visual-screenshots -D out/baseline; then + echo "Using screenshots from run $(jq -r .databaseId <<< "$run")" else - echo "No screenshots published for $sha; rendering it here" - echo "found=false" >> "$GITHUB_OUTPUT" + echo "::notice::No screenshots published for main at $sha; cases are reported as new" fi - - name: Render baseline - if: steps.baseline.outputs.found == 'false' - # A base that predates the harness can't render; its cases are then reported as new. - continue-on-error: true - env: - BASE_SHA: ${{ steps.baseline.outputs.sha }} - CARGO_TARGET_DIR: ${{ github.workspace }}/target - run: | - git worktree add --detach "$RUNNER_TEMP/base" "$BASE_SHA" - git -C "$RUNNER_TEMP/base" submodule update --init --depth 1 - xvfb-run -a -s "-screen 0 1920x1080x24" \ - python3 tests/visual/visual.py render --root "$RUNNER_TEMP/base" --out out/baseline - - name: Compare id: compare if: github.event_name == 'pull_request' diff --git a/tests/visual/visual.py b/tests/visual/visual.py index 0ea9b9e1..bd42a144 100644 --- a/tests/visual/visual.py +++ b/tests/visual/visual.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Visual regression harness. - visual.py render --out DIR [--root CHECKOUT] [--only NAME ...] + visual.py render --out DIR [--only NAME ...] visual.py compare --baseline DIR --actual DIR --out DIR [--allow-changes] """ @@ -75,7 +75,7 @@ def adapter_from_log(log: str) -> str | None: def cmd_render(args: argparse.Namespace) -> int: - root = Path(args.root).resolve() + root = REPO out = Path(args.out).resolve() logs = out / "logs" logs.mkdir(parents=True, exist_ok=True) @@ -364,7 +364,6 @@ def main() -> int: render = sub.add_parser("render", help="render every case to PNG") render.add_argument("--out", required=True) - render.add_argument("--root", default=str(REPO), help="checkout to build and render") render.add_argument("--only", nargs="+", metavar="NAME") render.add_argument("--skip-build", action="store_true", help="reuse existing builds") render.set_defaults(func=cmd_render) From a652e88c788a72e3c8c1400464f348df81efa290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 25 Sep 2026 23:00:58 -0700 Subject: [PATCH 8/8] Guard time span. --- crates/processing_input/src/lib.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/processing_input/src/lib.rs b/crates/processing_input/src/lib.rs index 4ee0bfd4..118a295f 100644 --- a/crates/processing_input/src/lib.rs +++ b/crates/processing_input/src/lib.rs @@ -8,6 +8,7 @@ use bevy::input::mouse::{ }; use bevy::input::touch::TouchPhase; use bevy::prelude::*; +use bevy::time::TimeSystems; use bevy::window::{CursorMoved, WindowResized}; use processing_core::app_mut; @@ -17,10 +18,18 @@ pub use state::{CursorPosition, LastKey, LastMouseButton}; pub struct InputPlugin; +#[derive(Resource, Default)] +struct InputFlushing(bool); + impl Plugin for InputPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() + .init_resource::() + .configure_sets( + First, + TimeSystems.run_if(|flushing: Res| !flushing.0), + ) .add_systems( PreUpdate, ( @@ -208,9 +217,11 @@ pub fn input_set_cursor_icon( pub fn input_flush() -> error::Result<()> { app_mut(|app| { let world = app.world_mut(); + world.resource_mut::().0 = true; world.run_schedule(First); world.run_schedule(PreUpdate); world.run_schedule(RunFixedMainLoop); + world.resource_mut::().0 = false; Ok(()) }) }