graphic design tool
git clone https://git.lucas.co/cce-designer.git
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() {