diff --git a/Cargo.lock b/Cargo.lock index 805c919..32a9361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6255,6 +6255,7 @@ name = "processing_core" version = "0.4.0" dependencies = [ "bevy", + "rand 0.10.2", "raw-window-handle", "thiserror 2.0.20", "tracing", diff --git a/crates/processing_core/Cargo.toml b/crates/processing_core/Cargo.toml index bb38866..52761fe 100644 --- a/crates/processing_core/Cargo.toml +++ b/crates/processing_core/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] bevy = { workspace = true } +rand = { workspace = true } raw-window-handle = "0.6" thiserror = "2" tracing = "0.1" diff --git a/crates/processing_core/src/constants.rs b/crates/processing_core/src/constants.rs index f677149..0cd5866 100644 --- a/crates/processing_core/src/constants.rs +++ b/crates/processing_core/src/constants.rs @@ -54,6 +54,16 @@ pub const OKLCH: &str = "oklch"; pub const LAB: &str = "lab"; pub const LCH: &str = "lch"; pub const XYZ: &str = "xyz"; +// Processing's names for the same spaces +pub const RGB: &str = SRGB; +pub const HSB: &str = HSV; + +pub const ARROW: &str = "arrow"; +pub const CROSS: &str = "cross"; +pub const HAND: &str = "hand"; +pub const MOVE: &str = "move"; +pub const TEXT: &str = "text"; +pub const WAIT: &str = "wait"; pub const PBR_NEUTRAL: &str = "pbr_neutral"; // tonemapping pub const AGX: &str = "agx"; diff --git a/crates/processing_core/src/lib.rs b/crates/processing_core/src/lib.rs index 489754e..2963804 100644 --- a/crates/processing_core/src/lib.rs +++ b/crates/processing_core/src/lib.rs @@ -1,6 +1,7 @@ pub mod config; pub mod constants; pub mod error; +pub mod noise; use std::cell::RefCell; use std::sync::OnceLock; diff --git a/crates/processing_core/src/noise.rs b/crates/processing_core/src/noise.rs new file mode 100644 index 0000000..1dc52b6 --- /dev/null +++ b/crates/processing_core/src/noise.rs @@ -0,0 +1,101 @@ +//! Processing's `noise()`: value noise over a table of 4096 random values with +//! cosine smoothing, summed over octaves. A port of `PApplet.noise`, including +//! its half-degree cosine table, so sketches look the way they do in Processing. + +use rand::rngs::StdRng; +use rand::{RngExt, SeedableRng}; + +const YWRAPB: u32 = 4; +const YWRAP: u32 = 1 << YWRAPB; +const ZWRAPB: u32 = 8; +const ZWRAP: u32 = 1 << ZWRAPB; +const SIZE: u32 = 4095; + +pub struct Noise { + table: Vec, + octaves: u32, + falloff: f32, +} + +impl Noise { + pub fn new(seed: u64) -> Self { + let mut rng = StdRng::seed_from_u64(seed); + Self { + table: (0..=SIZE).map(|_| rng.random::()).collect(), + octaves: 4, + falloff: 0.5, + } + } + + /// Reseeds the table, keeping the detail settings. + pub fn seed(&mut self, seed: u64) { + let mut rng = StdRng::seed_from_u64(seed); + self.table.iter_mut().for_each(|v| *v = rng.random()); + } + + /// Non-positive values leave the current setting unchanged, as in Processing. + pub fn detail(&mut self, octaves: i32, falloff: Option) { + if octaves > 0 { + self.octaves = octaves as u32; + } + if let Some(falloff) = falloff.filter(|f| *f > 0.0) { + self.falloff = falloff; + } + } + + pub fn get(&self, x: f32, y: f32, z: f32) -> f32 { + let (x, y, z) = (x.abs(), y.abs(), z.abs()); + let (mut xi, mut yi, mut zi) = (x as u32, y as u32, z as u32); + let (mut xf, mut yf, mut zf) = (x - xi as f32, y - yi as f32, z - zi as f32); + let at = |i: u32| self.table[(i & SIZE) as usize]; + + let mut r = 0.0; + let mut amplitude = 0.5; + for _ in 0..self.octaves { + let mut of = xi + .wrapping_add(yi.wrapping_shl(YWRAPB)) + .wrapping_add(zi.wrapping_shl(ZWRAPB)); + let rxf = smooth(xf); + let ryf = smooth(yf); + + let mut n1 = at(of); + n1 += rxf * (at(of.wrapping_add(1)) - n1); + let mut n2 = at(of.wrapping_add(YWRAP)); + n2 += rxf * (at(of.wrapping_add(YWRAP + 1)) - n2); + n1 += ryf * (n2 - n1); + + of = of.wrapping_add(ZWRAP); + n2 = at(of); + n2 += rxf * (at(of.wrapping_add(1)) - n2); + let mut n3 = at(of.wrapping_add(YWRAP)); + n3 += rxf * (at(of.wrapping_add(YWRAP + 1)) - n3); + n2 += ryf * (n3 - n2); + + n1 += smooth(zf) * (n2 - n1); + + r += n1 * amplitude; + amplitude *= self.falloff; + (xi, yi, zi) = (xi.wrapping_shl(1), yi.wrapping_shl(1), zi.wrapping_shl(1)); + (xf, yf, zf) = (xf * 2.0, yf * 2.0, zf * 2.0); + if xf >= 1.0 { + xi = xi.wrapping_add(1); + xf -= 1.0; + } + if yf >= 1.0 { + yi = yi.wrapping_add(1); + yf -= 1.0; + } + if zf >= 1.0 { + zi = zi.wrapping_add(1); + zf -= 1.0; + } + } + r + } +} + +/// `0.5 * (1 - cos(t * PI))`, quantized to half a degree like Processing's `cosLUT`. +fn smooth(t: f32) -> f32 { + let half_degrees = (t * 360.0) as u32 % 720; + 0.5 * (1.0 - (half_degrees as f32 * 0.5).to_radians().cos()) +} diff --git a/crates/processing_pyo3/processing_py/math.py b/crates/processing_pyo3/processing_py/math.py index 2ae3d90..de9bbf8 100644 --- a/crates/processing_pyo3/processing_py/math.py +++ b/crates/processing_pyo3/processing_py/math.py @@ -1,5 +1,6 @@ """Processing math methods and vector/quaternion types.""" import math as _math +import random as _random from .processing_py import math as _native_math # extend native math so glob imports work @@ -131,3 +132,41 @@ def dist(*args): dx, dy, dz = x2 - x1, y2 - y1, z2 - z1 return sqrt(dx * dx + dy * dy + dz * dz) raise TypeError(f"dist() takes 4 or 6 arguments ({len(args)} given)") + + +def random(low, high=None): + """`random(high)` is in [0, high), `random(low, high)` in [low, high).""" + if high is None: + low, high = 0.0, low + return low + _random.random() * (high - low) + + +def random_gaussian(): + return _random.gauss(0.0, 1.0) + + +def random_seed(seed): + _random.seed(seed) + + +def bezier_point(a, b, c, d, t): + u = 1.0 - t + return a * u * u * u + 3 * b * t * u * u + 3 * c * t * t * u + d * t * t * t + + +def bezier_tangent(a, b, c, d, t): + return 3 * t * t * (-a + 3 * b - 3 * c + d) + 6 * t * (a - 2 * b + c) + 3 * (-a + b) + + +# Catmull-Rom, matching `curve()` (Processing's curveTightness(0)) +def curve_point(a, b, c, d, t): + return 0.5 * ( + 2 * b + + (-a + c) * t + + (2 * a - 5 * b + 4 * c - d) * t * t + + (-a + 3 * b - 3 * c + d) * t * t * t + ) + + +def curve_tangent(a, b, c, d, t): + return 0.5 * ((-a + c) + 2 * (2 * a - 5 * b + 4 * c - d) * t + 3 * (-a + 3 * b - 3 * c + d) * t * t) diff --git a/crates/processing_pyo3/src/color.rs b/crates/processing_pyo3/src/color.rs index 6aef707..a6ed297 100644 --- a/crates/processing_pyo3/src/color.rs +++ b/crates/processing_pyo3/src/color.rs @@ -459,6 +459,62 @@ impl ColorLike { } } +/// A channel read back in the range `color_mode` set for its space, or the +/// space's native range when the mode is a different space. +fn channel_in_mode(mode: &ColorMode, space: ColorSpace, value: f32, ch: usize) -> f32 { + if mode.space == space { + value / space.default_maxes()[ch] * mode.max[ch] + } else { + value + } +} + +pub fn red(color: Color, mode: &ColorMode) -> f32 { + channel_in_mode(mode, ColorSpace::Srgb, Srgba::from(color).red, 0) +} + +pub fn green(color: Color, mode: &ColorMode) -> f32 { + channel_in_mode(mode, ColorSpace::Srgb, Srgba::from(color).green, 1) +} + +pub fn blue(color: Color, mode: &ColorMode) -> f32 { + channel_in_mode(mode, ColorSpace::Srgb, Srgba::from(color).blue, 2) +} + +pub fn alpha(color: Color, mode: &ColorMode) -> f32 { + color.alpha() * mode.max[3] +} + +pub fn hue(color: Color, mode: &ColorMode) -> f32 { + channel_in_mode(mode, ColorSpace::Hsv, Hsva::from(color).hue, 0) +} + +pub fn saturation(color: Color, mode: &ColorMode) -> f32 { + channel_in_mode(mode, ColorSpace::Hsv, Hsva::from(color).saturation, 1) +} + +pub fn brightness(color: Color, mode: &ColorMode) -> f32 { + channel_in_mode(mode, ColorSpace::Hsv, Hsva::from(color).value, 2) +} + +/// Interpolates in the `color_mode` space, as Processing's `lerpColor` does +/// for RGB and HSB. +pub fn lerp_color(from: Color, to: Color, amount: f32, mode: &ColorMode) -> Color { + let in_space = |c: Color| match mode.space { + ColorSpace::Srgb => Color::Srgba(c.into()), + ColorSpace::Linear => Color::LinearRgba(c.into()), + ColorSpace::Hsl => Color::Hsla(c.into()), + ColorSpace::Hsv => Color::Hsva(c.into()), + ColorSpace::Hwb => Color::Hwba(c.into()), + ColorSpace::Oklab => Color::Oklaba(c.into()), + ColorSpace::Oklch => Color::Oklcha(c.into()), + ColorSpace::Lab => Color::Laba(c.into()), + ColorSpace::Lch => Color::Lcha(c.into()), + ColorSpace::Xyz => Color::Xyza(c.into()), + }; + in_space(from).mix(&in_space(to), amount.clamp(0.0, 1.0)) +} + fn parse_hex(s: &str) -> PyResult { Srgba::hex(s) .map(Color::Srgba) diff --git a/crates/processing_pyo3/src/constants.rs b/crates/processing_pyo3/src/constants.rs index 5a5673c..a29bbff 100644 --- a/crates/processing_pyo3/src/constants.rs +++ b/crates/processing_pyo3/src/constants.rs @@ -44,6 +44,8 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { add!(m, WORD, CHAR); add!(m, NEAREST, CLAMP, REPEAT, MIRROR); add!(m, SRGB, LINEAR, HSL, HSV, HWB, OKLAB, OKLCH, LAB, LCH, XYZ); + add!(m, RGB, HSB); + add!(m, ARROW, CROSS, HAND, MOVE, TEXT, WAIT); add!( m, PBR_NEUTRAL, diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index ca1265c..d805220 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -864,6 +864,10 @@ impl Drop for Graphics { } impl Graphics { + pub(crate) fn current_color_mode(&self) -> PyResult { + graphics_get_color_mode(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + /// Create an offscreen graphics buffer in the already-running app (no window, /// no `init`). Backs `create_graphics()` / `new_offscreen()`. The caller must /// ensure the app exists (i.e. `size()` was called first). @@ -1162,6 +1166,93 @@ impl Graphics { .map(crate::color::PyColor::from) } + pub fn red(&self, color: crate::color::ColorLike) -> PyResult { + Ok(crate::color::red( + color.into_color()?, + &self.current_color_mode()?, + )) + } + + pub fn green(&self, color: crate::color::ColorLike) -> PyResult { + Ok(crate::color::green( + color.into_color()?, + &self.current_color_mode()?, + )) + } + + pub fn blue(&self, color: crate::color::ColorLike) -> PyResult { + Ok(crate::color::blue( + color.into_color()?, + &self.current_color_mode()?, + )) + } + + pub fn alpha(&self, color: crate::color::ColorLike) -> PyResult { + Ok(crate::color::alpha( + color.into_color()?, + &self.current_color_mode()?, + )) + } + + pub fn hue(&self, color: crate::color::ColorLike) -> PyResult { + Ok(crate::color::hue( + color.into_color()?, + &self.current_color_mode()?, + )) + } + + pub fn saturation(&self, color: crate::color::ColorLike) -> PyResult { + Ok(crate::color::saturation( + color.into_color()?, + &self.current_color_mode()?, + )) + } + + pub fn brightness(&self, color: crate::color::ColorLike) -> PyResult { + Ok(crate::color::brightness( + color.into_color()?, + &self.current_color_mode()?, + )) + } + + pub fn lerp_color( + &self, + from: crate::color::ColorLike, + to: crate::color::ColorLike, + amount: f32, + ) -> PyResult { + let color = crate::color::lerp_color( + from.into_color()?, + to.into_color()?, + amount, + &self.current_color_mode()?, + ); + Ok(color.into()) + } + + /// `ARROW`, `CROSS`, `HAND`, `MOVE`, `TEXT` or `WAIT`. Also shows a hidden cursor. + #[pyo3(signature = (kind=constants::ARROW))] + pub fn cursor(&self, kind: &str) -> PyResult<()> { + use bevy::window::SystemCursorIcon; + let icon = match kind { + constants::ARROW => SystemCursorIcon::Default, + constants::CROSS => SystemCursorIcon::Crosshair, + constants::HAND => SystemCursorIcon::Pointer, + constants::MOVE => SystemCursorIcon::Move, + constants::TEXT => SystemCursorIcon::Text, + constants::WAIT => SystemCursorIcon::Wait, + _ => return Err(PyValueError::new_err(format!("unknown cursor: {kind:?}"))), + }; + input_set_cursor_visible(self.surface.entity, true) + .and_then(|_| input_set_cursor_icon(self.surface.entity, icon)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn no_cursor(&self) -> PyResult<()> { + input_set_cursor_visible(self.surface.entity, false) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + #[pyo3(signature = (*args))] pub fn background(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { let color = extract_color_with_mode( diff --git a/crates/processing_pyo3/src/host.rs b/crates/processing_pyo3/src/host.rs index fb7071f..7d172c7 100644 --- a/crates/processing_pyo3/src/host.rs +++ b/crates/processing_pyo3/src/host.rs @@ -9,8 +9,9 @@ //! 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 +//! windows, `loop()`/`no_loop()` state, the tracked-globals cache, event +//! history, noise field, frame counter and frame timing are otherwise +//! process-global. The host enters a sketch's //! context before touching it and exits after. use std::collections::HashMap; @@ -23,7 +24,7 @@ use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::PyBytes; -use crate::{LAST_GLOBALS, LOOP_STATE, LoopState}; +use crate::{EVENT_STATE, EventState, LAST_GLOBALS, LOOP_STATE, LoopState, NOISE, new_noise}; fn err(e: impl std::fmt::Display) -> PyErr { PyRuntimeError::new_err(format!("{e}")) @@ -41,7 +42,10 @@ pub struct SketchContext { windows: Option>, loop_state: LoopState, last_globals: HashMap<&'static str, Py>, + event_state: EventState, + noise: processing_core::noise::Noise, frame_count: u32, + frame_timing: processing_render::time::FrameTiming, entered: bool, } @@ -51,7 +55,10 @@ pub(crate) fn context_new() -> SketchContext { windows: None, loop_state: LoopState::default(), last_globals: HashMap::new(), + event_state: EventState::default(), + noise: new_noise(), frame_count: 0, + frame_timing: Default::default(), entered: false, } } @@ -77,8 +84,11 @@ pub(crate) fn context_enter(module: &Bound<'_, PyModule>, ctx: &mut SketchContex 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)); + EVENT_STATE.with(|s| s.set(ctx.event_state)); + NOISE.with(|n| std::mem::swap(&mut *n.borrow_mut(), &mut ctx.noise)); // The app may not exist yet (first sketch, before `size()`). let _ = set_frame_count(ctx.frame_count); + let _ = processing_render::set_frame_timing(ctx.frame_timing); ctx.entered = true; Ok(()) } @@ -91,7 +101,10 @@ pub(crate) fn context_exit(module: &Bound<'_, PyModule>, ctx: &mut SketchContext 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.event_state = EVENT_STATE.with(|s| s.replace(EventState::default())); + NOISE.with(|n| std::mem::swap(&mut *n.borrow_mut(), &mut ctx.noise)); ctx.frame_count = frame_count().unwrap_or(ctx.frame_count); + ctx.frame_timing = processing_render::frame_timing().unwrap_or(ctx.frame_timing); ctx.entered = false; Ok(()) } diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 15e0820..ee56d52 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -117,6 +117,9 @@ use std::env; pub(crate) struct LoopState { looping: bool, redraw_requested: bool, + exit_requested: bool, + /// Set by `frame_rate(fps)`. `run()` sleeps out the rest of each frame. + frame_interval: Option, } impl Default for LoopState { @@ -124,6 +127,8 @@ impl Default for LoopState { Self { looping: true, redraw_requested: false, + exit_requested: false, + frame_interval: None, } } } @@ -140,6 +145,16 @@ thread_local! { pub(crate) static LAST_GLOBALS: RefCell>> = RefCell::new(HashMap::new()); pub(crate) static LOOP_STATE: Cell = Cell::new(LoopState::default()); pub(crate) static EVENT_STATE: Cell = Cell::new(EventState::default()); + pub(crate) static NOISE: RefCell = RefCell::new(new_noise()); +} + +/// A sketch's noise field: randomly seeded, fixed under CI so captures are stable. +pub(crate) fn new_noise() -> processing_core::noise::Noise { + processing_core::noise::Noise::new(if processing_render::ci::enabled() { + 0 + } else { + rand::random() + }) } fn update_loop_state(f: impl FnOnce(&mut LoopState)) { @@ -207,6 +222,14 @@ fn sync_globals(module: &Bound<'_, PyModule>, globals: &Bound<'_, PyAny>) -> PyR Ok(()) } +/// The main canvas's color mode, or the default before `size()`. +fn current_color_mode(module: &Bound<'_, PyModule>) -> PyResult { + match get_graphics(module)? { + Some(graphics) => graphics.current_color_mode(), + None => Ok(color::ColorMode::default()), + } +} + fn mouse_event(module: &Bound<'_, PyModule>) -> PyResult> { // the graphics borrow has to end before the callback runs, which may draw let event = { @@ -976,6 +999,75 @@ pub mod processing_py { Ok(packages) } + /// Ends `run()` once the current `draw()` returns. Processing's `exit()`, + /// renamed so it doesn't shadow Python's builtin. + #[pyfunction] + fn exit_sketch() -> PyResult<()> { + update_loop_state(|s| s.exit_requested = true); + Ok(()) + } + + /// `frame_rate()` returns the measured frames per second. `frame_rate(fps)` + /// caps how fast `run()` draws (the display's vsync still applies above it). + #[pyfunction] + #[pyo3(signature = (fps=None))] + fn frame_rate(py: Python<'_>, fps: Option) -> PyResult> { + match fps { + Some(fps) if fps > 0.0 => { + let interval = std::time::Duration::from_secs_f32(1.0 / fps); + update_loop_state(|s| s.frame_interval = Some(interval)); + Ok(py.None()) + } + Some(fps) => Err(pyo3::exceptions::PyValueError::new_err(format!( + "frame_rate must be positive, got {fps}" + ))), + None => { + let rate = processing::prelude::frame_rate() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(rate.into_pyobject(py)?.into_any().unbind()) + } + } + } + + /// Milliseconds since the sketch started. + #[pyfunction] + fn millis() -> PyResult { + let elapsed = processing::prelude::elapsed_time() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok((elapsed as f64 * 1000.0) as u64) + } + + /// Processing's Perlin noise, in [0, 1). + #[pyfunction] + #[pyo3(signature = (x, y=0.0, z=0.0))] + fn noise(x: f32, y: f32, z: f32) -> f32 { + NOISE.with(|n| n.borrow().get(x, y, z)) + } + + /// `octaves` (default 4) and `falloff` (default 0.5) per octave. + #[pyfunction] + #[pyo3(signature = (octaves, falloff=None))] + fn noise_detail(octaves: i32, falloff: Option) { + NOISE.with(|n| n.borrow_mut().detail(octaves, falloff)); + } + + #[pyfunction] + fn noise_seed(seed: u64) { + NOISE.with(|n| n.borrow_mut().seed(seed)); + } + + #[pyfunction] + #[pyo3(pass_module, signature = (kind=processing::prelude::constants::ARROW))] + fn cursor(module: &Bound<'_, PyModule>, kind: &str) -> PyResult<()> { + graphics!(module).cursor(kind) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn no_cursor(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).no_cursor() + } + #[pyfunction] #[pyo3(pass_module)] fn window_title(module: &Bound<'_, PyModule>, title: &str) -> PyResult<()> { @@ -1150,9 +1242,15 @@ pub mod processing_py { let setup_fn = locals.get_item("setup").ok(); let mut draw_fn = locals.get_item("draw").ok(); + update_loop_state(|s| s.exit_requested = false); + let exit_requested = || LOOP_STATE.with(|s| s.get().exit_requested); + if let Some(ref setup) = setup_fn { setup.call0()?; } + if exit_requested() { + return Ok(()); + } ensure_graphics(module)?; @@ -1188,6 +1286,9 @@ pub mod processing_py { } } dispatch_event_callbacks(module, &locals)?; + if exit_requested() { + break; + } std::thread::sleep(std::time::Duration::from_millis(16)); } @@ -1230,6 +1331,9 @@ pub mod processing_py { } dispatch_event_callbacks(module, &locals)?; + if exit_requested() { + break; + } let should_draw = first_frame || LOOP_STATE.with(|s| { @@ -1242,6 +1346,7 @@ pub mod processing_py { continue; } first_frame = false; + let frame_start = std::time::Instant::now(); processing::prelude::advance_frame_count() .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; @@ -1263,6 +1368,12 @@ pub mod processing_py { } update_loop_state(|s| s.redraw_requested = false); + if exit_requested() { + break; + } + if let Some(interval) = LOOP_STATE.with(|s| s.get().frame_interval) { + std::thread::sleep(interval.saturating_sub(frame_start.elapsed())); + } } Ok(()) @@ -1483,6 +1594,81 @@ pub mod processing_py { ) } + #[pyfunction] + #[pyo3(pass_module)] + fn red(module: &Bound<'_, PyModule>, color: super::color::ColorLike) -> PyResult { + Ok(super::color::red( + color.into_color()?, + ¤t_color_mode(module)?, + )) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn green(module: &Bound<'_, PyModule>, color: super::color::ColorLike) -> PyResult { + Ok(super::color::green( + color.into_color()?, + ¤t_color_mode(module)?, + )) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn blue(module: &Bound<'_, PyModule>, color: super::color::ColorLike) -> PyResult { + Ok(super::color::blue( + color.into_color()?, + ¤t_color_mode(module)?, + )) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn alpha(module: &Bound<'_, PyModule>, color: super::color::ColorLike) -> PyResult { + Ok(super::color::alpha( + color.into_color()?, + ¤t_color_mode(module)?, + )) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn hue(module: &Bound<'_, PyModule>, color: super::color::ColorLike) -> PyResult { + Ok(super::color::hue( + color.into_color()?, + ¤t_color_mode(module)?, + )) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn saturation(module: &Bound<'_, PyModule>, color: super::color::ColorLike) -> PyResult { + Ok(super::color::saturation( + color.into_color()?, + ¤t_color_mode(module)?, + )) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn brightness(module: &Bound<'_, PyModule>, color: super::color::ColorLike) -> PyResult { + Ok(super::color::brightness( + color.into_color()?, + ¤t_color_mode(module)?, + )) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn lerp_color( + module: &Bound<'_, PyModule>, + from: super::color::ColorLike, + to: super::color::ColorLike, + amount: f32, + ) -> PyResult { + let mode = current_color_mode(module)?; + Ok(super::color::lerp_color(from.into_color()?, to.into_color()?, amount, &mode).into()) + } + #[pyfunction(name = "color")] #[pyo3(pass_module, signature = (*args))] fn create_color( diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index c01747b..f9fc08b 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -80,6 +80,10 @@ impl Plugin for ProcessingRenderPlugin { let config = app.world().resource::().clone(); app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + // sketch time is wall time: no 250ms cap on a slow or paused frame + app.insert_resource(Time::::from_max_delta(std::time::Duration::MAX)); let has_sketch_file = config .get(ConfigKey::SketchFileName) @@ -2245,6 +2249,24 @@ pub fn set_frame_count(n: u32) -> error::Result<()> { }) } +pub fn frame_timing() -> error::Result { + app_mut(|app| { + Ok(app + .world_mut() + .run_system_cached(time::frame_timing) + .unwrap()) + }) +} + +pub fn set_frame_timing(timing: time::FrameTiming) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(time::set_frame_timing, timing) + .unwrap(); + Ok(()) + }) +} + pub fn advance_frame_count() -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -2254,6 +2276,10 @@ pub fn advance_frame_count() -> error::Result<()> { }) } +pub fn frame_rate() -> error::Result { + app_mut(|app| Ok(app.world_mut().run_system_cached(time::frame_rate).unwrap())) +} + pub fn delta_time() -> error::Result { app_mut(|app| Ok(app.world_mut().run_system_cached(time::delta_secs).unwrap())) } diff --git a/crates/processing_render/src/time.rs b/crates/processing_render/src/time.rs index 4c7d156..b31b98d 100644 --- a/crates/processing_render/src/time.rs +++ b/crates/processing_render/src/time.rs @@ -12,12 +12,73 @@ pub fn set_frame_count(In(n): In, mut frame: ResMut) { frame.0 = n; } -pub fn advance_frame_count(mut frame: ResMut) { +/// Frames per second, smoothed like Processing's `frameRate`: starts at 60 and +/// moves 10% toward the instantaneous rate each frame. +#[derive(Resource, Debug, Clone, Copy)] +pub struct ProcessingFrameRate(pub f32); + +impl Default for ProcessingFrameRate { + fn default() -> Self { + Self(60.0) + } +} + +/// Time between Processing frames; `Time::delta` only spans the last of a frame's `app.update()`s. +#[derive(Resource, Default, Debug, Clone, Copy)] +pub struct ProcessingFrameTime { + last_elapsed: Option, + delta: f32, +} + +pub fn advance_frame_count( + mut frame: ResMut, + mut rate: ResMut, + mut frame_time: ResMut, + time: Option>, +) { frame.0 = frame.0.wrapping_add(1); + let Some(elapsed) = time.map(|t| t.elapsed_secs_f64()) else { + return; + }; + let dt = frame_time + .last_elapsed + .map_or(0.0, |last| (elapsed - last) as f32); + frame_time.last_elapsed = Some(elapsed); + frame_time.delta = dt; + if dt > 0.0 { + rate.0 = rate.0 * 0.9 + 0.1 / dt; + } +} + +/// The frame rate and frame time of one sketch, for hosts that drive several over one app. +#[derive(Default, Debug, Clone, Copy)] +pub struct FrameTiming { + rate: ProcessingFrameRate, + time: ProcessingFrameTime, +} + +pub fn frame_timing(rate: Res, time: Res) -> FrameTiming { + FrameTiming { + rate: *rate, + time: *time, + } +} + +pub fn set_frame_timing( + In(timing): In, + mut rate: ResMut, + mut time: ResMut, +) { + *rate = timing.rate; + *time = timing.time; +} + +pub fn frame_rate(rate: Res) -> f32 { + rate.0 } -pub fn delta_secs(time: Option>) -> f32 { - time.map(|t| t.delta_secs()).unwrap_or(0.0) +pub fn delta_secs(frame_time: Res) -> f32 { + frame_time.delta } pub fn elapsed_secs(time: Option>) -> f32 {