graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the Normal node writes vertex normals, cusped
A Class row chooses the points' normals, as before, or a normal per
corner on the vertices, averaged over the faces around the point that
lie within the Cusp Angle of the corner's own. Smooth shading lights a
corner by its own normal where the geometry carries one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 33 ++++++++++++++++++++--
nodes/normal.json | 2 ++
src/geometry.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++--
src/main.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++
src/render.rs | 2 +-
5 files changed, 185 insertions(+), 7 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index c53de60..9b0a282 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2231,9 +2231,8 @@ the size slider re-sizes both without an evaluation. **A vertex's normal
is its `N` attribute where the detail carries a Float3 one on its
vertices, and its primitive's normal where it does not** — the normal of
this corner of this face, where a point's is the average over the faces
-around it. No node here writes vertex normals yet, so today the whiskers
-show the faceting: the corners of one face agree, and a shared point
-wears a fan of them. `scene_element_overlays` takes an `ElementOverlays`
+around it. Without the attribute the whiskers show the faceting: the
+corners of one face agree, and a shared point wears a fan of them. `scene_element_overlays` takes an `ElementOverlays`
of what is wanted and returns an `ElementOverlayGeometry`.
`the_viewport_menu_groups_its_display_rows` holds the
@@ -2355,6 +2354,34 @@ show. `a_point_number_is_dimmed_by_the_fill_in_front_of_it` is the test;
the whiskers' order has none, being a draw list only a renderer reads, and
was checked in a shadow session before and after.
+### The Normal node writes point or vertex normals
+
+`normal` has a **Class** row (since 2026-09-29): `Points`, what it always
+wrote, or `Vertices`, a normal per CORNER on the detail's vertex store,
+with a **Cusp Angle** (shown for Vertices, 0–180, default 60). It is a
+class of the one node and not a node of its own, for the reason
+`gem_graph` was not ported: two nodes that compute the same thing into two
+stores are one node with a choice. A node without the row is one from
+before it and writes the points'; the template merge gives a saved
+instance the row at `Points`.
+
+`geometry::vertex_normals(geom, cusp)` is the sum, for each corner, of the
+face normals around its point that lie within the cusp angle of its OWN
+face's — so faces that turn less than the angle from each other are
+averaged and read smooth, and those that turn more keep to their own side
+and the edge reads hard, which a point's one normal cannot say. At 180 it
+is `point_normals` term for term (the same unnormalized cross, so the two
+weigh faces alike); at 0 the face's alone.
+
+Two things read a Float3 `N` on the vertices (`own_vertex_normals`, which
+also asks that it is as long as the vertices are): the Show Vertex Normals
+overlay, and **smooth shading**, which lights each corner by its own
+normal where there is one (`smooth_lit_vertices`) and by its point's where
+there is not — so a cusp is visible in the fill, not only in the whiskers.
+Flat shading is the shader's and reads neither. The attribute rides the
+scene's merged `Detail` to both. `the_normal_node_writes_cusped_vertex_normals`
+is the test, on a box, whose faces meet at 90 degrees.
+
### Pull arrows
While the params pane shows an Attribute node that writes `Pos`
diff --git a/nodes/normal.json b/nodes/normal.json
index b380826..b4b8f73 100644
--- a/nodes/normal.json
+++ b/nodes/normal.json
@@ -5,7 +5,9 @@
"outputs": 1,
"params": [
{ "name": "Input", "type": "node", "default": "" },
+ { "name": "Class", "type": "choice:Points,Vertices", "default": "Points" },
{ "name": "Attribute", "type": "attribute", "default": "N" },
+ { "name": "Cusp Angle", "type": "slider", "default": "60", "min": 0.0, "max": 180.0, "step": 1.0, "show_when": "Class == Vertices" },
{ "name": "Flip", "type": "choice:false,true", "default": "false" }
]
}
diff --git a/src/geometry.rs b/src/geometry.rs
index 4748d19..5f252e7 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -254,16 +254,26 @@ pub fn shade_factor(n: Vec3) -> f32 {
pub fn smooth_lit_vertices(d: &Detail) -> Vec<Vertex3D> {
let normals = point_normals(d);
let lit: Vec<f32> = normals.iter().map(|n| shade_factor(*n)).collect();
+ // Vertex normals, where the geometry carries them, are the normals it
+ // asked to be shaded by: a corner is lit by its own, so a crease the
+ // Normal node cusped reads hard and the rest smooth.
+ let own = own_vertex_normals(d);
let mut out = Vec::new();
for prim in 0..d.num_prims() {
let pts = d.prim_points(prim);
if pts.len() < 3 {
continue;
}
+ let first = d.prim_verts(prim).start;
for i in 1..pts.len() - 1 {
- for &p in &[pts[0], pts[i], pts[i + 1]] {
- let p = p as usize;
- let k = lit.get(p).copied().unwrap_or(1.0);
+ for &corner in &[0, i, i + 1] {
+ let p = pts[corner] as usize;
+ let by_vertex = own
+ .and_then(|n| n.get(first + corner))
+ .map(|n| n.as_vec3())
+ .filter(|n| n.length_squared() > 1e-12)
+ .map(|n| shade_factor(n.normalize()));
+ let k = by_vertex.unwrap_or_else(|| lit.get(p).copied().unwrap_or(1.0));
let c = d.color(p);
out.push(Vertex3D { position: d.pos(p).to_array(), color: [c[0] * k, c[1] * k, c[2] * k] });
}
@@ -2010,6 +2020,15 @@ pub fn resolve_normal_geometry_with_errors(
}
let flip = node_param_bool(target, "Flip", false);
let sign = if flip { -1.0 } else { 1.0 };
+ // A node without the Class row is one from before it, and writes the
+ // points' as it always did.
+ if node_param_str(target, "Class", "Points").trim().eq_ignore_ascii_case("Vertices") {
+ let cusp = node_param_f32(target, "Cusp Angle", 60.0);
+ let normals: Vec<[f32; 3]> = vertex_normals(&geom, cusp).iter().map(|n| (*n * sign).to_array()).collect();
+ geom.verts_mut().create(&name, AttribValue::Float3([0.0; 3]));
+ let _ = geom.verts_mut().insert(&name, AttribData::Float3(normals));
+ return Some(geom);
+ }
let normals: Vec<[f32; 3]> = point_normals(&geom).iter().map(|n| (*n * sign).to_array()).collect();
geom.points_mut().create(&name, AttribValue::Float3([0.0; 3]));
let _ = geom.points_mut().insert(&name, AttribData::Float3(normals));
@@ -3890,6 +3909,64 @@ pub fn point_normals(geom: &Detail) -> Vec<Vec3> {
.collect()
}
+/// Vertex normals with a cusp: for each vertex — a corner of one primitive
+/// — the normalized sum of the face normals of the primitives around its
+/// point that lie within `cusp_degrees` of its OWN primitive's.
+///
+/// That is what a vertex normal can say and a point normal cannot: the
+/// faces either side of a crease share the crease's points, and a point
+/// has one normal to give them both. Where the faces around a point turn
+/// less than the cusp angle from each other they are averaged and the
+/// surface reads smooth; where they turn more, each keeps to its own side
+/// and the edge reads hard. At 180 every face around the point is in, and a
+/// vertex's normal is its point's (`point_normals`, the same sum, term for
+/// term); at 0 it is its primitive's alone.
+///
+/// A primitive of fewer than three points has no face, and its vertices
+/// get zero.
+pub fn vertex_normals(geom: &Detail, cusp_degrees: f32) -> Vec<Vec3> {
+ // The face normal as `point_normals` takes it, unnormalized, so the two
+ // weigh the faces alike.
+ let faces: Vec<Vec3> = (0..geom.num_prims())
+ .map(|prim| {
+ let pts = geom.prim_points(prim);
+ if pts.len() < 3 {
+ return Vec3::ZERO;
+ }
+ let (a, b, c) = (geom.pos(pts[0] as usize), geom.pos(pts[1] as usize), geom.pos(pts[2] as usize));
+ let n = (b - a).cross(c - a);
+ if n.length_squared() > 1e-12 { n } else { Vec3::ZERO }
+ })
+ .collect();
+ let units: Vec<Vec3> = faces.iter().map(|n| n.normalize_or_zero()).collect();
+ // A hair of slack, so that faces exactly at the angle are in and 180
+ // takes faces that oppose each other outright.
+ let limit = cusp_degrees.clamp(0.0, 180.0).to_radians().cos() - 1e-5;
+ let mut out = vec![Vec3::ZERO; geom.num_verts()];
+ for prim in 0..geom.num_prims() {
+ let own = units[prim];
+ if own == Vec3::ZERO {
+ continue;
+ }
+ for (v, &p) in geom.prim_verts(prim).zip(geom.prim_points(prim)) {
+ let mut sum = Vec3::ZERO;
+ for &around in geom.point_prims(p as usize) {
+ if units[around as usize].dot(own) >= limit {
+ sum += faces[around as usize];
+ }
+ }
+ out[v] = sum.normalize_or_zero();
+ }
+ }
+ out
+}
+
+/// The scene's vertex normals, where it carries them: a Float3 `N` on its
+/// vertices, which is what the Normal node's Vertices class writes.
+pub fn own_vertex_normals(d: &Detail) -> Option<&AttribData> {
+ d.verts().get("N").filter(|n| n.ty().components() == 3 && n.len() == d.num_verts())
+}
+
/// Which points a neighbourhood operator treats as a point's neighbours.
enum Hood {
/// Points reachable within N edge rings. The mesh's own connectivity, and
diff --git a/src/main.rs b/src/main.rs
index 3b0551e..512c88a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9128,6 +9128,78 @@ mod tests {
assert!(f.points().value("N", 0).unwrap().as_vec3().dot(radial) < 0.0);
}
+ /// The Normal node's Vertices class writes a normal per CORNER, cusped:
+ /// the faces around a point that turn less than the Cusp Angle from a
+ /// corner's own face are averaged into it, the rest are left out. On a
+ /// box, whose faces meet at 90 degrees, 60 keeps every corner to its
+ /// own face and 120 rounds them into the points' normals; the points'
+ /// own attribute is not written, and smooth shading lights each corner
+ /// by what the node wrote.
+ #[test]
+ fn the_normal_node_writes_cusped_vertex_normals() {
+ let mut d = Detail::new();
+ let p: Vec<u32> = (0..8)
+ .map(|i| d.add_point(Vec3::new((i & 1) as f32, ((i >> 1) & 1) as f32, ((i >> 2) & 1) as f32)))
+ .collect();
+ // Six quads, wound to face outward.
+ for q in [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]] {
+ d.add_prim(&q.map(|i| p[i]));
+ }
+ assert!(d.is_closed());
+ let face = |prim: usize| {
+ let pts = d.prim_points(prim);
+ (d.pos(pts[1] as usize) - d.pos(pts[0] as usize)).cross(d.pos(pts[2] as usize) - d.pos(pts[0] as usize)).normalize()
+ };
+ let points = crate::geometry::point_normals(&d);
+
+ let hard = crate::geometry::vertex_normals(&d, 60.0);
+ let soft = crate::geometry::vertex_normals(&d, 120.0);
+ let all = crate::geometry::vertex_normals(&d, 180.0);
+ let none = crate::geometry::vertex_normals(&d, 0.0);
+ assert_eq!(hard.len(), d.num_verts());
+ for prim in 0..d.num_prims() {
+ for (v, &pt) in d.prim_verts(prim).zip(d.prim_points(prim)) {
+ assert!((hard[v] - face(prim)).length() < 1e-5, "vertex {v}: a corner under the cusp keeps to its face");
+ assert!((none[v] - face(prim)).length() < 1e-5);
+ assert!((soft[v] - points[pt as usize]).length() < 1e-5, "vertex {v}: over it, the faces around the point");
+ assert!((all[v] - points[pt as usize]).length() < 1e-5);
+ }
+ }
+
+ // Through the node.
+ let root = modelling_root(
+ "1.0",
+ vec![phase3_node("normal", &[("Input", "sphere 1"), ("Class", "Vertices"), ("Cusp Angle", "180"), ("Flip", "true")])],
+ );
+ let (g, err) = eval_node(&root, "normal 1");
+ assert!(err.is_none(), "{err:?}");
+ assert!(!g.points().has("N"), "the points' is not written");
+ let n = crate::geometry::own_vertex_normals(&g).expect("a normal a vertex");
+ assert_eq!(n.len(), g.num_verts());
+ let of_points = crate::geometry::point_normals(&g);
+ for (v, &pt) in g.vert_points().iter().enumerate() {
+ let got = n.get(v).unwrap().as_vec3();
+ assert!((got + of_points[pt as usize]).length() < 1e-4, "vertex {v}: flipped, and its point's at 180");
+ }
+
+ // Smooth shading lights a corner by its own normal: cusped to the
+ // faces, every corner of a face is lit alike, which point normals
+ // on a box never are.
+ let mut cusped = d.clone();
+ cusped.verts_mut().create("N", crate::detail::AttribValue::Float3([0.0; 3]));
+ cusped
+ .verts_mut()
+ .insert("N", crate::detail::AttribData::Float3(hard.iter().map(|n| n.to_array()).collect()))
+ .unwrap();
+ let lit = crate::geometry::smooth_lit_vertices(&cusped);
+ let plain = crate::geometry::smooth_lit_vertices(&d);
+ assert_eq!(lit.len(), plain.len());
+ for tri in lit.chunks(3) {
+ assert!(tri.iter().all(|c| (c.color[0] - tri[0].color[0]).abs() < 1e-6), "one face, one light");
+ }
+ assert!(plain.chunks(3).any(|tri| tri.iter().any(|c| (c.color[0] - tri[0].color[0]).abs() > 1e-3)));
+ }
+
#[test]
fn test_bounds_measures_into_detail_attributes() {
let root = modelling_root(
diff --git a/src/render.rs b/src/render.rs
index 47a3dcb..cb40d96 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1491,7 +1491,7 @@ pub(crate) fn scene_element_overlays(
}
let to_linear = |c: [u8; 3]| cce_ui::colors::to_linear_rgb(c.map(|c| c as f32 / 255.0));
let (prim_color, vertex_color) = (to_linear(PRIM_LABEL_COLOR), to_linear(VERTEX_LABEL_COLOR));
- let own_normals = geom.verts().get("N").filter(|n| n.ty().components() == 3);
+ let own_normals = crate::geometry::own_vertex_normals(geom);
let by_vertex = want.vertex_numbers || want.vertex_markers || want.vertex_normals;
for prim in 0..geom.num_prims() {
let pts = geom.prim_points(prim);