graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: random group selection and a Relax tissue solver
Group gains a Mode param (Box/Random): Random draws Count elements
deterministically from Seed — for Points it draws WELDED points, so one
random point tags every coincident vertex copy instead of tearing one
corner off a triangle. The box selector is unchanged as the default.
Relax is a new native node: an edge-length constraint solver over the
welded surface. It restores the edge lengths of a Rest geometry (a node
name evaluated as a second input — inside a simnet, input1, the previous
state) with Gauss-Seidel iterations scaled by Stiffness, while vertices
in Pin Group keep their input position and push everyone else. Chain a
group displacement before it and the surface follows the pulled point
with an organic falloff; each sim step's result becomes the next rest
shape, so sustained pulls creep like soft tissue.
The Pin Group row joins the group-picker upgrade in the param pane.
Co-Authored-By: Claude Fable 5 <[email protected]>
nodes/group.json | 21 +++
nodes/relax.json | 27 ++++
src/app.rs | 3 +-
src/geometry.rs | 403 ++++++++++++++++++++++++++++++++++++++++++++++++++-----
4 files changed, 419 insertions(+), 35 deletions(-)
diff --git a/nodes/group.json b/nodes/group.json
index 45fb932..0f34b90 100644
--- a/nodes/group.json
+++ b/nodes/group.json
@@ -19,6 +19,27 @@
"type": "choice:Points,Primitives,Edges",
"default": "Points"
},
+ {
+ "name": "Mode",
+ "type": "choice:Box,Random",
+ "default": "Box"
+ },
+ {
+ "name": "Count",
+ "type": "spinbox",
+ "default": "1",
+ "min": 1.0,
+ "max": 500.0,
+ "step": 1.0
+ },
+ {
+ "name": "Seed",
+ "type": "spinbox",
+ "default": "0",
+ "min": 0.0,
+ "max": 999.0,
+ "step": 1.0
+ },
{
"name": "Center",
"type": "float3",
diff --git a/nodes/relax.json b/nodes/relax.json
new file mode 100644
index 0000000..173fc91
--- /dev/null
+++ b/nodes/relax.json
@@ -0,0 +1,27 @@
+{
+ "name": "Relax",
+ "type": "relax",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "default": "", "type": "text" },
+ { "name": "Rest", "default": "", "type": "text" },
+ { "name": "Pin Group", "default": "", "type": "text" },
+ {
+ "name": "Stiffness",
+ "type": "slider",
+ "default": "0.50",
+ "min": 0.0,
+ "max": 1.0,
+ "step": 0.05
+ },
+ {
+ "name": "Iterations",
+ "type": "spinbox",
+ "default": "8",
+ "min": 1.0,
+ "max": 50.0,
+ "step": 1.0
+ }
+ ]
+}
diff --git a/src/app.rs b/src/app.rs
index 9cd1532..b9ef71d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2101,7 +2101,7 @@ impl State {
let dir = self.current_dir();
let Some(node) = dir.children.get(slot) else { return params };
let nt = node.node_type.to_lowercase();
- if nt != "attribute" && nt != "group" {
+ if nt != "attribute" && nt != "group" && nt != "relax" {
return params;
}
(nt, node_param_str(node, "Input", ""))
@@ -2115,6 +2115,7 @@ impl State {
("attribute", "Attribute Name") => &attrs,
("attribute", "Group") => &groups,
("group", "Group Name") => &groups,
+ ("relax", "Pin Group") => &groups,
_ => continue,
};
if row.2 == "text" && !list.is_empty() {
diff --git a/src/geometry.rs b/src/geometry.rs
index c1f6f61..4eb5957 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -486,6 +486,8 @@ pub fn generate_single_node_geometry_with_errors(
resolve_scatter_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("group") {
resolve_group_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("relax") {
+ resolve_relax_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("attribute") {
resolve_attribute_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("opencl") {
@@ -583,15 +585,55 @@ pub fn resolve_scatter_geometry(root: &FsNode, target: &FsNode, visited: &mut Ve
resolve_scatter_geometry_with_errors(root, target, visited, &mut err, &mut sim)
}
-/// The Group node: pass the input geometry through, tagging the elements
-/// selected by an axis-aligned box (Center/Size) with a per-vertex membership
-/// attribute `group:<name>` = Float(1.0). Element Type picks the selection
-/// unit over the triangle soup — Points (per vertex), Primitives (a
-/// triangle's centroid; all three vertices tag together), Edges (a triangle
-/// edge with both endpoints inside; its two vertices tag). Membership rides
-/// GVertex::attributes so it survives merges and native filters — downstream
-/// nodes consume it by reading the same attribute. Highlight tints members
-/// toward a warm accent so the group reads in the viewport.
+/// Deterministic 64-bit PRNG step (splitmix64). Node evaluation must be a
+/// pure function of the graph, so anything "random" derives from a Seed
+/// param through this — never from a real entropy source.
+fn splitmix64(state: &mut u64) -> u64 {
+ *state = state.wrapping_add(0x9E3779B97F4A7C15);
+ let mut z = *state;
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4B9B9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
+ z ^ (z >> 31)
+}
+
+/// Weld a triangle soup's coincident vertices into points: returns
+/// (point id per vertex, copies per point). Positions quantize to 1e-4 so
+/// vertices a kernel emitted from the same formula weld reliably. "Point"
+/// operations (random point groups, the relax solver) act on welded points
+/// and fan back out to every copy — moving one copy of a shared corner
+/// without its siblings would tear the surface.
+fn weld_points(positions: &[[f32; 3]]) -> (Vec<usize>, Vec<Vec<usize>>) {
+ let mut key_to_point: HashMap<(i64, i64, i64), usize> = HashMap::new();
+ let mut point_of = Vec::with_capacity(positions.len());
+ let mut copies: Vec<Vec<usize>> = Vec::new();
+ for (i, p) in positions.iter().enumerate() {
+ let key = (
+ (p[0] as f64 * 1e4).round() as i64,
+ (p[1] as f64 * 1e4).round() as i64,
+ (p[2] as f64 * 1e4).round() as i64,
+ );
+ let id = *key_to_point.entry(key).or_insert_with(|| {
+ copies.push(Vec::new());
+ copies.len() - 1
+ });
+ point_of.push(id);
+ copies[id].push(i);
+ }
+ (point_of, copies)
+}
+
+/// The Group node: pass the input geometry through, tagging the selected
+/// elements with a per-vertex membership attribute `group:<name>` =
+/// Float(1.0). Element Type picks the selection unit over the triangle
+/// soup — Points (per vertex), Primitives (a triangle; all three vertices
+/// tag together), Edges (a triangle edge; its two vertices tag). Mode picks
+/// the selector: Box selects by an axis-aligned box (Center/Size); Random
+/// draws Count elements deterministically from Seed — for Points it draws
+/// welded points (coincident vertices tag together, so "one random point"
+/// is one surface point, not one loose corner of a triangle). Membership
+/// rides GVertex::attributes so it survives merges and native filters —
+/// downstream nodes consume it by reading the same attribute. Highlight
+/// tints members toward a warm accent so the group reads in the viewport.
pub fn resolve_group_geometry_with_errors(
root: &FsNode,
target: &FsNode,
@@ -622,37 +664,81 @@ pub fn resolve_group_geometry_with_errors(
let n = geom.vertices.len();
let mut member = vec![false; n];
- match etype.as_str() {
- "primitives" => {
- for tri in 0..n / 3 {
- let b = tri * 3;
- let centroid = [
- (geom.vertices[b].pos[0] + geom.vertices[b + 1].pos[0] + geom.vertices[b + 2].pos[0]) / 3.0,
- (geom.vertices[b].pos[1] + geom.vertices[b + 1].pos[1] + geom.vertices[b + 2].pos[1]) / 3.0,
- (geom.vertices[b].pos[2] + geom.vertices[b + 1].pos[2] + geom.vertices[b + 2].pos[2]) / 3.0,
- ];
- if inside(¢roid) {
- member[b] = true;
- member[b + 1] = true;
- member[b + 2] = true;
+ let mode = node_param_str(target, "Mode", "Box").to_lowercase();
+ if mode == "random" {
+ let count = node_param_f32(target, "Count", 1.0).max(0.0) as usize;
+ // Seed offsets the stream, and the element type joins it so switching
+ // type reshuffles instead of replaying the same index sequence.
+ let mut rng = node_param_f32(target, "Seed", 0.0) as u64 ^ 0xCCE0;
+ // Partial Fisher-Yates: draw `count` distinct indices out of `m`.
+ let mut draw = |m: usize, count: usize| -> Vec<usize> {
+ let mut idx: Vec<usize> = (0..m).collect();
+ let take = count.min(m);
+ for i in 0..take {
+ let j = i + (splitmix64(&mut rng) as usize) % (m - i);
+ idx.swap(i, j);
+ }
+ idx.truncate(take);
+ idx
+ };
+ match etype.as_str() {
+ "primitives" => {
+ for tri in draw(n / 3, count) {
+ for k in 0..3 {
+ member[tri * 3 + k] = true;
+ }
}
}
- }
- "edges" => {
- for tri in 0..n / 3 {
- let b = tri * 3;
- for (a, c) in [(0usize, 1usize), (1, 2), (2, 0)] {
- if inside(&geom.vertices[b + a].pos) && inside(&geom.vertices[b + c].pos) {
- member[b + a] = true;
- member[b + c] = true;
+ "edges" => {
+ for e in draw((n / 3) * 3, count) {
+ let (tri, side) = (e / 3, e % 3);
+ member[tri * 3 + side] = true;
+ member[tri * 3 + (side + 1) % 3] = true;
+ }
+ }
+ _ => {
+ let positions: Vec<[f32; 3]> = geom.vertices.iter().map(|v| v.pos).collect();
+ let (_, copies) = weld_points(&positions);
+ for pt in draw(copies.len(), count) {
+ for &i in &copies[pt] {
+ member[i] = true;
}
}
}
}
- _ => {
- for (i, v) in geom.vertices.iter().enumerate() {
- if inside(&v.pos) {
- member[i] = true;
+ } else {
+ match etype.as_str() {
+ "primitives" => {
+ for tri in 0..n / 3 {
+ let b = tri * 3;
+ let centroid = [
+ (geom.vertices[b].pos[0] + geom.vertices[b + 1].pos[0] + geom.vertices[b + 2].pos[0]) / 3.0,
+ (geom.vertices[b].pos[1] + geom.vertices[b + 1].pos[1] + geom.vertices[b + 2].pos[1]) / 3.0,
+ (geom.vertices[b].pos[2] + geom.vertices[b + 1].pos[2] + geom.vertices[b + 2].pos[2]) / 3.0,
+ ];
+ if inside(¢roid) {
+ member[b] = true;
+ member[b + 1] = true;
+ member[b + 2] = true;
+ }
+ }
+ }
+ "edges" => {
+ for tri in 0..n / 3 {
+ let b = tri * 3;
+ for (a, c) in [(0usize, 1usize), (1, 2), (2, 0)] {
+ if inside(&geom.vertices[b + a].pos) && inside(&geom.vertices[b + c].pos) {
+ member[b + a] = true;
+ member[b + c] = true;
+ }
+ }
+ }
+ }
+ _ => {
+ for (i, v) in geom.vertices.iter().enumerate() {
+ if inside(&v.pos) {
+ member[i] = true;
+ }
}
}
}
@@ -677,6 +763,117 @@ pub fn resolve_group_geometry_with_errors(
Some(geom)
}
+/// The Relax node: an edge-length constraint solver — the organic-tissue
+/// response. Pass the input geometry through, then move every welded point
+/// toward restoring the edge lengths of the `Rest` geometry (a node name,
+/// evaluated like a second input; inside a simnet, `input1` — the previous
+/// sim state). Vertices carrying `group:<Pin Group>` are pinned: they keep
+/// their input position and push everyone else instead — chain a
+/// displacement over that group first and this node spreads it through the
+/// surface with a stiffness-shaped falloff. Iterations Gauss–Seidel passes
+/// over the unique edges; Stiffness scales each correction. With no Rest,
+/// an unresolvable Rest, or a Rest whose vertex count differs from the
+/// input's, the geometry passes through unchanged — there is nothing
+/// coherent to restore toward.
+///
+/// No visited guard here (the dispatch already pushed this node's id — the
+/// same trap Attribute documents below). Rest is evaluated as a second
+/// chain off `root`, which is legal mid-solve: the feedback stack, not the
+/// call graph, is what makes an `input` node yield the previous state.
+pub fn resolve_relax_geometry_with_errors(
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) -> Option<Geometry> {
+ let input_name = node_param_str(target, "Input", "");
+ if input_name.is_empty() {
+ return None;
+ }
+ let input_node = find_node_by_name(root, &input_name)?;
+ let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+
+ let rest_name = node_param_str(target, "Rest", "");
+ let rest_name = rest_name.trim();
+ if rest_name.is_empty() {
+ return Some(geom);
+ }
+ let Some(rest_node) = find_node_by_name(root, rest_name) else { return Some(geom) };
+ let Some(rest) = generate_single_node_geometry_with_errors(root, rest_node, visited, ocl_error, sim) else {
+ return Some(geom);
+ };
+ if rest.vertices.len() != geom.vertices.len() || geom.vertices.is_empty() {
+ return Some(geom);
+ }
+
+ let stiffness = node_param_f32(target, "Stiffness", 0.5).clamp(0.0, 1.0);
+ let iterations = node_param_f32(target, "Iterations", 8.0).max(1.0) as usize;
+ let pin = node_param_str(target, "Pin Group", "").trim().to_string();
+ let pin_attr = format!("group:{}", pin);
+
+ // Weld on REST positions: the input may already carry this step's
+ // displacement, and the weld must not split a point the pull moved.
+ let rest_pos: Vec<[f32; 3]> = rest.vertices.iter().map(|v| v.pos).collect();
+ let (point_of, copies) = weld_points(&rest_pos);
+ let m = copies.len();
+ let mut pos: Vec<Vec3> = copies.iter().map(|c| Vec3::from(geom.vertices[c[0]].pos)).collect();
+ let mut pinned = vec![false; m];
+ if !pin.is_empty() {
+ for (i, v) in geom.vertices.iter().enumerate() {
+ if v.attributes.contains_key(&pin_attr) {
+ pinned[point_of[i]] = true;
+ }
+ }
+ }
+
+ // Unique edges over welded points; rest length from the rest shape.
+ let mut edges: Vec<(usize, usize, f32)> = Vec::new();
+ let mut seen = std::collections::HashSet::new();
+ for tri in 0..point_of.len() / 3 {
+ let b = tri * 3;
+ for (a, c) in [(0usize, 1usize), (1, 2), (2, 0)] {
+ let (pa, pc) = (point_of[b + a], point_of[b + c]);
+ if pa == pc {
+ continue;
+ }
+ let key = (pa.min(pc), pa.max(pc));
+ if seen.insert(key) {
+ let ra = Vec3::from(rest.vertices[copies[key.0][0]].pos);
+ let rc = Vec3::from(rest.vertices[copies[key.1][0]].pos);
+ edges.push((key.0, key.1, (rc - ra).length()));
+ }
+ }
+ }
+
+ for _ in 0..iterations {
+ for &(a, b, rest_len) in &edges {
+ let d = pos[b] - pos[a];
+ let len = d.length();
+ if len < 1e-6 {
+ continue;
+ }
+ let corr = d * ((len - rest_len) / len * 0.5 * stiffness);
+ match (pinned[a], pinned[b]) {
+ (false, false) => {
+ pos[a] += corr;
+ pos[b] -= corr;
+ }
+ (true, false) => pos[b] -= corr * 2.0,
+ (false, true) => pos[a] += corr * 2.0,
+ (true, true) => {}
+ }
+ }
+ }
+
+ for (pt, c) in copies.iter().enumerate() {
+ for &i in c {
+ geom.vertices[i].pos = pos[pt].to_array();
+ }
+ }
+ Some(geom)
+}
+
/// The Attribute node: pass the input geometry through, running one
/// attribute edit over it. Operation picks the edit —
///
@@ -1556,6 +1753,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
|| nt == "output"
|| nt == "scatter"
|| nt == "group"
+ || nt == "relax"
|| nt == "attribute"
|| nt == "simnet"
}
@@ -1680,6 +1878,15 @@ pub fn network_sphere_vertices_with_errors(
out.merge(geom);
}
}
+ } else if node.node_type.eq_ignore_ascii_case("relax") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_relax_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ out.merge(geom);
+ }
+ }
} else if node.node_type.eq_ignore_ascii_case("opencl") {
let _idx = *count;
*count += 1;
@@ -2855,4 +3062,132 @@ mod simnet_tests {
assert_eq!(interior.vertices.len(), 2 * SPHERE,
"input draws the seed and output draws the chain result");
}
+
+ fn eval(root: &FsNode, name: &str) -> Geometry {
+ let target = root.children.iter().find(|c| c.name == name).unwrap();
+ let mut visited = Vec::new();
+ let mut err = None;
+ let mut cache = SimCache::default();
+ let mut sim = EvalSim::new(0, 0, &mut cache);
+ generate_single_node_geometry_with_errors(root, target, &mut visited, &mut err, &mut sim)
+ .expect("node evaluates")
+ }
+
+ /// Random point groups draw from WELDED points: one random point tags
+ /// every coincident vertex copy, deterministically from Seed.
+ #[test]
+ fn test_group_random_point_is_welded_and_deterministic() {
+ let make_root = |seed: &str| {
+ let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+ let group = node(
+ "id-group",
+ "Group 1",
+ "group",
+ vec![
+ param("Input", "Sphere 1"),
+ param("Group Name", "pull"),
+ param("Mode", "Random"),
+ param("Count", "1"),
+ param("Seed", seed),
+ param("Highlight", "false"),
+ ],
+ vec![],
+ );
+ node("id-root", "root", "node", vec![], vec![sphere, group])
+ };
+
+ let g = eval(&make_root("7"), "Group 1");
+ let tagged: Vec<usize> = (0..g.vertices.len())
+ .filter(|&i| g.vertices[i].attributes.contains_key("group:pull"))
+ .collect();
+ assert!(!tagged.is_empty(), "random mode selected nothing");
+ let anchor = g.vertices[tagged[0]].pos;
+ let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4);
+ for &i in &tagged {
+ assert!(near(g.vertices[i].pos, anchor), "one random point must be ONE welded position");
+ }
+ for (i, v) in g.vertices.iter().enumerate() {
+ if near(v.pos, anchor) {
+ assert!(tagged.contains(&i), "a coincident copy was left untagged (would tear the surface)");
+ }
+ }
+
+ let again = eval(&make_root("7"), "Group 1");
+ let tagged_again: Vec<usize> = (0..again.vertices.len())
+ .filter(|&i| again.vertices[i].attributes.contains_key("group:pull"))
+ .collect();
+ assert_eq!(tagged, tagged_again, "same Seed must select the same point");
+ }
+
+ /// The tissue chain: pull one random point with an Attribute Pos edit,
+ /// then Relax against the pre-pull shape with the point pinned — the
+ /// point keeps its pulled position, neighbors follow part of the way.
+ #[test]
+ fn test_relax_spreads_a_pinned_pull() {
+ let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+ let group = node(
+ "id-group",
+ "Group 1",
+ "group",
+ vec![
+ param("Input", "Sphere 1"),
+ param("Group Name", "pull"),
+ param("Mode", "Random"),
+ param("Count", "1"),
+ param("Seed", "3"),
+ param("Highlight", "false"),
+ ],
+ vec![],
+ );
+ let pull = node(
+ "id-pull",
+ "Pull 1",
+ "attribute",
+ vec![
+ param("Input", "Group 1"),
+ param("Operation", "Modify"),
+ param("Attribute Name", "Pos"),
+ param("Value", "0.00:0.50:0.00"),
+ param("Combine", "Add"),
+ param("Group", "pull"),
+ ],
+ vec![],
+ );
+ let relax = node(
+ "id-relax",
+ "Relax 1",
+ "relax",
+ vec![
+ param("Input", "Pull 1"),
+ param("Rest", "Group 1"),
+ param("Pin Group", "pull"),
+ param("Stiffness", "0.50"),
+ param("Iterations", "8"),
+ ],
+ vec![],
+ );
+ let root = node("id-root", "root", "node", vec![], vec![sphere, group, pull, relax]);
+
+ let base = eval(&root, "Group 1");
+ let pulled = eval(&root, "Pull 1");
+ let relaxed = eval(&root, "Relax 1");
+ assert_eq!(relaxed.vertices.len(), base.vertices.len());
+
+ let dist = |a: [f32; 3], b: [f32; 3]| -> f32 {
+ a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::<f32>().sqrt()
+ };
+ let mut max_response: f32 = 0.0;
+ for i in 0..base.vertices.len() {
+ let moved = dist(relaxed.vertices[i].pos, base.vertices[i].pos);
+ if base.vertices[i].attributes.contains_key("group:pull") {
+ assert!(dist(relaxed.vertices[i].pos, pulled.vertices[i].pos) < 1e-4,
+ "the pinned point must keep its pulled position");
+ } else {
+ assert!(moved <= 0.5 + 1e-3, "a neighbor overshot the pull itself");
+ max_response = max_response.max(moved);
+ }
+ }
+ assert!(max_response > 0.01,
+ "no neighbor responded to the pull (relax did nothing), max {max_response}");
+ }
}