graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(phase4): a native grid, and one polygon for four create operators
Grid is a welded sheet of quads. Plane already makes a grid, but as an OpenCL
subnet: it costs a kernel compile and emits a corner list that welds on the way
back, so it cannot feed a remesh or a diffusion without a round trip. This one
can. Plane stays, because a kernel generator is a useful thing to have an
example of.
Polygon is im_square, im_triangle, im_star and the circle nobody got round to,
because they are one shape with one parameter varying: three sides is a
triangle, four a square, thirty-two a circle, and a non-zero Inner Radius makes
any of them a star by pulling every other corner inward.
Filled, it fans from a CENTRE POINT rather than from the first corner. A corner
fan is fine on a convex polygon and wrong on a star — the triangles cross the
concave notches and the shape renders as its own convex hull. The test asserts
every triangle touches the hub, and the render shows the notches.
Both are placed at a Center parameter rather than by their position in the
graph. The older generators offset themselves by an index so several do not
stack, which is surprising the first time and unadjustable afterwards; adding
the new types to that index would also have moved every existing sphere.
The grid wound DOWNWARD on the first try — the quad order gave -Y — which the
test caught and which is now asserted for the polygon too, since laying out a
face by hand is exactly where this goes wrong.
Verified by rendering the two composing with the Phase 4 measuring set: a star
measured against a grid by Distance, turned into a selection by Group's
Attribute mode, and lifted by Soft Transform's falloff, which embosses the
star's outline into the sheet.
Co-Authored-By: Claude Opus 5 <[email protected]>
nodes/grid.json | 13 +++++++
nodes/polygon.json | 13 +++++++
shapeshifter.md | 11 ++++--
src/geometry.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
src/main.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 240 insertions(+), 4 deletions(-)
diff --git a/nodes/grid.json b/nodes/grid.json
new file mode 100644
index 0000000..144fbf1
--- /dev/null
+++ b/nodes/grid.json
@@ -0,0 +1,13 @@
+{
+ "name": "Grid",
+ "type": "grid",
+ "inputs": 0,
+ "outputs": 1,
+ "params": [
+ { "name": "Rows", "type": "spinbox", "default": "10", "min": 1.0, "max": 500.0, "step": 1.0 },
+ { "name": "Columns", "type": "spinbox", "default": "10", "min": 1.0, "max": 500.0, "step": 1.0 },
+ { "name": "Width", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05 },
+ { "name": "Length", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05 },
+ { "name": "Center", "type": "text", "default": "0.00:0.00:0.00" }
+ ]
+}
diff --git a/nodes/polygon.json b/nodes/polygon.json
new file mode 100644
index 0000000..61294d3
--- /dev/null
+++ b/nodes/polygon.json
@@ -0,0 +1,13 @@
+{
+ "name": "Polygon",
+ "type": "polygon",
+ "inputs": 0,
+ "outputs": 1,
+ "params": [
+ { "name": "Sides", "type": "spinbox", "default": "4", "min": 3.0, "max": 256.0, "step": 1.0 },
+ { "name": "Radius", "type": "slider", "default": "0.50", "min": 0.01, "max": 10.0, "step": 0.01 },
+ { "name": "Inner Radius", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.01 },
+ { "name": "Fill", "type": "choice:true,false", "default": "true" },
+ { "name": "Center", "type": "text", "default": "0.00:0.00:0.00" }
+ ]
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index 327d9dd..90cbf98 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -296,9 +296,14 @@ Touches: a new `remesh.rs`, `geometry.rs`, `nodes/*.json`.
> and `im_curl` into one node: they are the same shape, a transform whose
> strength varies along an axis.
>
-> Remaining: Select (which is `group` with more criteria, not a new node), the
-> Create primitives, and the long tail — which the audit prunes hard (version
-> forks, the dead-on-arrival nodes, the Houdini-SOP wrappers).
+> The Create set grew a native `grid` (welded points, no kernel, so it can
+> feed a remesh or a diffusion directly) and a `polygon` covering `im_square`,
+> `im_triangle`, `im_star` and the circle nobody got round to — one shape with
+> one parameter varying.
+>
+> Remaining: Select (which is `group` with more criteria, not a new node), and
+> the long tail — which the audit prunes hard (version forks, the
+> dead-on-arrival nodes, the Houdini-SOP wrappers).
The `im_*` family, which is the part that looks biggest and is actually the
easiest — ninety nodes, most of them a screenful once points and prims exist.
diff --git a/src/geometry.rs b/src/geometry.rs
index 20abdad..8287301 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -722,6 +722,10 @@ pub fn generate_single_node_geometry_with_errors(
Some(box_detail(start, end, thickness))
} else if target.node_type.eq_ignore_ascii_case("curve") {
Some(curve_detail(target))
+ } else if target.node_type.eq_ignore_ascii_case("grid") {
+ Some(grid_detail(target))
+ } else if target.node_type.eq_ignore_ascii_case("polygon") {
+ Some(polygon_detail(target))
} 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);
@@ -1664,7 +1668,91 @@ pub fn resolve_cull_geometry_with_errors(
Some(geom)
}
-/// The Transfer node: carry attributes from one geometry onto another.
+/// The Grid node: a flat sheet of quads in the XZ plane.
+///
+/// Native, where Plane is an OpenCL subnet. Both make a grid; this one costs
+/// no kernel compile, produces welded points rather than a corner list that
+/// has to be welded on the way back, and can therefore be the input to a
+/// remesh or a diffusion without a round trip. Plane stays because a kernel
+/// generator is a useful thing to have an example of.
+///
+/// Placed at Center rather than by its position in the graph. The older
+/// generators offset themselves by an index so several of them do not stack,
+/// which is surprising the first time and unadjustable after; a parameter says
+/// what it is.
+pub fn grid_detail(target: &FsNode) -> Detail {
+ let rows = node_param_f32(target, "Rows", 10.0).clamp(1.0, 500.0) as usize;
+ let cols = node_param_f32(target, "Columns", 10.0).clamp(1.0, 500.0) as usize;
+ let width = node_param_f32(target, "Width", 1.0).max(1e-4);
+ let length = node_param_f32(target, "Length", 1.0).max(1e-4);
+ let centre = node_param_vec3(target, "Center", Vec3::ZERO);
+
+ let mut d = Detail::new();
+ for r in 0..=rows {
+ for c in 0..=cols {
+ let u = c as f32 / cols as f32 - 0.5;
+ let v = r as f32 / rows as f32 - 0.5;
+ d.add_point(centre + Vec3::new(u * width, 0.0, v * length));
+ }
+ }
+ let at = |r: usize, c: usize| (r * (cols + 1) + c) as u32;
+ // Wound counter-clockwise seen from +Y, so the plain cross points up —
+ // the same convention the sphere and the template meshes follow.
+ for r in 0..rows {
+ for c in 0..cols {
+ d.add_prim(&[at(r, c), at(r + 1, c), at(r + 1, c + 1), at(r, c + 1)]);
+ }
+ }
+ d
+}
+
+/// The Polygon node: a regular n-gon, a star, or a disc.
+///
+/// One node for four of the plugin's Create operators — `im_square`,
+/// `im_triangle`, `im_star` and the circle nobody got round to — because they
+/// are one shape with one parameter varying. Three sides is a triangle, four
+/// is a square, thirty-two is a circle, and a non-zero Inner Radius makes any
+/// of them a star by pulling every other point inward.
+///
+/// Filled fans from a CENTRE POINT rather than from the first corner. A fan
+/// from a corner is fine on a convex polygon and wrong on a star: the
+/// triangles cross the concave notches and the shape renders as its own convex
+/// hull.
+pub fn polygon_detail(target: &FsNode) -> Detail {
+ let sides = node_param_f32(target, "Sides", 4.0).clamp(3.0, 256.0) as usize;
+ let radius = node_param_f32(target, "Radius", 0.5).max(1e-4);
+ let inner = node_param_f32(target, "Inner Radius", 0.0).max(0.0);
+ let fill = node_param_str(target, "Fill", "true") != "false";
+ let centre = node_param_vec3(target, "Center", Vec3::ZERO);
+
+ let mut d = Detail::new();
+ // A star alternates between the two radii, so it has twice the corners.
+ let star = inner > 0.0;
+ let corners = if star { sides * 2 } else { sides };
+ let ring: Vec<u32> = (0..corners)
+ .map(|i| {
+ let a = std::f32::consts::TAU * i as f32 / corners as f32;
+ let r = if star && i % 2 == 1 { inner } else { radius };
+ d.add_point(centre + Vec3::new(r * a.cos(), 0.0, -r * a.sin()))
+ })
+ .collect();
+
+ if fill {
+ let hub = d.add_point(centre);
+ for i in 0..corners {
+ d.add_prim(&[hub, ring[i], ring[(i + 1) % corners]]);
+ }
+ } else {
+ // Two-point primitives: the outline as edges, which Detail's topology
+ // reads as a closed loop and nothing tries to shade.
+ for i in 0..corners {
+ d.add_prim(&[ring[i], ring[(i + 1) % corners]]);
+ }
+ }
+ d
+}
+
+/// The Transfer node: carry attributes from one geometry onto another./// The Transfer node: carry attributes from one geometry onto another.
///
/// How a field outlives the geometry it was defined on. A remesh keeps values
/// for the points that survived, but a chain that REBUILDS — a kernel
@@ -4697,6 +4785,8 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
nt == "sphere"
|| nt == "line"
|| nt == "curve"
+ || nt == "grid"
+ || nt == "polygon"
|| nt == "points"
|| nt == "transform"
|| nt == "opencl"
@@ -4797,6 +4887,18 @@ pub fn network_sphere_vertices_with_errors(
let end = start + Vec3::new(0.0, length, 0.0);
out.merge(&box_detail(start, end, thickness));
}
+ } else if node.node_type.eq_ignore_ascii_case("grid") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ out.merge(&grid_detail(node));
+ }
+ } else if node.node_type.eq_ignore_ascii_case("polygon") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ out.merge(&polygon_detail(node));
+ }
} else if node.node_type.eq_ignore_ascii_case("curve") {
// Absolute world coordinates: no grid-index placement, and
// `count` untouched so find_sphere_index stays aligned.
diff --git a/src/main.rs b/src/main.rs
index cadf9b7..29b174d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3364,6 +3364,109 @@ mod tests {
}
}
+ #[test]
+ fn test_grid_is_a_welded_sheet_wound_upward() {
+ let root = modelling_root(
+ "1.0",
+ vec![phase3_node(
+ "grid",
+ &[("Rows", "4"), ("Columns", "6"), ("Width", "2.00"), ("Length", "3.00")],
+ )],
+ );
+ let (g, err) = eval_node(&root, "grid 1");
+ assert!(err.is_none(), "{err:?}");
+
+ // Welded, not a corner list: 5 x 7 points for 4 x 6 quads. That is the
+ // difference from the Plane subnet, whose kernel emits corners that
+ // have to be welded on the way back.
+ assert_eq!(g.num_points(), 5 * 7);
+ assert_eq!(g.num_prims(), 4 * 6);
+ for prim in 0..g.num_prims() {
+ assert_eq!(g.prim_points(prim).len(), 4, "prim {prim} is not a quad");
+ }
+
+ // Sized by its parameters, centred where it was told.
+ let (lo, hi) = g.bounds().unwrap();
+ assert!(((hi - lo) - Vec3::new(2.0, 0.0, 3.0)).length() < 1e-5, "{:?}", hi - lo);
+ assert!(((lo + hi) * 0.5).length() < 1e-5, "not centred: {:?}", (lo + hi) * 0.5);
+
+ // Wound so the plain cross points up, like every other generator.
+ for n in crate::geometry::point_normals(&g) {
+ assert!(n.y > 0.99, "a face points {n:?} rather than up");
+ }
+
+ // An interior point has four neighbours, which is what says the sheet
+ // is one surface rather than loose quads.
+ let interior = (0..g.num_points())
+ .find(|&p| g.point_neighbours(p).len() == 4)
+ .expect("no interior point");
+ assert_eq!(g.point_prims(interior).len(), 4);
+ }
+
+ #[test]
+ fn test_polygon_is_four_create_operators_with_one_parameter_varying() {
+ let build = |params: &[(&str, &str)]| {
+ let mut ps = vec![("Radius", "1.00")];
+ ps.extend_from_slice(params);
+ let root = modelling_root("1.0", vec![phase3_node("polygon", &ps)]);
+ eval_node(&root, "polygon 1").0
+ };
+
+ // Three sides is a triangle, four a square, thirty-two a circle: the
+ // same shape with one number changed.
+ let tri = build(&[("Sides", "3")]);
+ assert_eq!(tri.num_prims(), 3, "a filled triangle is three fan triangles");
+ assert_eq!(tri.num_points(), 4, "three corners and a hub");
+ let circle = build(&[("Sides", "32")]);
+ assert_eq!(circle.num_points(), 33);
+
+ // A circle's corners all sit at the radius; a square's do too.
+ for d in [&circle, &build(&[("Sides", "4")])] {
+ for p in 0..d.num_points() {
+ let r = d.pos(p).length();
+ assert!(r < 1.0 + 1e-4, "point {p} is outside the radius at {r}");
+ }
+ assert!((d.bounds().unwrap().1.y).abs() < 1e-6, "the polygon is not flat");
+ // Wound so the plain cross points up, like the grid and the
+ // sphere. The grid got this backwards on the first try, so it is
+ // worth asserting wherever a generator lays out a face by hand.
+ for n in crate::geometry::point_normals(d) {
+ assert!(n.y > 0.99, "a face points {n:?} rather than up");
+ }
+ }
+
+ // A star alternates the two radii, so it has twice the corners and
+ // half of them sit on the inner circle.
+ let star = build(&[("Sides", "5"), ("Inner Radius", "0.40")]);
+ assert_eq!(star.num_prims(), 10);
+ let inner = (0..star.num_points())
+ .filter(|&p| (star.pos(p).length() - 0.4).abs() < 1e-4)
+ .count();
+ assert_eq!(inner, 5, "the notches are not on the inner radius");
+
+ // Filled from a CENTRE point, not fanned from a corner: on a star a
+ // corner fan crosses the notches and the shape renders as its convex
+ // hull. Every triangle here touches the hub.
+ let hub = star.num_points() - 1;
+ assert!(star.pos(hub).length() < 1e-6, "the hub is not at the centre");
+ for prim in 0..star.num_prims() {
+ assert!(
+ star.prim_points(prim).contains(&(hub as u32)),
+ "prim {prim} does not touch the hub, so the fill fans from a corner"
+ );
+ }
+
+ // Unfilled is the outline: one two-point primitive per edge, a closed
+ // loop, and nothing to shade.
+ let ring = build(&[("Sides", "6"), ("Fill", "false")]);
+ assert_eq!(ring.num_points(), 6, "no hub when there is no fill");
+ assert_eq!(ring.num_prims(), 6);
+ assert_eq!(ring.edges().len(), 6, "the outline closes");
+ for p in 0..ring.num_points() {
+ assert_eq!(ring.point_neighbours(p).len(), 2, "point {p} is not on a loop");
+ }
+ }
+
// ---- The parameter pane's conditional rows ----
fn pd(name: &str, value: &str, show_when: &str) -> crate::app::ParamDef {