git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit6a22b6e8d3e341671a02dbc87e0a3b2ff96f371f
parentf8322802e4
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 17:27
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);