diff --git a/crates/processing_pyo3/examples/flocking_duck.py b/crates/processing_pyo3/examples/flocking_duck.py index 3af396ab..e60ff4f4 100644 --- a/crates/processing_pyo3/examples/flocking_duck.py +++ b/crates/processing_pyo3/examples/flocking_duck.py @@ -62,7 +62,7 @@ def setup(): duck = gltf.geometry("LOD3spShape") p = create_particles( - geometry=duck, + duck, attributes=[ Attribute.position(), Attribute.rotation(), diff --git a/crates/processing_pyo3/examples/flocking_gpu.py b/crates/processing_pyo3/examples/flocking_gpu.py index d3119830..ab8ec579 100644 --- a/crates/processing_pyo3/examples/flocking_gpu.py +++ b/crates/processing_pyo3/examples/flocking_gpu.py @@ -61,7 +61,7 @@ def setup(): directional_light((0.95, 0.9, 0.85), 800.0) p = create_particles( - capacity=BOID_COUNT, + BOID_COUNT, attributes=[ Attribute.position(), Attribute.rotation(), diff --git a/crates/processing_pyo3/examples/particles_animated.py b/crates/processing_pyo3/examples/particles_animated.py index b80c9335..035df999 100644 --- a/crates/processing_pyo3/examples/particles_animated.py +++ b/crates/processing_pyo3/examples/particles_animated.py @@ -23,7 +23,7 @@ def setup(): for z in range(10): positions.extend([x - 4.5, y - 4.5, z - 4.5]) - p = create_particles(capacity=capacity, attributes=[Attribute.position()]) + p = create_particles(capacity, attributes=[Attribute.position()]) pos_buf = p.buffer("position") pos_buf.write(positions) diff --git a/crates/processing_pyo3/examples/particles_basic.py b/crates/processing_pyo3/examples/particles_basic.py index ae897b85..d94b64ec 100644 --- a/crates/processing_pyo3/examples/particles_basic.py +++ b/crates/processing_pyo3/examples/particles_basic.py @@ -15,7 +15,7 @@ def setup(): source = Geometry.sphere(5.0, 32, 24) p = create_particles( - geometry=source, + source, attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) diff --git a/crates/processing_pyo3/examples/particles_density.py b/crates/processing_pyo3/examples/particles_density.py index 1bbe6463..f98af0d6 100644 --- a/crates/processing_pyo3/examples/particles_density.py +++ b/crates/processing_pyo3/examples/particles_density.py @@ -18,7 +18,7 @@ def setup(): mode_3d() p = create_particles( - capacity=N, + N, attributes=[ Attribute.position(), Attribute.color(), diff --git a/crates/processing_pyo3/examples/particles_emit.py b/crates/processing_pyo3/examples/particles_emit.py index 4a3db9dc..7f868b85 100644 --- a/crates/processing_pyo3/examples/particles_emit.py +++ b/crates/processing_pyo3/examples/particles_emit.py @@ -17,7 +17,7 @@ def setup(): capacity = 2000 p = create_particles( - capacity=capacity, + capacity, attributes=[Attribute.position(), Attribute.color()], ) diff --git a/crates/processing_pyo3/examples/particles_emit_gpu.py b/crates/processing_pyo3/examples/particles_emit_gpu.py index 22093ad3..d32eb7e8 100644 --- a/crates/processing_pyo3/examples/particles_emit_gpu.py +++ b/crates/processing_pyo3/examples/particles_emit_gpu.py @@ -29,7 +29,7 @@ def setup(): # so declare them (all built-in here). `velocity`/`age` are built-ins now, so # no custom `Attribute(...)` is needed. p = create_particles( - capacity=CAPACITY, + CAPACITY, attributes=[ Attribute.position(), Attribute.velocity(), diff --git a/crates/processing_pyo3/examples/particles_from_mesh.py b/crates/processing_pyo3/examples/particles_from_mesh.py index f7e5d64a..d577f41e 100644 --- a/crates/processing_pyo3/examples/particles_from_mesh.py +++ b/crates/processing_pyo3/examples/particles_from_mesh.py @@ -15,7 +15,7 @@ def setup(): source = Geometry.sphere(5.0, 32, 24) p = create_particles( - geometry=source, + source, attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) diff --git a/crates/processing_pyo3/examples/particles_gpu_surface.py b/crates/processing_pyo3/examples/particles_gpu_surface.py index e0e4af69..4d77b967 100644 --- a/crates/processing_pyo3/examples/particles_gpu_surface.py +++ b/crates/processing_pyo3/examples/particles_gpu_surface.py @@ -14,7 +14,7 @@ def setup(): window_title("gpu index") mode_3d() - p = create_particles(capacity=NX * NY, attributes=[Attribute.position()]) + p = create_particles(NX * NY, attributes=[Attribute.position()]) idx = p.index_buffer((NX - 1) * (NY - 1) * 6) gen = create_compute(load_shader("shaders/gen_surface.wesl")) gen.set(indices=idx) diff --git a/crates/processing_pyo3/examples/particles_gpu_surface_lit.py b/crates/processing_pyo3/examples/particles_gpu_surface_lit.py index d737d1bd..a1db5871 100644 --- a/crates/processing_pyo3/examples/particles_gpu_surface_lit.py +++ b/crates/processing_pyo3/examples/particles_gpu_surface_lit.py @@ -15,7 +15,7 @@ def setup(): mode_3d() p = create_particles( - capacity=NX * NY, + NX * NY, attributes=[Attribute.position(), Attribute.color(), Attribute.normal()], ) idx = p.index_buffer((NX - 1) * (NY - 1) * 6) diff --git a/crates/processing_pyo3/examples/particles_lifecycle.py b/crates/processing_pyo3/examples/particles_lifecycle.py index d6ecdd2e..8301fd81 100644 --- a/crates/processing_pyo3/examples/particles_lifecycle.py +++ b/crates/processing_pyo3/examples/particles_lifecycle.py @@ -22,7 +22,7 @@ def setup(): capacity = 800 p = create_particles( - capacity=capacity, + capacity, attributes=[ Attribute.position(), Attribute.color(), diff --git a/crates/processing_pyo3/examples/particles_lines.py b/crates/processing_pyo3/examples/particles_lines.py index 3137ae11..20083fa2 100644 --- a/crates/processing_pyo3/examples/particles_lines.py +++ b/crates/processing_pyo3/examples/particles_lines.py @@ -23,7 +23,7 @@ def setup(): mode_3d() p = create_particles( - capacity=N, + N, attributes=[Attribute.position()], ) p.buffer("position").write([list(knot(i / N * tau, 0.0)) for i in range(N)]) diff --git a/crates/processing_pyo3/examples/particles_lissajous.py b/crates/processing_pyo3/examples/particles_lissajous.py index 886c3408..190bee92 100644 --- a/crates/processing_pyo3/examples/particles_lissajous.py +++ b/crates/processing_pyo3/examples/particles_lissajous.py @@ -34,10 +34,10 @@ def setup(): window_title(f"Lissajous — all points connected — {N:,} pts") mode_3d() - p = create_particles(capacity=N, attributes=[Attribute.position(), Attribute.color()]) + p = create_particles(N, attributes=[Attribute.position(), Attribute.color()]) # One line = 2 vertices; up to N*MAX_LINKS lines. edges = create_particles( - capacity=N * MAX_LINKS * 2, attributes=[Attribute.position(), Attribute.color()] + N * MAX_LINKS * 2, attributes=[Attribute.position(), Attribute.color()] ) idx = edges.index_buffer(N * MAX_LINKS * 2) # link fills indices + the dynamic count diff --git a/crates/processing_pyo3/examples/particles_noise.py b/crates/processing_pyo3/examples/particles_noise.py index ba2aa993..1d558e98 100644 --- a/crates/processing_pyo3/examples/particles_noise.py +++ b/crates/processing_pyo3/examples/particles_noise.py @@ -16,7 +16,7 @@ def setup(): source = Geometry.sphere(5.0, 32, 24) p = create_particles( - geometry=source, + source, attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) diff --git a/crates/processing_pyo3/examples/particles_plexus.py b/crates/processing_pyo3/examples/particles_plexus.py index 027d3712..5c740427 100644 --- a/crates/processing_pyo3/examples/particles_plexus.py +++ b/crates/processing_pyo3/examples/particles_plexus.py @@ -25,7 +25,7 @@ def setup(): mode_3d() p = create_particles( - capacity=N, + N, attributes=[Attribute.position(), Attribute.velocity(), Attribute.color()], ) diff --git a/crates/processing_pyo3/examples/particles_points.py b/crates/processing_pyo3/examples/particles_points.py index 2c745d27..f1b0befe 100644 --- a/crates/processing_pyo3/examples/particles_points.py +++ b/crates/processing_pyo3/examples/particles_points.py @@ -15,7 +15,7 @@ def setup(): mode_3d() p = create_particles( - capacity=COUNT, + COUNT, attributes=[Attribute.position()], ) diff --git a/crates/processing_pyo3/examples/particles_scatter_volume.py b/crates/processing_pyo3/examples/particles_scatter_volume.py index af3d251d..4ff5ad93 100644 --- a/crates/processing_pyo3/examples/particles_scatter_volume.py +++ b/crates/processing_pyo3/examples/particles_scatter_volume.py @@ -26,7 +26,7 @@ def setup(): particle = Geometry.sphere(0.15, 4, 3) p = create_particles( - capacity=CAPACITY, + CAPACITY, attributes=[ Attribute.position(), Attribute.scale(), @@ -45,7 +45,7 @@ def draw(): seed = (int(elapsed_time * 1000.0) ^ 0xC0FFEE) & 0xFFFFFFFF scatter.set(seed=seed) p.emit_gpu(BURST, scatter) - p.apply(MAP, a="scale", op=AFFINE, scale=0.985, offset=0.0) + p.apply(MAP, "scale", op=AFFINE, scale=0.985, offset=0.0) run() diff --git a/crates/processing_pyo3/examples/particles_sphere.py b/crates/processing_pyo3/examples/particles_sphere.py index e8bd6927..c515171e 100644 --- a/crates/processing_pyo3/examples/particles_sphere.py +++ b/crates/processing_pyo3/examples/particles_sphere.py @@ -16,13 +16,13 @@ def setup(): rest = Attribute("rest", AttributeFormat.Float3) sphere = Geometry.sphere(1.5, 96, 64) p = create_particles( - geometry=sphere, + sphere, attributes=[ Attribute.position(), rest, ], ) - p.apply(MAP, a=Attribute.position(), out=rest, op=AFFINE, scale=1.0, offset=0.0) + p.apply(MAP, Attribute.position(), out=rest, op=AFFINE, scale=1.0, offset=0.0) def draw(): diff --git a/crates/processing_pyo3/examples/particles_stress.py b/crates/processing_pyo3/examples/particles_stress.py index b86a3ec4..68d7a8ff 100644 --- a/crates/processing_pyo3/examples/particles_stress.py +++ b/crates/processing_pyo3/examples/particles_stress.py @@ -25,7 +25,7 @@ def setup(): directional_light((0.0, 0.0, 1.0), 1000.0, position=Vec3.Z, look_at=Vec3.ZERO) p = create_particles( - geometry=Geometry.grid(GRID, GRID, GRID, SPACING), + Geometry.grid(GRID, GRID, GRID, SPACING), attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) diff --git a/crates/processing_pyo3/examples/particles_surface.py b/crates/processing_pyo3/examples/particles_surface.py index 07afe584..7a33cec0 100644 --- a/crates/processing_pyo3/examples/particles_surface.py +++ b/crates/processing_pyo3/examples/particles_surface.py @@ -30,7 +30,7 @@ def setup(): window_title(f"Particle surface — {N:,}-vertex Mobius strip") mode_3d() - p = create_particles(capacity=N, attributes=[Attribute.position()]) + p = create_particles(N, attributes=[Attribute.position()]) def draw(): diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index e4474170..591fd911 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -800,8 +800,7 @@ impl Geometry { Ok(Self { entity }) } - /// lattice centered at the origin; topology is `POINTS`, intended as a - /// position source for `Particles(geometry=...)` rather than rasterized. + /// A point lattice centered at the origin, to seed `create_particles(geometry)`. #[staticmethod] #[pyo3(signature = (nx, ny, nz, spacing=1.0))] pub fn grid(nx: u32, ny: u32, nz: u32, spacing: f32) -> PyResult { diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 97deda89..01012978 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -1626,18 +1626,15 @@ pub mod mewnala { Ok(window) } - /// Creates a GPU particle system (Processing `createParticles`). `attributes` - /// defaults to `position`; other built-in attributes (`velocity`, `color`, - /// `scale`, `life`, `age`, ...) and declared custom ones materialize on - /// demand when you call `buffer("name")`. + /// Creates a GPU particle system (Processing `createParticles`) from a count or a `Geometry`. + /// Attributes are created on first use. #[pyfunction] - #[pyo3(signature = (capacity=None, attributes=None, geometry=None))] + #[pyo3(signature = (source, attributes=None))] fn create_particles( - capacity: Option, + source: &Bound<'_, PyAny>, attributes: Option>>, - geometry: Option<&Geometry>, ) -> PyResult { - super::particles::Particles::create(capacity, attributes, geometry) + super::particles::Particles::create(source, attributes) } /// Creates a compute pass from a shader (Processing-style `createCompute`). diff --git a/crates/processing_pyo3/src/particles.rs b/crates/processing_pyo3/src/particles.rs index ae1497fe..eeb54e57 100644 --- a/crates/processing_pyo3/src/particles.rs +++ b/crates/processing_pyo3/src/particles.rs @@ -1,7 +1,7 @@ use bevy::prelude::Entity; use processing::prelude::*; use processing_render::geometry; -use pyo3::types::PyDict; +use pyo3::types::{PyDict, PyTuple}; use pyo3::{ exceptions::{PyRuntimeError, PyTypeError, PyValueError}, prelude::*, @@ -265,6 +265,54 @@ fn map_param_keys(op: u32) -> &'static [&'static str] { } } +fn operand_names(verb: &str) -> &'static [&'static str] { + let is = |c: &str| verb.eq_ignore_ascii_case(c); + if is(c::MAP) || is(c::REDUCE) || is(c::EXTRACT) || is(c::LOOKUP) || is(c::NEIGHBOR) { + &["a"] + } else if is(c::COMBINE) { + &["a", "b"] + } else if is(c::MIX) { + &["a", "b", "t"] + } else { + &[] + } +} + +fn with_operands<'py>( + verb: &str, + operands: &Bound<'py, PyTuple>, + kwargs: Option<&Bound<'py, PyDict>>, +) -> PyResult>> { + let py = operands.py(); + let merged = match kwargs { + Some(k) => k.copy()?, + None => PyDict::new(py), + }; + let packs = verb.eq_ignore_ascii_case(c::PACK); + let names = operand_names(verb); + for name in names.iter().chain(packs.then_some(&"sources")) { + if merged.contains(*name)? { + return Err(PyTypeError::new_err(format!( + "apply({verb}): pass operands positionally, not as `{name}=`" + ))); + } + } + if packs { + merged.set_item("sources", operands.to_list())?; + } else if operands.len() > names.len() { + return Err(PyTypeError::new_err(format!( + "apply({verb}): takes at most {} operand(s), got {}", + names.len(), + operands.len() + ))); + } else { + for (name, operand) in names.iter().zip(operands.iter()) { + merged.set_item(*name, operand)?; + } + } + Ok(Some(merged)) +} + fn reject_unknown_kwargs(kwargs: Option<&Bound<'_, PyDict>>, valid: &[&str]) -> PyResult<()> { let Some(kwargs) = kwargs else { return Ok(()); @@ -455,9 +503,8 @@ impl Particles { }) } - /// Resolve a `buffer()` argument (an attribute name string or an `Attribute`) - /// to its attribute entity. Built-in names map to their factory; other names - /// must have been declared as custom attributes at construction. + /// Resolve an attribute name or `Attribute` to its entity. Unknown names must have been + /// written first, e.g. via `out=`. fn resolve_attribute(&self, attribute: &Bound<'_, PyAny>) -> PyResult { if let Ok(attr) = attribute.extract::() { return Ok(attr.entity); @@ -469,8 +516,13 @@ impl Particles { if let Some((entity, _)) = self.name_to_attr.get(&name) { return Ok(*entity); } + if let Some(entity) = geometry_attribute_find(&name) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))? + { + return Ok(entity); + } return Err(PyValueError::new_err(format!( - "\"{name}\" is not a built-in attribute; pass its Attribute to buffer()" + "no attribute \"{name}\" yet; write to it first, e.g. out=\"{name}\"" ))); } Err(PyTypeError::new_err( @@ -503,24 +555,66 @@ impl Particles { } fn operand(&self, kwargs: Option<&Bound<'_, PyDict>>, key: &str) -> PyResult<(Entity, u32)> { - let val = kw(kwargs, key) - .ok_or_else(|| PyRuntimeError::new_err(format!("apply(): missing operand '{key}'")))?; + let val = kw(kwargs, key).ok_or_else(|| { + PyTypeError::new_err(match key { + "out" => "apply(): missing `out=`", + _ => "apply(): missing an operand", + }) + })?; self.resolve_operand(&val) } - fn dest(&self, kwargs: Option<&Bound<'_, PyDict>>, in_place: Entity) -> PyResult { + /// Resolve `out=`, creating it with `components` if new. An existing `out` must match. + fn output(&self, kwargs: Option<&Bound<'_, PyDict>>, components: u32) -> PyResult { + let rt = |e: error::ProcessingError| PyRuntimeError::new_err(format!("{e}")); + let val = + kw(kwargs, "out").ok_or_else(|| PyTypeError::new_err("apply(): missing `out=`"))?; + if let Ok(name) = val.extract::() + && Self::builtin_attribute(&name).is_none() + && !self.name_to_attr.contains_key(&name) + && geometry_attribute_find(&name).map_err(rt)?.is_none() + { + use processing_render::geometry::AttributeFormat as F; + let format = match components { + 1 => F::Float, + 2 => F::Float2, + 3 => F::Float3, + 4 => F::Float4, + n => { + return Err(PyValueError::new_err(format!( + "apply(): can't create \"{name}\" with {n} components" + ))); + } + }; + let attr = geometry_attribute_create(name, format).map_err(rt)?; + return particles_ensure_attribute(self.entity, attr).map_err(rt); + } + let (out, comp) = self.resolve_operand(&val)?; + if comp != components { + return Err(PyValueError::new_err(format!( + "apply(): `out` has {comp} components but this writes {components}" + ))); + } + Ok(out) + } + + /// `out=` if given, otherwise in place. + fn dest( + &self, + kwargs: Option<&Bound<'_, PyDict>>, + in_place: Entity, + components: u32, + ) -> PyResult { match kw(kwargs, "out") { - Some(v) => Ok(self.resolve_operand(&v)?.0), + Some(_) => self.output(kwargs, components), None => Ok(in_place), } } - /// Build a particle system (backs `create_particles`). Attributes default to - /// `position`; the rest (built-in or declared custom) materialize on demand. + /// Build a particle system (backs `create_particles`) from a count or a `Geometry`. pub(crate) fn create( - capacity: Option, + source: &Bound<'_, PyAny>, attributes: Option>>, - geometry: Option<&Geometry>, ) -> PyResult { let attrs: Vec = match attributes { Some(list) => list.iter().map(|a| (**a).clone()).collect(), @@ -528,22 +622,16 @@ impl Particles { }; let attr_entities: Vec = attrs.iter().map(|a| a.entity).collect(); - let entity = match (capacity, geometry) { - (Some(cap), None) => particles_create(cap, attr_entities) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, - (None, Some(g)) => particles_create_from_geometry(g.entity, attr_entities) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, - (None, None) => { - return Err(PyRuntimeError::new_err( - "create_particles() requires either capacity or geometry", - )); - } - (Some(_), Some(_)) => { - return Err(PyRuntimeError::new_err( - "create_particles() accepts capacity or geometry, not both", - )); - } - }; + let entity = if let Ok(geometry) = source.extract::>() { + particles_create_from_geometry(geometry.entity, attr_entities) + } else if let Ok(capacity) = source.extract::() { + particles_create(capacity, attr_entities) + } else { + return Err(PyTypeError::new_err( + "create_particles(): pass a particle count or a Geometry", + )); + } + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Self { entity, @@ -565,7 +653,7 @@ impl Particles { if name.eq_ignore_ascii_case(c::MAP) { let (a, comp) = self.operand(kwargs, "a")?; - let out = self.dest(kwargs, a)?; + let out = self.dest(kwargs, a, comp)?; let op = kw_op(kwargs, MAP_AFFINE, parse_map_op)?; let mut valid = vec!["a", "out", "op"]; valid.extend_from_slice(map_param_keys(op)); @@ -581,7 +669,7 @@ impl Particles { "apply(combine): the first operand has {comp} components, so the second needs {comp} or 1, not {b_comp}" ))); } - let out = self.dest(kwargs, a)?; + let out = self.dest(kwargs, a, comp)?; let op = kw_op(kwargs, COMBINE_ADD, parse_combine_op)?; let b_scale = kw_f32(kwargs, "b_scale", 1.0)?; let b_offset = kw_f32(kwargs, "b_offset", 0.0)?; @@ -596,15 +684,15 @@ impl Particles { let (t, t_comp) = self.operand(kwargs, "t")?; if b_comp != comp { return Err(PyValueError::new_err(format!( - "apply(mix): `a` has {comp} components but `b` has {b_comp} (must match)" + "apply(mix): the first operand has {comp} components but the second has {b_comp}" ))); } if t_comp != 1 { return Err(PyValueError::new_err(format!( - "apply(mix): `t` must be a per-particle scalar (1 component), got {t_comp}" + "apply(mix): the third operand must be one value per particle, not {t_comp} components" ))); } - let out = self.dest(kwargs, a)?; + let out = self.dest(kwargs, a, comp)?; let t_scale = kw_f32(kwargs, "t_scale", 1.0)?; let t_offset = kw_f32(kwargs, "t_offset", 0.0)?; let t_clamp = kw_bool(kwargs, "t_clamp", true)?; @@ -624,7 +712,7 @@ impl Particles { ], )?; let (a, in_comp) = self.operand(kwargs, "a")?; - let out = self.operand(kwargs, "out")?.0; + let out = self.output(kwargs, 4)?; let tex = kw(kwargs, "tex") .ok_or_else(|| PyRuntimeError::new_err("apply(lookup): missing 'tex' Image"))? .extract::>() @@ -650,29 +738,47 @@ impl Particles { } else if name.eq_ignore_ascii_case(c::REDUCE) { reject_unknown_kwargs(kwargs, &["a", "out", "op"])?; let (a, comp) = self.operand(kwargs, "a")?; - let out = self.operand(kwargs, "out")?.0; + let out = self.output(kwargs, 1)?; let op = kw_op(kwargs, REDUCE_LENGTH, parse_reduce_op)?; algebra_reduce(out, a, comp, op).map_err(rt) } else if name.eq_ignore_ascii_case(c::EXTRACT) { reject_unknown_kwargs(kwargs, &["a", "out", "index"])?; let (a, comp) = self.operand(kwargs, "a")?; - let out = self.operand(kwargs, "out")?.0; + let out = self.output(kwargs, 1)?; let index = kw_u32(kwargs, "index", 0)?; algebra_extract(out, a, comp, index).map_err(rt) } else if name.eq_ignore_ascii_case(c::PACK) { reject_unknown_kwargs(kwargs, &["out", "sources"])?; - let out = self.operand(kwargs, "out")?.0; let sources = kw(kwargs, "sources") .ok_or_else(|| PyRuntimeError::new_err("apply(pack): missing 'sources' list"))?; let items: Vec> = sources.extract()?; + let out = self.output(kwargs, items.len() as u32)?; let mut entities = Vec::with_capacity(items.len()); for item in &items { entities.push(self.resolve_operand(item)?.0); } algebra_pack(out, &entities).map_err(rt) } else if name.eq_ignore_ascii_case(c::GENERATE) { - reject_unknown_kwargs(kwargs, &["out", "mode", "seed", "scale", "offset"])?; - let (out, comp) = self.operand(kwargs, "out")?; + reject_unknown_kwargs( + kwargs, + &["out", "mode", "seed", "scale", "offset", "components"], + )?; + // nothing to infer from, so a new `out` needs `components=` + let (out, comp) = match kw(kwargs, "components") { + Some(c) => { + let comp = c.extract::()?; + (self.output(kwargs, comp)?, comp) + } + None => match kw(kwargs, "out").map(|v| self.resolve_operand(&v)) { + Some(Ok(resolved)) => resolved, + Some(Err(_)) => { + return Err(PyValueError::new_err( + "apply(generate): pass components= to create a new `out`", + )); + } + None => return Err(PyTypeError::new_err("apply(): missing `out=`")), + }, + }; let mode = match kw(kwargs, "mode") { Some(v) => parse_generate_mode(&v.extract::()?)?, None => GEN_UNIFORM, @@ -695,13 +801,7 @@ impl Particles { let cell = grid.cell_size()?; let radius = kw_f32(kwargs, "radius", cell)?.min(cell); - let (out, out_comp) = self.operand(kwargs, "out")?; let (a, components) = if op == NEIGHBOR_COUNT { - if out_comp != 1 { - return Err(PyValueError::new_err( - "apply(neighbor, op=count/density): `out` must be a scalar (1 component)", - )); - } let a = match kw(kwargs, "a") { Some(_) => self.operand(kwargs, "a")?.0, None => { @@ -713,14 +813,9 @@ impl Particles { }; (a, 1u32) } else { - let (a, in_comp) = self.operand(kwargs, "a")?; - if in_comp != out_comp { - return Err(PyValueError::new_err(format!( - "apply(neighbor): source has {in_comp} components but out has {out_comp}" - ))); - } - (a, in_comp) + self.operand(kwargs, "a")? }; + let out = self.output(kwargs, components)?; particles_gather( self.entity, grid.entity, @@ -772,8 +867,8 @@ impl Particles { /// The GPU buffer for an attribute, materialized on demand. `attribute` is a /// built-in name (`"position"`, `"velocity"`, `"color"`, `"scale"`, `"life"`, - /// `"age"`, `"normal"`, `"uv"`, `"rotation"`), a declared custom attribute's - /// name, or an `Attribute`. + /// `"age"`, `"normal"`, `"uv"`, `"rotation"`), the name of any created + /// attribute, or an `Attribute`. pub fn buffer(&self, attribute: &Bound<'_, PyAny>) -> PyResult { let attr_entity = self.resolve_attribute(attribute)?; let buf = particles_ensure_attribute(self.entity, attr_entity) @@ -811,13 +906,21 @@ impl Particles { particles_reset_indices(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - #[pyo3(signature = (kind, **kwargs))] + /// Run an operation: operands positionally, `out=` and options as keywords, e.g. + /// `p.apply(COMBINE, "velocity", "force", op="add")`. + #[pyo3(signature = (kind, *operands, **kwargs))] pub fn apply( &self, kind: &Bound<'_, PyAny>, + operands: &Bound<'_, PyTuple>, kwargs: Option<&Bound<'_, PyDict>>, ) -> PyResult<()> { if let Ok(compute) = kind.extract::>() { + if !operands.is_empty() { + return Err(PyTypeError::new_err( + "apply(compute): pass kernel parameters as keywords", + )); + } if let Some(kwargs) = kwargs { compute.set(Some(kwargs))?; } @@ -829,7 +932,8 @@ impl Particles { "apply(): first argument must be an operation constant or a Compute", ) })?; - self.apply_named(&name, kwargs) + let kwargs = with_operands(&name, operands, kwargs)?; + self.apply_named(&name, kwargs.as_ref()) } #[pyo3(signature = (n, **kwargs))] @@ -841,12 +945,10 @@ impl Particles { let mut data: Vec<(Entity, Vec)> = Vec::new(); for (key, value) in kwargs.iter() { let name: String = key.extract()?; - let (attr_entity, fmt) = self.name_to_attr.get(&name).copied().ok_or_else(|| { - PyRuntimeError::new_err(format!( - "no attribute named '{name}' (registered: {:?})", - self.name_to_attr.keys().collect::>() - )) - })?; + let attr_entity = self.resolve_attribute(&key)?; + let (_, fmt) = geometry_attribute_info(attr_entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let fmt = AttributeFormat::from_inner(fmt); let floats: Vec = value.extract()?; let expected = (n as usize) * fmt.float_count(); if floats.len() != expected { diff --git a/crates/processing_render/src/geometry/attribute.rs b/crates/processing_render/src/geometry/attribute.rs index ca5f7764..4c674ef8 100644 --- a/crates/processing_render/src/geometry/attribute.rs +++ b/crates/processing_render/src/geometry/attribute.rs @@ -346,6 +346,19 @@ pub fn create( Ok(entity) } +pub fn find( + In(name): In, + builtins: Res, + registry: Res, +) -> Option { + builtins.by_name(&name).or_else(|| { + registry + .by_name + .get(&hash_attr_name(&name)) + .map(|(entity, _)| *entity) + }) +} + pub fn destroy(In(entity): In, mut commands: Commands) -> Result<()> { commands.entity(entity).despawn(); Ok(()) diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 10c65cbd..499f3a38 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1462,6 +1462,16 @@ pub fn geometry_attribute_create( }) } +/// The attribute named `name`, if one has been created. +pub fn geometry_attribute_find(name: impl Into) -> error::Result> { + app_mut(|app| { + Ok(app + .world_mut() + .run_system_cached_with(geometry::attribute::find, name.into()) + .unwrap()) + }) +} + pub fn geometry_attribute_position() -> Entity { app_mut(|app| { Ok(app