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/2] 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/2] 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); }