git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitc8530c7860cd42da94282473a4532767b9fc3eab
parent2b4d6874d3
authorLucas Galante <[email protected]>
date2026-08-25 10:07
fix: Scatter evaluates when dispatched — drop the double cycle guard

generate_single_node_geometry_with_errors pushes the target id before
dispatching, so the scatter resolver's own visited guard saw it and refused
every dispatched call: the spreadsheet of a selected Scatter came up empty
and any downstream consumer ate None — while the scene walk's direct call
(fresh visited) kept the node looking healthy. Cycles stay guarded by the
dispatch itself; the resolver-local pop that would now underflow the
dispatch's stack entry is gone too. Chain test: Sphere → Scatter →
Attribute sees the same points the direct eval yields.

 src/geometry.rs | 29 ++++++----------------
 src/main.rs     | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 83 insertions(+), 21 deletions(-)

diff --git a/src/geometry.rs b/src/geometry.rs
index efebd47..8062a2e 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -876,30 +876,18 @@ pub fn resolve_scatter_geometry_with_errors(
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
 ) -> Option<Geometry> {
-    if visited.contains(&target.id) {
-        return None;
-    }
-    visited.push(target.id.clone());
-
+    // No visited guard here: `generate_single_node_geometry_with_errors`
+    // pushes the target's id before dispatching to this resolver, so a local
+    // `visited.contains` check refused every dispatched call — scatter
+    // geometry evaluated to None for the spreadsheet and for any downstream
+    // consumer, while the scene walk's direct call (fresh `visited`) kept the
+    // node LOOKING healthy. Cycles stay guarded by the dispatch itself.
     let input_name = node_param_str(target, "Input", "");
     if input_name.is_empty() {
-        visited.pop();
         return None;
     }
-    let input_node = match find_node_by_name(root, &input_name) {
-        Some(node) => node,
-        None => {
-            visited.pop();
-            return None;
-        }
-    };
-    let geom = match generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim) {
-        Some(g) => g,
-        None => {
-            visited.pop();
-            return None;
-        }
-    };
+    let input_node = find_node_by_name(root, &input_name)?;
+    let geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
 
     let num_points = node_param_f32(target, "Points", 100.0) as usize;
     let radius = node_param_f32(target, "Radius", 0.02);
@@ -976,7 +964,6 @@ pub fn resolve_scatter_geometry_with_errors(
         scattered_geom
     };
 
-    visited.pop();
     Some(res)
 }
 
diff --git a/src/main.rs b/src/main.rs
index dccb4f1..4c96c09 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1131,6 +1131,81 @@ mod tests {
         assert!(geom.vertices.iter().all(|v| !v.attributes.contains_key("mass")));
     }
 
+    /// A Scatter consumed downstream must still evaluate: the dispatch pushes
+    /// the target id before dispatching, so a resolver-local visited guard
+    /// sees it and refuses every dispatched call — scatter geometry silently
+    /// vanished from any chain while displaying fine on its own.
+    #[test]
+    fn test_scatter_consumed_downstream() {
+        let templates_root = crate::app::load_fs_tree();
+        let find = |name: &str| {
+            templates_root.children.iter().find(|t| t.name == name).unwrap()
+        };
+        let instance = |template: &FsNode, id: &str, name: &str, params: &[(&str, &str)]| {
+            let mut inst = template.clone();
+            inst.id = id.to_string();
+            inst.name = name.to_string();
+            for child in &mut inst.children {
+                child.id = format!("{}_{}", inst.id, child.name);
+            }
+            for (pname, val) in params {
+                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().default =
+                    val.to_string();
+            }
+            inst
+        };
+        let root = FsNode {
+            id: "root".to_string(),
+            name: "root".to_string(),
+            node_type: "node".to_string(),
+            children: vec![
+                instance(find("Sphere"), "s", "Sphere 1", &[]),
+                instance(find("Scatter"), "sc", "Scatter 1", &[("Input", "Sphere 1")]),
+                instance(find("Attribute"), "a", "Attr 1", &[
+                    ("Input", "Scatter 1"),
+                    ("Operation", "Create"),
+                    ("Attribute Name", "mass"),
+                    ("Value", "1.00"),
+                ]),
+            ],
+            params: vec![],
+            geometry_visible: true,
+            position: (0.0, 0.0),
+            inputs: 0,
+            outputs: 0,
+        };
+
+        // The scatter alone works (the scene walk's direct-call path)…
+        let mut visited = Vec::new();
+        let mut err = None;
+        let mut cache = crate::geometry::SimCache::default();
+        let direct = crate::geometry::generate_single_node_geometry_with_errors(
+            &root,
+            &root.children[1],
+            &mut visited,
+            &mut err,
+            &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
+        ).expect("scatter evaluates on its own");
+        assert!(err.is_none(), "{err:?}");
+        assert!(!direct.vertices.is_empty());
+
+        // …and the SAME scatter feeding a downstream node yields the SAME
+        // points, tagged by the consumer.
+        let mut visited = Vec::new();
+        let mut err = None;
+        let mut cache = crate::geometry::SimCache::default();
+        let chained = crate::geometry::generate_single_node_geometry_with_errors(
+            &root,
+            &root.children[2],
+            &mut visited,
+            &mut err,
+            &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
+        ).expect("a node consuming a scatter must see its geometry");
+        assert!(err.is_none(), "{err:?}");
+        assert_eq!(chained.vertices.len(), direct.vertices.len());
+        assert!(chained.vertices.iter().all(|v| v.attributes.contains_key("mass")));
+    }
+
     /// The Plane template mirrors the Sphere subnet (an opencl node feeding an
     /// output node); its kernel generates a divs x divs grid on XZ at y = 0,
     /// with the Size param as the side length.