graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: Extrude node
A new subnet template (input -> opencl -> output): the kernel offsets
each input triangle along its face normal by Distance and stitches
side walls, with a Keep Base toggle for the original face (reversed
winding). Wound for the raster pass's CCW-front back-face culling —
the templates' triangles read clockwise from outside, so the normal is
the negated cross.
Two engine fixes it flushed out:
- The geometry cycle guard keyed on node NAME, so a subnet consuming
another subnet's geometry died at the second "output1" — now by id,
like the wire-walk guard (test fixtures gain unique ids).
- resolve_opencl_geometry resolved its Input globally by name; two
subnet instances both containing "input1" would wire to the first.
Siblings now win, exactly like the output type's lookup.
Co-Authored-By: Claude Fable 5 <[email protected]>
nodes/extrude.json | 69 +++++++++++++++++++++++++++++++++++++++++++++
src/geometry.rs | 43 +++++++++++++++++-----------
src/main.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 179 insertions(+), 16 deletions(-)
diff --git a/nodes/extrude.json b/nodes/extrude.json
new file mode 100644
index 0000000..0fbfae5
--- /dev/null
+++ b/nodes/extrude.json
@@ -0,0 +1,69 @@
+{
+ "name": "Extrude",
+ "type": "node",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ {
+ "name": "Input",
+ "default": "",
+ "type": "text"
+ },
+ {
+ "name": "Distance",
+ "default": "0.2",
+ "type": "slider",
+ "min": -1.0,
+ "max": 1.0,
+ "step": 0.01
+ },
+ {
+ "name": "Keep Base",
+ "default": "true",
+ "type": "toggle"
+ }
+ ],
+ "children": [
+ {
+ "name": "input1",
+ "type": "input",
+ "params": [],
+ "position": [
+ 4.0,
+ 1.0
+ ]
+ },
+ {
+ "name": "opencl1",
+ "type": "opencl",
+ "params": [
+ {
+ "name": "Input",
+ "default": "input1"
+ },
+ {
+ "name": "Code",
+ "default": "__kernel void process(__global const float* in_pos, __global const float* in_col, int in_count, __global float* out_pos, __global float* out_col, __global int* out_count, int max_vertices) {\n int id = get_global_id(0);\n if (id == 0) {\n float dist = chf(\"Distance\", 0.2f);\n int keep_base = chb(\"Keep Base\", true) > 0.5f ? 1 : 0;\n int tri_count = in_count / 3;\n int count = 0;\n for (int t = 0; t < tri_count; t++) {\n int i0 = (t * 3 + 0) * 3;\n int i1 = (t * 3 + 1) * 3;\n int i2 = (t * 3 + 2) * 3;\n float px[3] = {in_pos[i0], in_pos[i1], in_pos[i2]};\n float py[3] = {in_pos[i0 + 1], in_pos[i1 + 1], in_pos[i2 + 1]};\n float pz[3] = {in_pos[i0 + 2], in_pos[i1 + 2], in_pos[i2 + 2]};\n float cr[3] = {in_col[i0], in_col[i1], in_col[i2]};\n float cg[3] = {in_col[i0 + 1], in_col[i1 + 1], in_col[i2 + 1]};\n float cb[3] = {in_col[i0 + 2], in_col[i1 + 2], in_col[i2 + 2]};\n // Face normal from the winding (CCW front): extrusion direction.\n float ux = px[1] - px[0], uy = py[1] - py[0], uz = pz[1] - pz[0];\n float vx = px[2] - px[0], vy = py[2] - py[0], vz = pz[2] - pz[0];\n // Negated cross: the templates' triangles wind clockwise seen\n // from outside under this renderer's convention, so the plain\n // cross(B-A, C-A) points inward.\n float nx = uz * vy - uy * vz;\n float ny = ux * vz - uz * vx;\n float nz = uy * vx - ux * vy;\n float len = sqrt(nx * nx + ny * ny + nz * nz);\n if (len > 1e-8f) { nx /= len; ny /= len; nz /= len; }\n float ox = nx * dist, oy = ny * dist, oz = nz * dist;\n // Top face: the input triangle offset along its normal, same winding.\n for (int v = 0; v < 3; v++) {\n int idx = count++;\n if (idx < max_vertices) {\n out_pos[idx * 3 + 0] = px[v] + ox;\n out_pos[idx * 3 + 1] = py[v] + oy;\n out_pos[idx * 3 + 2] = pz[v] + oz;\n out_col[idx * 3 + 0] = cr[v];\n out_col[idx * 3 + 1] = cg[v];\n out_col[idx * 3 + 2] = cb[v];\n }\n }\n // Side walls: one quad per edge, wound so the outside faces out\n // (CCW front) for a CCW input triangle and positive distance.\n for (int e = 0; e < 3; e++) {\n int s0 = e;\n int s1 = (e + 1) % 3;\n float wx[6] = {px[s0], px[s1], px[s1] + ox, px[s0], px[s1] + ox, px[s0] + ox};\n float wy[6] = {py[s0], py[s1], py[s1] + oy, py[s0], py[s1] + oy, py[s0] + oy};\n float wz[6] = {pz[s0], pz[s1], pz[s1] + oz, pz[s0], pz[s1] + oz, pz[s0] + oz};\n int wc[6] = {s0, s1, s1, s0, s1, s0};\n for (int v = 0; v < 6; v++) {\n int idx = count++;\n if (idx < max_vertices) {\n out_pos[idx * 3 + 0] = wx[v];\n out_pos[idx * 3 + 1] = wy[v];\n out_pos[idx * 3 + 2] = wz[v];\n out_col[idx * 3 + 0] = cr[wc[v]] * 0.85f;\n out_col[idx * 3 + 1] = cg[wc[v]] * 0.85f;\n out_col[idx * 3 + 2] = cb[wc[v]] * 0.85f;\n }\n }\n }\n // Base: the original triangle, winding reversed so it faces away\n // from the extrusion.\n if (keep_base) {\n int ord[3] = {0, 2, 1};\n for (int v = 0; v < 3; v++) {\n int s = ord[v];\n int idx = count++;\n if (idx < max_vertices) {\n out_pos[idx * 3 + 0] = px[s];\n out_pos[idx * 3 + 1] = py[s];\n out_pos[idx * 3 + 2] = pz[s];\n out_col[idx * 3 + 0] = cr[s];\n out_col[idx * 3 + 1] = cg[s];\n out_col[idx * 3 + 2] = cb[s];\n }\n }\n }\n }\n *out_count = count > max_vertices ? max_vertices : count;\n }\n}"
+ }
+ ],
+ "position": [
+ 4.0,
+ 2.0
+ ]
+ },
+ {
+ "name": "output1",
+ "type": "output",
+ "params": [
+ {
+ "name": "Input",
+ "default": "opencl1"
+ }
+ ],
+ "position": [
+ 4.0,
+ 3.0
+ ]
+ }
+ ]
+}
diff --git a/src/geometry.rs b/src/geometry.rs
index 074f1d5..62a50fb 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -357,10 +357,15 @@ pub fn generate_single_node_geometry_with_errors(
visited: &mut Vec<String>,
ocl_error: &mut Option<String>,
) -> Option<Geometry> {
- if visited.contains(&target.name) {
+ // Cycle guard by ID, not name: subnet instances share child names
+ // ("output1", "opencl1"), so a name guard falsely blocks a subnet that
+ // consumes another subnet's geometry (Extrude eating a Sphere never
+ // reaches the sphere's own output1). The wire-walk guard below (line
+ // ~490) already keys on id.
+ if visited.contains(&target.id) {
return None;
}
- visited.push(target.name.clone());
+ visited.push(target.id.clone());
let res = if target.node_type.eq_ignore_ascii_case("sphere") {
let idx = find_sphere_index(root, target)?;
@@ -807,7 +812,13 @@ pub fn resolve_opencl_geometry_with_errors(
) -> Option<Geometry> {
let input_name = node_param_str(target, "Input", "");
let mut geom = if !input_name.is_empty() {
- if let Some(input_node) = find_node_by_name(root, &input_name) {
+ // Siblings first, exactly like the output type's lookup: subnet
+ // templates (Extrude) wire their inner opencl to a child named
+ // "input1", and a global-first search would resolve to the FIRST
+ // subnet's child once two instances exist.
+ let sibling = find_parent_node(root, &target.id)
+ .and_then(|p| p.children.iter().find(|c| c.name == input_name || c.id == input_name));
+ if let Some(input_node) = sibling.or_else(|| find_node_by_name(root, &input_name)) {
generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error).unwrap_or_default()
} else {
Geometry::default()
@@ -1561,7 +1572,7 @@ mod tests {
#[test]
fn test_add_node_points() {
let add_node = FsNode {
- id: String::new(),
+ id: "id Add points test".to_string(),
inputs: 1,
outputs: 1,
name: "Add points test".to_string(),
@@ -1583,7 +1594,7 @@ mod tests {
position: (0.0, 0.0),
};
let root = FsNode {
- id: String::new(),
+ id: "id root".to_string(),
inputs: 1,
outputs: 1,
name: "root".to_string(),
@@ -1600,7 +1611,7 @@ mod tests {
#[test]
fn test_transform_node() {
let sphere = FsNode {
- id: String::new(),
+ id: "id Sphere 1".to_string(),
inputs: 1,
outputs: 1,
name: "Sphere 1".to_string(),
@@ -1622,7 +1633,7 @@ mod tests {
position: (0.0, 0.0),
};
let transform1 = FsNode {
- id: String::new(),
+ id: "id Transform 1".to_string(),
inputs: 1,
outputs: 1,
name: "Transform 1".to_string(),
@@ -1654,7 +1665,7 @@ mod tests {
position: (0.0, 0.0),
};
let root = FsNode {
- id: String::new(),
+ id: "id root".to_string(),
inputs: 1,
outputs: 1,
name: "root".to_string(),
@@ -1678,7 +1689,7 @@ mod tests {
// Test chained transform
let transform2 = FsNode {
- id: String::new(),
+ id: "id Transform 2".to_string(),
inputs: 1,
outputs: 1,
name: "Transform 2".to_string(),
@@ -1710,7 +1721,7 @@ mod tests {
position: (0.0, 0.0),
};
let root_chained = FsNode {
- id: String::new(),
+ id: "id root".to_string(),
inputs: 1,
outputs: 1,
name: "root".to_string(),
@@ -1731,7 +1742,7 @@ mod tests {
// Test loop detection
let transform_loop = FsNode {
- id: String::new(),
+ id: "id Transform Loop".to_string(),
inputs: 1,
outputs: 1,
name: "Transform Loop".to_string(),
@@ -1763,7 +1774,7 @@ mod tests {
position: (0.0, 0.0),
};
let root_loop = FsNode {
- id: String::new(),
+ id: "id root".to_string(),
inputs: 1,
outputs: 1,
name: "root".to_string(),
@@ -1786,7 +1797,7 @@ mod tests {
}
let sphere = FsNode {
- id: String::new(),
+ id: "id Sphere 1".to_string(),
inputs: 1,
outputs: 1,
name: "Sphere 1".to_string(),
@@ -1809,7 +1820,7 @@ mod tests {
};
let opencl_node = FsNode {
- id: String::new(),
+ id: "id OpenCL 1".to_string(),
inputs: 1,
outputs: 1,
name: "OpenCL 1".to_string(),
@@ -1849,7 +1860,7 @@ mod tests {
};
let root = FsNode {
- id: String::new(),
+ id: "id root".to_string(),
inputs: 1,
outputs: 1,
name: "root".to_string(),
@@ -1940,7 +1951,7 @@ mod tests {
};
let root = FsNode {
- id: String::new(),
+ id: "id root".to_string(),
inputs: 1,
outputs: 1,
name: "root".to_string(),
diff --git a/src/main.rs b/src/main.rs
index fe24536..afdaae6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -226,6 +226,89 @@ mod tests {
assert!((max_dist_2 - 1.0).abs() < 0.01, "Expected radius around 1.0, got {}", max_dist_2);
}
+ /// The Extrude template: a subnet (input -> opencl -> output) whose kernel
+ /// offsets each input triangle along its face normal and stitches side
+ /// walls. Per input triangle it emits top (3) + walls (18) + base (3) =
+ /// 24 vertices, or 21 with Keep Base off.
+ #[test]
+ fn test_extrude_subnet_geometry_generation() {
+ let templates_root = crate::app::load_fs_tree();
+ let sphere_template = templates_root.children.iter().find(|t| t.name == "Sphere").unwrap();
+ let extrude_template = templates_root
+ .children
+ .iter()
+ .find(|t| t.name == "Extrude")
+ .expect("Extrude template should be loaded");
+ assert_eq!(extrude_template.children.len(), 3);
+ assert_eq!(extrude_template.inputs, 1);
+
+ let mut sphere_instance = sphere_template.clone();
+ sphere_instance.id = "sphere_inst".to_string();
+ sphere_instance.name = "Sphere 1".to_string();
+ for child in &mut sphere_instance.children {
+ child.id = format!("{}_{}", sphere_instance.id, child.name);
+ }
+
+ let mut extrude_instance = extrude_template.clone();
+ extrude_instance.id = "extrude_inst".to_string();
+ extrude_instance.name = "Extrude 1".to_string();
+ for child in &mut extrude_instance.children {
+ child.id = format!("{}_{}", extrude_instance.id, child.name);
+ }
+ extrude_instance.params.iter_mut().find(|p| p.name == "Input").unwrap().default =
+ "Sphere 1".to_string();
+
+ let root = FsNode {
+ id: "root".to_string(),
+ name: "root".to_string(),
+ node_type: "node".to_string(),
+ children: vec![sphere_instance, extrude_instance],
+ params: vec![],
+ geometry_visible: true,
+ position: (0.0, 0.0),
+ inputs: 0,
+ outputs: 0,
+ };
+
+ let mut visited = Vec::new();
+ let mut ocl_err = None;
+ let geom = crate::geometry::generate_single_node_geometry_with_errors(
+ &root,
+ &root.children[1],
+ &mut visited,
+ &mut ocl_err,
+ ).expect("Extrude geometry generation failed");
+ assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
+ // 2304 sphere vertices = 768 triangles; 768 * 24 = 18432.
+ assert_eq!(geom.vertices.len(), 18432);
+
+ // Extruding a radius-0.5 sphere outward by the default 0.2 pushes the
+ // farthest vertices to ~0.7 from its center.
+ let mut max_dist: f32 = 0.0;
+ for v in &geom.vertices {
+ let dx = v.pos[0];
+ let dy = v.pos[1] - 0.55;
+ let dz = v.pos[2];
+ max_dist = max_dist.max((dx * dx + dy * dy + dz * dz).sqrt());
+ }
+ assert!((max_dist - 0.7).abs() < 0.02, "Expected max extent ~0.7, got {}", max_dist);
+
+ // Keep Base off drops the 3 base vertices per triangle: 768 * 21.
+ let mut root2 = root.clone();
+ root2.children[1].params.iter_mut().find(|p| p.name == "Keep Base").unwrap().default =
+ "false".to_string();
+ let mut visited2 = Vec::new();
+ let mut ocl_err2 = None;
+ let geom2 = crate::geometry::generate_single_node_geometry_with_errors(
+ &root2,
+ &root2.children[1],
+ &mut visited2,
+ &mut ocl_err2,
+ ).expect("Extrude geometry generation failed (no base)");
+ assert!(ocl_err2.is_none(), "OpenCL compilation error: {:?}", ocl_err2);
+ assert_eq!(geom2.vertices.len(), 16128);
+ }
+
/// 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.