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

commit08b4caed567ed58ffbe2d95443c7df96f39934f7
parent0acc130660
authorLucas Galante <[email protected]>
date2026-09-02 15:14
feat: rename the Add node to Points, with a Shape dropdown

The point-cloud generator node is now Points (type "points", template
nodes/points.json), and its hardcoded spiral becomes a Shape choice:
None (default — every point in the same spot), Spiral (the old
arrangement), Line, Circle, Grid. Generation is factored into one
points_node_geometry used by both the single-node resolver and the
scene walk, which previously carried duplicate copies of the spiral.

Legacy saves migrate in merge_template_defs (the session->meta
pattern): type "add" retypes in place before template matching, so
old instances find the renamed template and gain Shape through the
normal merge — names and saved param values untouched, since names
are the wire identity Input params reference.

Co-Authored-By: Claude Fable 5 <[email protected]>

 nodes/{add.json => points.json} |   9 +++-
 src/app.rs                      |  14 +++++
 src/geometry.rs                 | 116 ++++++++++++++++++++++++++++------------
 src/main.rs                     |  29 ++++++++++
 4 files changed, 131 insertions(+), 37 deletions(-)

diff --git a/nodes/add.json b/nodes/points.json
similarity index 56%
rename from nodes/add.json
rename to nodes/points.json
index 2d1ab26..6f6a0be 100644
--- a/nodes/add.json
+++ b/nodes/points.json
@@ -1,6 +1,6 @@
 {
-  "name": "Add",
-  "type": "add",
+  "name": "Points",
+  "type": "points",
   "inputs": 0,
   "outputs": 1,
   "params": [
@@ -11,6 +11,11 @@
       "min": 10.0,
       "max": 5000.0,
       "step": 10.0
+    },
+    {
+      "name": "Shape",
+      "type": "choice:None,Spiral,Line,Circle,Grid",
+      "default": "None"
     }
   ]
 }
diff --git a/src/app.rs b/src/app.rs
index 1d40728..20af77a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -468,6 +468,20 @@ pub fn meta_pref(node: &FsNode, name: &str) -> bool {
 /// and nothing is ever injected or deleted. Simnet children (the user's sim
 /// chain) are out of scope by construction: simnet is a native type.
 pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
+    // Legacy retypes, session->meta style: renamed native types are rewritten
+    // in place (params and name intact) BEFORE matching, so old saves find the
+    // renamed template and gain its new params through the normal merge.
+    // "add" became "points" (2026-09, gaining the Shape param).
+    fn retype_legacy(node: &mut FsNode) {
+        if node.node_type.eq_ignore_ascii_case("add") {
+            node.node_type = "points".to_string();
+        }
+        for c in &mut node.children {
+            retype_legacy(c);
+        }
+    }
+    retype_legacy(root);
+
     fn template_for<'a>(node: &FsNode, templates: &'a [NodeTemplate]) -> Option<&'a FsNode> {
         if node.node_type.eq_ignore_ascii_case("node") {
             let base = node.name.trim_end_matches(|c: char| c.is_ascii_digit()).trim_end();
diff --git a/src/geometry.rs b/src/geometry.rs
index 98838e9..0bcefcc 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -545,22 +545,10 @@ pub fn generate_single_node_geometry_with_errors(
         Some(line_vertices(start, end, thickness))
     } else if target.node_type.eq_ignore_ascii_case("curve") {
         Some(curve_geometry(target))
-    } else if target.node_type.eq_ignore_ascii_case("add") {
+    } else if target.node_type.eq_ignore_ascii_case("points") {
         let idx = find_sphere_index(root, target)?;
         let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
-        let num_points = node_param_f32(target, "Points", 100.0) as i32;
-        let mut geom = Geometry::new();
-        for i in 0..num_points {
-            let t = i as f32 / num_points.max(1) as f32;
-            let angle = t * std::f32::consts::TAU * 3.0;
-            let r = 0.4 * t;
-            let px = center.x + r * angle.cos();
-            let py = center.y + t * 0.5 - 0.25;
-            let pz = center.z + r * angle.sin();
-            let pt_center = Vec3::new(px, py, pz);
-            geom.merge(sphere_vertices_res(pt_center, 0.02, 6, 8));
-        }
-        Some(geom)
+        Some(points_node_geometry(target, center))
     } else if target.node_type.eq_ignore_ascii_case("transform") {
         resolve_transform_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("scatter") {
@@ -1997,7 +1985,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
     nt == "sphere"
         || nt == "line"
         || nt == "curve"
-        || nt == "add"
+        || nt == "points"
         || nt == "transform"
         || nt == "opencl"
         || nt == "box"
@@ -2084,22 +2072,12 @@ pub fn network_sphere_vertices_with_errors(
             if is_visible {
                 out.merge(curve_geometry(node));
             }
-        } else if node.node_type.eq_ignore_ascii_case("add") {
+        } else if node.node_type.eq_ignore_ascii_case("points") {
             let idx = *count;
             *count += 1;
             if is_visible {
                 let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
-                let num_points = node_param_f32(node, "Points", 100.0) as i32;
-                for i in 0..num_points {
-                    let t = i as f32 / num_points.max(1) as f32;
-                    let angle = t * std::f32::consts::TAU * 3.0;
-                    let r = 0.4 * t;
-                    let px = center.x + r * angle.cos();
-                    let py = center.y + t * 0.5 - 0.25;
-                    let pz = center.z + r * angle.sin();
-                    let pt_center = Vec3::new(px, py, pz);
-                    out.merge(sphere_vertices_res(pt_center, 0.02, 6, 8));
-                }
+                out.merge(points_node_geometry(node, center));
             }
         } else if node.node_type.eq_ignore_ascii_case("transform") {
             let _idx = *count;
@@ -2210,12 +2188,46 @@ pub fn network_sphere_vertices_with_errors(
     out
 }
 
+/// The Points node's cloud (type "points", nee "add"): `Points` markers
+/// arranged by the `Shape` param around `center`. One function for both
+/// consumers — the single-node resolver and the scene walk — so the two
+/// renderings can never drift apart.
+pub fn points_node_geometry(node: &FsNode, center: Vec3) -> Geometry {
+    let num_points = node_param_f32(node, "Points", 100.0) as i32;
+    let shape = node_param_str(node, "Shape", "None");
+    let mut geom = Geometry::new();
+    for i in 0..num_points {
+        let t = i as f32 / num_points.max(1) as f32;
+        let offset = match shape.as_str() {
+            "Spiral" => {
+                let angle = t * std::f32::consts::TAU * 3.0;
+                let r = 0.4 * t;
+                Vec3::new(r * angle.cos(), t * 0.5 - 0.25, r * angle.sin())
+            }
+            "Line" => Vec3::new(t - 0.5, 0.0, 0.0),
+            "Circle" => {
+                let angle = t * std::f32::consts::TAU;
+                Vec3::new(0.4 * angle.cos(), 0.0, 0.4 * angle.sin())
+            }
+            "Grid" => {
+                let side = (num_points as f32).sqrt().ceil().max(1.0) as i32;
+                let step = if side > 1 { 0.8 / (side - 1) as f32 } else { 0.0 };
+                Vec3::new((i % side) as f32 * step - 0.4, 0.0, (i / side) as f32 * step - 0.4)
+            }
+            // "None" and anything unrecognized: every point at the same spot.
+            _ => Vec3::ZERO,
+        };
+        geom.merge(sphere_vertices_res(center + offset, 0.02, 6, 8));
+    }
+    geom
+}
+
 pub fn find_sphere_index(root: &FsNode, target: &FsNode) -> Option<usize> {
     fn visit(node: &FsNode, target: &FsNode, count: &mut usize) -> Option<usize> {
         let is_target = std::ptr::eq(node, target);
         if node.node_type.eq_ignore_ascii_case("sphere") 
             || node.node_type.eq_ignore_ascii_case("line") 
-            || node.node_type.eq_ignore_ascii_case("add")
+            || node.node_type.eq_ignore_ascii_case("points")
             || node.node_type.eq_ignore_ascii_case("transform")
             || node.node_type.eq_ignore_ascii_case("opencl")
             || node.node_type.eq_ignore_ascii_case("scatter") {
@@ -2590,13 +2602,13 @@ mod tests {
     }
 
     #[test]
-    fn test_add_node_points() {
-        let add_node = FsNode {
-            id: "id Add points test".to_string(),
+    fn test_points_node_shapes() {
+        let points_node = |shape: &str| FsNode {
+            id: format!("id Points {shape} test"),
             inputs: 1,
             outputs: 1,
-            name: "Add points test".to_string(),
-            node_type: "add".to_string(),
+            name: "Points test".to_string(),
+            node_type: "points".to_string(),
             children: vec![],
             params: vec![
                 ParamDef {
@@ -2608,7 +2620,17 @@ mod tests {
                     min: Some(1.0),
                     max: Some(10.0),
                     step: Some(1.0),
-                }
+                },
+                ParamDef {
+                    name: "Shape".to_string(),
+                    label: String::new(),
+                    param_type: "choice:None,Spiral,Line,Circle,Grid".to_string(),
+                    default: shape.to_string(),
+                    options: vec![],
+                    min: None,
+                    max: None,
+                    step: None,
+                },
             ],
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -2619,13 +2641,37 @@ mod tests {
             outputs: 1,
             name: "root".to_string(),
             node_type: "node".to_string(),
-            children: vec![add_node],
+            children: vec![points_node("None")],
             params: vec![],
             geometry_visible: true,
             position: (0.0, 0.0),
         };
         let geom = network_sphere_vertices(&root);
         assert_eq!(geom.vertices.len(), 5 * 288);
+
+        // Shape "None": every point sits in the same spot, so all five marker
+        // spheres cover an identical (tiny) extent. A spread shape must not.
+        let extent = |g: &Geometry| {
+            let (mut min, mut max) = (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN));
+            for v in &g.vertices {
+                min = min.min(Vec3::from_array(v.pos));
+                max = max.max(Vec3::from_array(v.pos));
+            }
+            max - min
+        };
+        let none = points_node_geometry(&points_node("None"), Vec3::ZERO);
+        let e = extent(&none);
+        assert!(e.length() < 0.1, "None must collapse to one spot, extent {e:?}");
+
+        for shape in ["Spiral", "Line", "Circle", "Grid"] {
+            let g = points_node_geometry(&points_node(shape), Vec3::ZERO);
+            assert_eq!(g.vertices.len(), 5 * 288, "{shape}");
+            assert!(
+                extent(&g).length() > 0.3,
+                "{shape} must spread its points, extent {:?}",
+                extent(&g)
+            );
+        }
     }
 
     #[test]
diff --git a/src/main.rs b/src/main.rs
index f9d6c90..d0a3d6e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -204,6 +204,35 @@ mod tests {
         );
     }
 
+    /// Legacy "add" nodes retype to "points" on load (the session->meta
+    /// pattern) and gain the renamed template's Shape param through the
+    /// normal merge, keeping their own name and saved param values.
+    #[test]
+    fn test_add_node_migrates_to_points() {
+        let legacy: FsNode = serde_json::from_str(
+            r#"{"name":"Add 3","type":"add","params":[
+                {"name":"Points","type":"spinbox","default":"250"}
+            ]}"#,
+        )
+        .unwrap();
+        let mut root: FsNode = serde_json::from_str(r#"{"name":"root"}"#).unwrap();
+        root.children.push(legacy);
+        let templates = crate::app::load_fs_tree();
+        let templates: Vec<crate::app::NodeTemplate> = templates
+            .children
+            .iter()
+            .map(|c| crate::app::NodeTemplate { label: c.name.clone(), node: c.clone() })
+            .collect();
+        crate::app::merge_template_defs(&mut root, &templates);
+        let node = &root.children[0];
+        assert_eq!(node.node_type, "points");
+        assert_eq!(node.name, "Add 3", "instance name is the wire identity — never rewritten");
+        let points = node.params.iter().find(|p| p.name == "Points").unwrap();
+        assert_eq!(points.default, "250", "instance owns its values");
+        let shape = node.params.iter().find(|p| p.name == "Shape").expect("Shape appended");
+        assert_eq!(shape.default, "None");
+    }
+
     /// The button must exist on Main, inside the File section, before Exit.
     #[test]
     fn test_main_node_offers_set_as_default() {