diff --git a/crates/processing_ffi/src/color.rs b/crates/processing_ffi/src/color.rs index 088f44d..59d8d78 100644 --- a/crates/processing_ffi/src/color.rs +++ b/crates/processing_ffi/src/color.rs @@ -1,35 +1,33 @@ use bevy::color::{LinearRgba, Srgba}; +use processing::prelude::color::{ColorMode, ColorSpace}; -/// A sRGB (?) color +/// A color with 4 float components and its color space. #[repr(C)] #[derive(Debug, Clone, Copy)] pub struct Color { - pub r: f32, - pub g: f32, - pub b: f32, + pub c1: f32, + pub c2: f32, + pub c3: f32, pub a: f32, + pub space: u8, } -impl From for bevy::color::Color { - fn from(color: Color) -> Self { - bevy::color::Color::srgba(color.r, color.g, color.b, color.a) +impl Color { + pub fn resolve(self, mode: &ColorMode) -> bevy::color::Color { + let c1 = mode.scale(self.c1, 0); + let c2 = mode.scale(self.c2, 1); + let c3 = mode.scale(self.c3, 2); + let ca = mode.scale(self.a, 3); + mode.space.color(c1, c2, c3, ca) } -} - -impl From for Color { - fn from(lin: LinearRgba) -> Self { - let srgb: Srgba = lin.into(); - srgb.into() - } -} -impl From for Color { - fn from(srgb: Srgba) -> Self { + pub fn from_linear(lin: LinearRgba) -> Self { Color { - r: srgb.red, - g: srgb.green, - b: srgb.blue, - a: srgb.alpha, + c1: lin.red, + c2: lin.green, + c3: lin.blue, + a: lin.alpha, + space: ColorSpace::Linear as u8, } } } diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index 4e78959..8bc9efe 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -173,7 +173,9 @@ pub extern "C" fn processing_background_color(graphics_id: u64, color: Color) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); error::check(|| { - graphics_record_command(graphics_entity, DrawCommand::BackgroundColor(color.into())) + let mode = graphics_get_color_mode(graphics_entity)?; + let color = color.resolve(&mode); + graphics_record_command(graphics_entity, DrawCommand::BackgroundColor(color)) }); } @@ -244,17 +246,46 @@ pub extern "C" fn processing_exit(exit_code: u8) { error::check(|| exit(exit_code)); } +/// Set the color mode for a graphics context. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_color_mode( + graphics_id: u64, + space: u8, + max1: f32, + max2: f32, + max3: f32, + max_alpha: f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + let space = processing::prelude::color::ColorSpace::from_u8(space).ok_or_else(|| { + processing::prelude::error::ProcessingError::InvalidArgument(format!( + "unknown color space: {space}" + )) + })?; + let mode = processing::prelude::color::ColorMode::new(space, max1, max2, max3, max_alpha); + graphics_set_color_mode(graphics_entity, mode) + }); +} + /// Set the fill color. /// /// SAFETY: /// - graphics_id is a valid ID returned from graphics_create. /// - This is called from the same thread as init. #[unsafe(no_mangle)] -pub extern "C" fn processing_set_fill(graphics_id: u64, r: f32, g: f32, b: f32, a: f32) { +pub extern "C" fn processing_set_fill(graphics_id: u64, color: Color) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - let color = bevy::color::Color::srgba(r, g, b, a); - error::check(|| graphics_record_command(graphics_entity, DrawCommand::Fill(color))); + error::check(|| { + let mode = graphics_get_color_mode(graphics_entity)?; + graphics_record_command(graphics_entity, DrawCommand::Fill(color.resolve(&mode))) + }); } /// Set the stroke color. @@ -263,11 +294,16 @@ pub extern "C" fn processing_set_fill(graphics_id: u64, r: f32, g: f32, b: f32, /// - graphics_id is a valid ID returned from graphics_create. /// - This is called from the same thread as init. #[unsafe(no_mangle)] -pub extern "C" fn processing_set_stroke_color(graphics_id: u64, r: f32, g: f32, b: f32, a: f32) { +pub extern "C" fn processing_set_stroke_color(graphics_id: u64, color: Color) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - let color = bevy::color::Color::srgba(r, g, b, a); - error::check(|| graphics_record_command(graphics_entity, DrawCommand::StrokeColor(color))); + error::check(|| { + let mode = graphics_get_color_mode(graphics_entity)?; + graphics_record_command( + graphics_entity, + DrawCommand::StrokeColor(color.resolve(&mode)), + ) + }); } /// Set the stroke weight. @@ -565,7 +601,7 @@ pub unsafe extern "C" fn processing_image_readback( unsafe { let buffer_slice = std::slice::from_raw_parts_mut(buffer, buffer_len); for (i, color) in colors.iter().enumerate() { - buffer_slice[i] = Color::from(*color); + buffer_slice[i] = Color::from_linear(*color); } } @@ -1154,9 +1190,12 @@ pub extern "C" fn processing_light_create_directional( ) -> u64 { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| light_create_directional(graphics_entity, color.into(), illuminance)) - .map(|e| e.to_bits()) - .unwrap_or(0) + error::check(|| { + let mode = graphics_get_color_mode(graphics_entity)?; + light_create_directional(graphics_entity, color.resolve(&mode), illuminance) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] @@ -1169,9 +1208,18 @@ pub extern "C" fn processing_light_create_point( ) -> u64 { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| light_create_point(graphics_entity, color.into(), intensity, range, radius)) - .map(|e| e.to_bits()) - .unwrap_or(0) + error::check(|| { + let mode = graphics_get_color_mode(graphics_entity)?; + light_create_point( + graphics_entity, + color.resolve(&mode), + intensity, + range, + radius, + ) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] @@ -1187,9 +1235,10 @@ pub extern "C" fn processing_light_create_spot( error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); error::check(|| { + let mode = graphics_get_color_mode(graphics_entity)?; light_create_spot( graphics_entity, - color.into(), + color.resolve(&mode), intensity, range, radius, diff --git a/crates/processing_pyo3/src/color.rs b/crates/processing_pyo3/src/color.rs index dda1ad3..6aef707 100644 --- a/crates/processing_pyo3/src/color.rs +++ b/crates/processing_pyo3/src/color.rs @@ -4,7 +4,91 @@ use bevy::color::{ }; use pyo3::{exceptions::PyTypeError, prelude::*, types::PyTuple}; -use crate::math::{PyVec4, PyVecIter, hash_f32}; +use crate::math::{PyVec3, PyVec4, PyVecIter, hash_f32}; + +pub use processing::prelude::color::{ColorMode, ColorSpace}; + +fn int_maxes(space: &ColorSpace) -> [f32; 4] { + match space { + ColorSpace::Srgb | ColorSpace::Linear => [255.0, 255.0, 255.0, 255.0], + ColorSpace::Hsl | ColorSpace::Hsv | ColorSpace::Hwb => [360.0, 100.0, 100.0, 255.0], + ColorSpace::Oklch => [100.0, 100.0, 360.0, 255.0], + ColorSpace::Oklab => [100.0, 100.0, 100.0, 255.0], + ColorSpace::Lab => [100.0, 100.0, 100.0, 255.0], + ColorSpace::Lch => [100.0, 100.0, 360.0, 255.0], + ColorSpace::Xyz => [100.0, 100.0, 100.0, 255.0], + } +} + +/// Parse a Python int or float into an f32 for a given channel. +pub(crate) fn parse_numeric( + space: &ColorSpace, + obj: &Bound<'_, PyAny>, + ch: usize, +) -> PyResult { + if let Ok(v) = obj.extract::() { + let native = space.default_maxes(); + let imax = int_maxes(space); + return Ok(v as f32 / imax[ch] * native[ch]); + } + if let Ok(v) = obj.extract::() { + return Ok(v as f32); + } + Err(PyTypeError::new_err("expected int or float")) +} + +fn convert_channel(mode: &ColorMode, obj: &Bound<'_, PyAny>, ch: usize) -> PyResult { + let v = parse_numeric(&mode.space, obj, ch)?; + Ok(mode.scale(v, ch)) +} + +// Accepts a varags of color-like arguments and extracts a Color, applying the provided ColorMode. +pub(crate) fn extract_color_with_mode( + args: &Bound<'_, PyTuple>, + mode: &ColorMode, +) -> PyResult { + let space = mode.space; + let native = space.default_maxes(); + match args.len() { + 0 => Err(PyTypeError::new_err("expected at least 1 argument")), + 1 => { + let first = args.get_item(0)?; + if let Ok(c) = first.extract::>() { + return Ok(c.0); + } + if let Ok(s) = first.extract::() { + return parse_hex(&s); + } + if let Ok(v) = first.extract::>() { + return Ok(space.color(v.0.x, v.0.y, v.0.z, v.0.w)); + } + if let Ok(v) = first.extract::>() { + return Ok(space.color(v.0.x, v.0.y, v.0.z, native[3])); + } + let v = convert_channel(mode, &first, 0)?; + Ok(space.gray(v, native[3])) + } + 2 => { + let v = convert_channel(mode, &args.get_item(0)?, 0)?; + let a = convert_channel(mode, &args.get_item(1)?, 3)?; + Ok(space.gray(v, a)) + } + 3 => { + let c1 = convert_channel(mode, &args.get_item(0)?, 0)?; + let c2 = convert_channel(mode, &args.get_item(1)?, 1)?; + let c3 = convert_channel(mode, &args.get_item(2)?, 2)?; + Ok(space.color(c1, c2, c3, native[3])) + } + 4 => { + let c1 = convert_channel(mode, &args.get_item(0)?, 0)?; + let c2 = convert_channel(mode, &args.get_item(1)?, 1)?; + let c3 = convert_channel(mode, &args.get_item(2)?, 2)?; + let ca = convert_channel(mode, &args.get_item(3)?, 3)?; + Ok(space.color(c1, c2, c3, ca)) + } + _ => Err(PyTypeError::new_err("expected 1-4 arguments")), + } +} #[pyclass(name = "Color", from_py_object)] #[derive(Clone, Debug)] @@ -28,20 +112,6 @@ impl From for PyColor { } } -fn extract_component(obj: &Bound<'_, PyAny>) -> PyResult { - if let Ok(v) = obj.extract::() { - return Ok(v as f32 / 255.0); - } - if let Ok(v) = obj.extract::() { - return Ok(v as f32); - } - Err(PyTypeError::new_err("expected int or float")) -} - -fn to_srgba(color: &Color) -> Srgba { - color.to_srgba() -} - fn components(color: &Color) -> [f32; 4] { use bevy::color::ColorToComponents; match *color { @@ -80,42 +150,7 @@ impl PyColor { #[new] #[pyo3(signature = (*args))] pub fn py_new(args: &Bound<'_, PyTuple>) -> PyResult { - match args.len() { - 0 => Err(PyTypeError::new_err("Color requires at least 1 argument")), - 1 => { - let first = args.get_item(0)?; - if let Ok(c) = first.extract::>() { - return Ok(Self(c.0)); - } - if let Ok(s) = first.extract::() { - return Ok(Self(parse_hex(&s)?)); - } - if let Ok(v) = first.extract::>() { - return Ok(Self(Color::srgba(v.0.x, v.0.y, v.0.z, v.0.w))); - } - let v = extract_component(&first)?; - Ok(Self(Color::srgba(v, v, v, 1.0))) - } - 2 => { - let v = extract_component(&args.get_item(0)?)?; - let a = extract_component(&args.get_item(1)?)?; - Ok(Self(Color::srgba(v, v, v, a))) - } - 3 => { - let r = extract_component(&args.get_item(0)?)?; - let g = extract_component(&args.get_item(1)?)?; - let b = extract_component(&args.get_item(2)?)?; - Ok(Self(Color::srgba(r, g, b, 1.0))) - } - 4 => { - let r = extract_component(&args.get_item(0)?)?; - let g = extract_component(&args.get_item(1)?)?; - let b = extract_component(&args.get_item(2)?)?; - let a = extract_component(&args.get_item(3)?)?; - Ok(Self(Color::srgba(r, g, b, a))) - } - _ => Err(PyTypeError::new_err("Color takes 1-4 arguments")), - } + extract_color_with_mode(args, &ColorMode::default()).map(Self) } #[staticmethod] @@ -225,33 +260,33 @@ impl PyColor { #[getter] fn r(&self) -> f32 { - to_srgba(&self.0).red + self.0.to_srgba().red } #[setter] fn set_r(&mut self, val: f32) { - let mut s = to_srgba(&self.0); + let mut s = self.0.to_srgba(); s.red = val; self.0 = Color::Srgba(s); } #[getter] fn g(&self) -> f32 { - to_srgba(&self.0).green + self.0.to_srgba().green } #[setter] fn set_g(&mut self, val: f32) { - let mut s = to_srgba(&self.0); + let mut s = self.0.to_srgba(); s.green = val; self.0 = Color::Srgba(s); } #[getter] fn b(&self) -> f32 { - to_srgba(&self.0).blue + self.0.to_srgba().blue } #[setter] fn set_b(&mut self, val: f32) { - let mut s = to_srgba(&self.0); + let mut s = self.0.to_srgba(); s.blue = val; self.0 = Color::Srgba(s); } @@ -266,7 +301,7 @@ impl PyColor { } fn to_hex(&self) -> String { - to_srgba(&self.0).to_hex() + self.0.to_srgba().to_hex() } fn with_alpha(&self, a: f32) -> Self { @@ -367,12 +402,12 @@ impl PyColor { fn __eq__(&self, other: &Self) -> bool { // Compare in sRGBA so colors in different spaces can be equal - to_srgba(&self.0) == to_srgba(&other.0) + self.0.to_srgba() == other.0.to_srgba() } fn __hash__(&self) -> u64 { // Hash in sRGBA so equal colors hash the same regardless of space - let s = to_srgba(&self.0); + let s = self.0.to_srgba(); let mut hasher = std::collections::hash_map::DefaultHasher::new(); hash_f32(s.red, &mut hasher); hash_f32(s.green, &mut hasher); @@ -424,10 +459,6 @@ impl ColorLike { } } -pub(crate) fn extract_color(args: &Bound<'_, PyTuple>) -> PyResult { - PyColor::py_new(args).map(|c| c.0) -} - fn parse_hex(s: &str) -> PyResult { Srgba::hex(s) .map(Color::Srgba) @@ -441,7 +472,7 @@ mod tests { #[test] fn test_color_from_srgba() { let c = PyColor(Color::srgba(1.0, 0.0, 0.5, 1.0)); - let s = to_srgba(&c.0); + let s = c.0.to_srgba(); assert!((s.red - 1.0).abs() < 1e-6); assert!((s.green - 0.0).abs() < 1e-6); assert!((s.blue - 0.5).abs() < 1e-6); @@ -451,7 +482,7 @@ mod tests { #[test] fn test_hex_roundtrip() { let c = parse_hex("#FF00FF").unwrap(); - let s = to_srgba(&c); + let s = c.to_srgba(); assert!((s.red - 1.0).abs() < 0.01); assert!((s.green - 0.0).abs() < 0.01); assert!((s.blue - 1.0).abs() < 0.01); @@ -461,7 +492,7 @@ mod tests { #[test] fn test_hex_with_alpha() { let c = parse_hex("#FF000080").unwrap(); - let s = to_srgba(&c); + let s = c.to_srgba(); assert!((s.red - 1.0).abs() < 0.01); assert!((s.alpha - 128.0 / 255.0).abs() < 0.01); } @@ -471,7 +502,7 @@ mod tests { let a = PyColor(Color::srgba(0.0, 0.0, 0.0, 1.0)); let b = PyColor(Color::srgba(1.0, 1.0, 1.0, 1.0)); let mid = a.mix(&b, 0.5); - let s = to_srgba(&mid.0); + let s = mid.0.to_srgba(); assert!((s.red - 0.5).abs() < 0.05); assert!((s.green - 0.5).abs() < 0.05); assert!((s.blue - 0.5).abs() < 0.05); @@ -482,8 +513,8 @@ mod tests { let c = PyColor(Color::srgba(0.5, 0.5, 0.5, 1.0)); let lighter = c.lighter(0.1); let darker = c.darker(0.1); - let sl = to_srgba(&lighter.0); - let sd = to_srgba(&darker.0); + let sl = lighter.0.to_srgba(); + let sd = darker.0.to_srgba(); assert!(sl.red > sd.red || sl.green > sd.green || sl.blue > sd.blue); } @@ -514,7 +545,7 @@ mod tests { #[test] fn test_hsla_roundtrip() { let c = PyColor::hsla(0.0, 1.0, 0.5, 1.0); - let s = to_srgba(&c.0); + let s = c.0.to_srgba(); assert!((s.red - 1.0).abs() < 0.01); assert!(s.green < 0.01); assert!(s.blue < 0.01); diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 671c195..92a2144 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -1,3 +1,10 @@ +use crate::color::{ColorMode, extract_color_with_mode}; +use crate::color::{ColorMode, extract_color_with_mode}; +use crate::glfw::GlfwContext; +use crate::glfw::GlfwContext; +use crate::input; +use crate::math::{extract_vec2, extract_vec3, extract_vec4}; +use crate::math::{extract_vec2, extract_vec3, extract_vec4}; use bevy::{ color::{ColorToPacked, Srgba}, math::Vec4, @@ -11,10 +18,6 @@ use pyo3::{ types::{PyDict, PyTuple}, }; -use crate::glfw::GlfwContext; -use crate::input; -use crate::math::{extract_vec2, extract_vec3, extract_vec4}; - #[pyclass(unsendable)] pub struct Surface { pub(crate) entity: Entity, @@ -300,9 +303,23 @@ impl Graphics { } } + #[pyo3(signature = (*args))] + pub fn color(&self, args: &Bound<'_, PyTuple>) -> PyResult { + extract_color_with_mode( + args, + &graphics_get_color_mode(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + ) + .map(crate::color::PyColor::from) + } + #[pyo3(signature = (*args))] pub fn background(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { - let color = crate::color::extract_color(args)?; + let color = extract_color_with_mode( + args, + &graphics_get_color_mode(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + )?; graphics_record_command(self.entity, DrawCommand::BackgroundColor(color)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -314,7 +331,11 @@ impl Graphics { #[pyo3(signature = (*args))] pub fn fill(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { - let color = crate::color::extract_color(args)?; + let color = extract_color_with_mode( + args, + &graphics_get_color_mode(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + )?; graphics_record_command(self.entity, DrawCommand::Fill(color)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -326,7 +347,11 @@ impl Graphics { #[pyo3(signature = (*args))] pub fn stroke(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { - let color = crate::color::extract_color(args)?; + let color = extract_color_with_mode( + args, + &graphics_get_color_mode(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + )?; graphics_record_command(self.entity, DrawCommand::StrokeColor(color)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -451,7 +476,11 @@ impl Graphics { #[pyo3(signature = (*args))] pub fn emissive(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { - let color = crate::color::extract_color(args)?; + let color = extract_color_with_mode( + args, + &graphics_get_color_mode(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + )?; graphics_record_command(self.entity, DrawCommand::Emissive(color)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -493,6 +522,49 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + #[pyo3(name = "color_mode", signature = (mode, max1=None, max2=None, max3=None, max_alpha=None))] + pub fn set_color_mode<'py>( + &self, + mode: u8, + max1: Option<&Bound<'py, PyAny>>, + max2: Option<&Bound<'py, PyAny>>, + max3: Option<&Bound<'py, PyAny>>, + max_alpha: Option<&Bound<'py, PyAny>>, + ) -> PyResult<()> { + let space = crate::color::ColorSpace::from_u8(mode) + .ok_or_else(|| PyRuntimeError::new_err(format!("unknown color space: {mode}")))?; + let parse = + |obj: &Bound<'py, PyAny>, ch: usize| crate::color::parse_numeric(&space, obj, ch); + let new_mode = match (max1, max2, max3, max_alpha) { + // color_mode(MODE) + (None, _, _, _) => ColorMode::with_defaults(space), + // color_mode(MODE, max) + (Some(m), None, _, _) => ColorMode::with_uniform_max(space, parse(m, 0)?), + // color_mode(MODE, max1, max2, max3) + (Some(m1), Some(m2), Some(m3), None) => { + let defaults = space.default_maxes(); + ColorMode::new( + space, + parse(m1, 0)?, + parse(m2, 1)?, + parse(m3, 2)?, + defaults[3], + ) + } + // color_mode(MODE, max1, max2, max3, maxA) + (Some(m1), Some(m2), Some(m3), Some(ma)) => ColorMode::new( + space, + parse(m1, 0)?, + parse(m2, 1)?, + parse(m3, 2)?, + parse(ma, 3)?, + ), + _ => return Err(PyRuntimeError::new_err("expected 1, 2, 4, or 5 arguments")), + }; + graphics_set_color_mode(self.entity, new_mode) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + pub fn begin_draw(&self) -> PyResult<()> { graphics_begin_draw(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 3f2cae0..b3227c8 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -319,6 +319,28 @@ mod mewnala { #[pymodule_export] const F12: u32 = 301; + // color space constants for color_mode() + #[pymodule_export] + const SRGB: u8 = 0; + #[pymodule_export] + const LINEAR: u8 = 1; + #[pymodule_export] + const HSL: u8 = 2; + #[pymodule_export] + const HSV: u8 = 3; + #[pymodule_export] + const HWB: u8 = 4; + #[pymodule_export] + const OKLAB: u8 = 5; + #[pymodule_export] + const OKLCH: u8 = 6; + #[pymodule_export] + const LAB: u8 = 7; + #[pymodule_export] + const LCH: u8 = 8; + #[pymodule_export] + const XYZ: u8 = 9; + #[pymodule] mod math { use super::*; @@ -366,12 +388,6 @@ mod mewnala { #[pymodule_export] use crate::color::PyColor; - #[pyfunction] - #[pyo3(signature = (*args))] - fn color(args: &Bound<'_, PyTuple>) -> PyResult { - PyColor::py_new(args) - } - #[pyfunction] fn hex(s: &str) -> PyResult { PyColor::hex(s) @@ -704,6 +720,21 @@ mod mewnala { graphics!(module).draw_geometry(&*geometry.extract::>()?) } + #[pyfunction(name = "color")] + #[pyo3(pass_module, signature = (*args))] + fn create_color( + module: &Bound<'_, PyModule>, + args: &Bound<'_, PyTuple>, + ) -> PyResult { + match get_graphics(module)? { + Some(g) => g.color(args), + None => { + let mode = super::color::ColorMode::default(); + super::color::extract_color_with_mode(args, &mode).map(super::color::PyColor::from) + } + } + } + #[pyfunction] #[pyo3(pass_module, signature = (*args))] fn background(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { @@ -716,6 +747,21 @@ mod mewnala { } } + #[pyfunction] + #[pyo3(pass_module, signature = (mode, max1=None, max2=None, max3=None, max_alpha=None))] + fn color_mode<'py>( + module: &Bound<'py, PyModule>, + mode: u8, + max1: Option<&Bound<'py, PyAny>>, + max2: Option<&Bound<'py, PyAny>>, + max3: Option<&Bound<'py, PyAny>>, + max_alpha: Option<&Bound<'py, PyAny>>, + ) -> PyResult<()> { + let graphics = + get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; + graphics.set_color_mode(mode, max1, max2, max3, max_alpha) + } + #[pyfunction] #[pyo3(pass_module, signature = (*args))] fn fill(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { diff --git a/crates/processing_render/src/color.rs b/crates/processing_render/src/color.rs new file mode 100644 index 0000000..d912c78 --- /dev/null +++ b/crates/processing_render/src/color.rs @@ -0,0 +1,178 @@ +use bevy::color::{Color, Hsla, Hsva, Hwba, Laba, Lcha, LinearRgba, Oklaba, Oklcha, Srgba, Xyza}; +use bevy::prelude::Component; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ColorSpace { + Srgb = 0, + Linear = 1, + Hsl = 2, + Hsv = 3, + Hwb = 4, + Oklab = 5, + Oklch = 6, + Lab = 7, + Lch = 8, + Xyz = 9, +} + +impl ColorSpace { + pub fn from_u8(v: u8) -> Option { + match v { + 0 => Some(Self::Srgb), + 1 => Some(Self::Linear), + 2 => Some(Self::Hsl), + 3 => Some(Self::Hsv), + 4 => Some(Self::Hwb), + 5 => Some(Self::Oklab), + 6 => Some(Self::Oklch), + 7 => Some(Self::Lab), + 8 => Some(Self::Lch), + 9 => Some(Self::Xyz), + _ => None, + } + } + + pub fn default_maxes(&self) -> [f32; 4] { + match self { + Self::Srgb | Self::Linear | Self::Oklab | Self::Xyz => [1.0, 1.0, 1.0, 1.0], + Self::Hsl | Self::Hsv | Self::Hwb => [360.0, 1.0, 1.0, 1.0], + Self::Oklch => [1.0, 1.0, 360.0, 1.0], + Self::Lab => [100.0, 1.0, 1.0, 1.0], + Self::Lch => [100.0, 1.0, 360.0, 1.0], + } + } + + pub fn color(self, c1: f32, c2: f32, c3: f32, alpha: f32) -> Color { + match self { + Self::Srgb => Color::Srgba(Srgba::new(c1, c2, c3, alpha)), + Self::Linear => Color::LinearRgba(LinearRgba::new(c1, c2, c3, alpha)), + Self::Hsl => Color::Hsla(Hsla::new(c1, c2, c3, alpha)), + Self::Hsv => Color::Hsva(Hsva::new(c1, c2, c3, alpha)), + Self::Hwb => Color::Hwba(Hwba::new(c1, c2, c3, alpha)), + Self::Oklab => Color::Oklaba(Oklaba::new(c1, c2, c3, alpha)), + Self::Oklch => Color::Oklcha(Oklcha::new(c1, c2, c3, alpha)), + Self::Lab => Color::Laba(Laba::new(c1, c2, c3, alpha)), + Self::Lch => Color::Lcha(Lcha::new(c1, c2, c3, alpha)), + Self::Xyz => Color::Xyza(Xyza::new(c1, c2, c3, alpha)), + } + } + + pub fn gray(self, v: f32, alpha: f32) -> Color { + match self { + Self::Srgb | Self::Linear | Self::Xyz => self.color(v, v, v, alpha), + Self::Hsl | Self::Hsv | Self::Hwb => self.color(0.0, 0.0, v, alpha), + Self::Oklab | Self::Lab => self.color(v, 0.0, 0.0, alpha), + Self::Oklch | Self::Lch => self.color(v, 0.0, 0.0, alpha), + } + } +} + +#[derive(Debug, Clone, Copy, Component)] +pub struct ColorMode { + pub space: ColorSpace, + pub max: [f32; 4], +} + +impl Default for ColorMode { + fn default() -> Self { + Self::with_defaults(ColorSpace::Srgb) + } +} + +impl ColorMode { + pub fn new(space: ColorSpace, max1: f32, max2: f32, max3: f32, max_alpha: f32) -> Self { + Self { + space, + max: [max1, max2, max3, max_alpha], + } + } + + pub fn with_defaults(space: ColorSpace) -> Self { + Self { + space, + max: space.default_maxes(), + } + } + + pub fn with_uniform_max(space: ColorSpace, max: f32) -> Self { + Self { + space, + max: [max, max, max, max], + } + } + + /// Scale a raw float value for a given channel to the 0-1 normalized range. + pub fn scale(&self, value: f32, ch: usize) -> f32 { + let native = self.space.default_maxes(); + value / self.max[ch] * native[ch] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy::color::ColorToComponents; + + #[test] + fn test_srgb_color() { + let c = ColorSpace::Srgb.color(1.0, 0.0, 0.0, 1.0); + let s: Srgba = c.into(); + assert!((s.red - 1.0).abs() < 1e-6); + assert!(s.green.abs() < 1e-6); + } + + #[test] + fn test_hsl_color() { + let c = ColorSpace::Hsl.color(180.0, 0.5, 0.5, 1.0); + let h: Hsla = c.into(); + assert!((h.hue - 180.0).abs() < 0.5); + assert!((h.saturation - 0.5).abs() < 0.01); + assert!((h.lightness - 0.5).abs() < 0.01); + } + + #[test] + fn test_hsv_red() { + let c = ColorSpace::Hsv.color(0.0, 1.0, 1.0, 1.0); + let s: Srgba = c.into(); + assert!((s.red - 1.0).abs() < 0.01); + assert!(s.green < 0.01); + } + + #[test] + fn test_gray_srgb() { + let c = ColorSpace::Srgb.gray(0.5, 1.0); + let s: Srgba = c.into(); + assert!((s.red - 0.5).abs() < 0.01); + assert!((s.green - 0.5).abs() < 0.01); + assert!((s.blue - 0.5).abs() < 0.01); + } + + #[test] + fn test_gray_hsl() { + let c = ColorSpace::Hsl.gray(0.5, 1.0); + let s: Srgba = c.into(); + assert!((s.red - 0.5).abs() < 0.05); + assert!((s.green - 0.5).abs() < 0.05); + } + + #[test] + fn test_scale_identity() { + let mode = ColorMode::default(); + assert!((mode.scale(0.5, 0) - 0.5).abs() < 1e-6); + } + + #[test] + fn test_scale_255() { + let mode = ColorMode::with_uniform_max(ColorSpace::Srgb, 255.0); + assert!((mode.scale(255.0, 0) - 1.0).abs() < 1e-4); + } + + #[test] + fn test_scale_hsl_percent() { + let mode = ColorMode::new(ColorSpace::Hsl, 360.0, 100.0, 100.0, 1.0); + assert!((mode.scale(180.0, 0) - 180.0).abs() < 0.01); + assert!((mode.scale(50.0, 1) - 0.5).abs() < 1e-4); + assert!((mode.scale(50.0, 2) - 0.5).abs() < 1e-4); + } +} diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index 48567b1..7877d16 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -137,8 +137,8 @@ impl CameraProjection for ProcessingProjection { self.width, self.height, // bottom = height 0.0, // top = 0 - self.near, self.far, + self.near, ) } @@ -239,6 +239,7 @@ pub fn create( render_layer, CommandBuffer::new(), RenderState::default(), + crate::color::ColorMode::default(), SurfaceSize(width, height), Graphics { readback_buffer, diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 5d68675..2eb6954 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1,5 +1,6 @@ #![allow(clippy::module_inception)] +pub mod color; pub mod geometry; pub mod gltf; mod graphics; @@ -371,6 +372,33 @@ pub fn graphics_update_region( }) } +/// Set the color mode for a graphics entity. +pub fn graphics_set_color_mode( + graphics_entity: Entity, + mode: color::ColorMode, +) -> error::Result<()> { + app_mut(|app| { + let mut entity = app + .world_mut() + .get_entity_mut(graphics_entity) + .map_err(|_| error::ProcessingError::GraphicsNotFound)?; + if let Some(mut cm) = entity.get_mut::() { + *cm = mode; + } + Ok(()) + }) +} + +/// Get the color mode for a graphics entity. +pub fn graphics_get_color_mode(graphics_entity: Entity) -> error::Result { + app_mut(|app| { + app.world() + .get::(graphics_entity) + .copied() + .ok_or(error::ProcessingError::GraphicsNotFound) + }) +} + /// Record a drawing command for a window pub fn graphics_record_command(graphics_entity: Entity, cmd: DrawCommand) -> error::Result<()> { app_mut(|app| {