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

commit58547ba7288e6c3aa2036e709d1e0777547cf586
parentfbb83d9e49
authorLucas Galante <[email protected]>
date2026-08-25 10:58
feat: meta Point Normals — per-node normal whiskers

Fourth meta pref: Point Normals draws a thin cyan whisker from each
distinct point along its smooth vertex normal, length 4x the Guides
Point Marker Size. Normals come from topology — per position, the
normalized sum of touching triangles' face normals, with the cross
negated per this repo's winding convention (kernel outputs' Norm
attribute is a default up-vector, useless for display). Whisker width is
deliberately fixed at 1: Wire Width is wireframe styling, not a normals
choice. The sphere test asserts every whisker tip sits farther from the
center than its base, pinning the outwardness convention.

 src/app.rs    | 28 ++++++++++++++++++++++++---
 src/main.rs   | 29 +++++++++++++++++++++-------
 src/render.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++------
 3 files changed, 103 insertions(+), 16 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 0dfaba8..d962c90 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -327,9 +327,12 @@ pub fn ensure_meta_on(node: &mut FsNode) {
             });
         }
         let meta = node.children.iter_mut().find(|c| c.node_type == "meta").unwrap();
-        for (name, default) in
-            [("Point Markers", "false"), ("Point Numbers", "false"), ("Wireframe", "false")]
-        {
+        for (name, default) in [
+            ("Point Markers", "false"),
+            ("Point Numbers", "false"),
+            ("Point Normals", "false"),
+            ("Wireframe", "false"),
+        ] {
             if !meta.params.iter().any(|p| p.name == name) {
                 meta.params.push(ParamDef {
                     name: name.to_string(),
@@ -809,6 +812,8 @@ pub struct SceneMeshes {
     pub meta_points: cce_ui::vk::MeshId,
     /// Per-node meta "Wireframe" overlay (LINE_LIST edge pairs).
     pub meta_wires: cce_ui::vk::MeshId,
+    /// Per-node meta "Point Normals" overlay (LINE_LIST whiskers).
+    pub meta_normals: cce_ui::vk::MeshId,
 }
 
 /// A left-press on the detached circular window's chrome that becomes an
@@ -1083,6 +1088,11 @@ pub struct State {
     /// triangles, drawn as a wire pass over the scene fill.
     pub meta_wire_verts: Vec<Vertex3D>,
     pub meta_wire_count: u32,
+    /// Per-node meta "Point Normals": LINE_LIST whiskers from each distinct
+    /// point along its smooth vertex normal (computed from topology — the
+    /// kernel outputs carry only a default up-normal attribute).
+    pub meta_normal_verts: Vec<Vertex3D>,
+    pub meta_normal_count: u32,
     /// World-unit radius of the meta "Point Markers" overlay — the Guides
     /// subnet's "Point Marker Size" control (stored there in thousandths).
     pub meta_marker_size: f32,
@@ -3265,6 +3275,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             meta_number_labels: Vec::new(),
             meta_wire_verts: Vec::new(),
             meta_wire_count: 0,
+            meta_normal_verts: Vec::new(),
+            meta_normal_count: 0,
             meta_marker_size: 0.02,
             last_scene_mvp: None,
             last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
@@ -5823,6 +5835,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.meta_point_count = self.meta_marker_verts.len() as u32;
             renderer.update_mesh(meshes.meta_wires, bytemuck::cast_slice(&self.meta_wire_verts));
             self.meta_wire_count = self.meta_wire_verts.len() as u32;
+            renderer
+                .update_mesh(meshes.meta_normals, bytemuck::cast_slice(&self.meta_normal_verts));
+            self.meta_normal_count = self.meta_normal_verts.len() as u32;
             self.viewport_dirty = true;
         }
     }
@@ -5850,6 +5865,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             group_points: renderer.create_mesh(&[]),
             meta_points: renderer.create_mesh(&[]),
             meta_wires: renderer.create_mesh(&[]),
+            meta_normals: renderer.create_mesh(&[]),
         });
         // Scene geometry built during `State::new` (before the renderer
         // existed) uploads on the first frame's flush.
@@ -6057,6 +6073,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                         if self.meta_wire_count > 0 {
                             draws.push(SceneDraw { mesh: meshes.meta_wires, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: self.wire_width, wire_base_width: 0.0 });
                         }
+                        // Per-node meta Point Normals: thin cyan whiskers,
+                        // width deliberately fixed (a chunky Wire Width is a
+                        // wireframe styling choice, not a normals one).
+                        if self.meta_normal_count > 0 {
+                            draws.push(SceneDraw { mesh: meshes.meta_normals, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
+                        }
                     }
                     renderer.stage_scene((sx, sy, cw, ch), draws);
                     }
diff --git a/src/main.rs b/src/main.rs
index 6b52605..d1b6acd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1199,7 +1199,7 @@ mod tests {
         let meta = s.children.iter().find(|c| c.node_type == "meta").expect("sphere meta");
         assert_eq!(
             meta.params.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
-            ["Point Markers", "Point Numbers", "Wireframe"]
+            ["Point Markers", "Point Numbers", "Point Normals", "Wireframe"]
         );
         assert!(meta.params.iter().all(|p| p.default == "false"));
         let opencl = s.children.iter().find(|c| c.name == "opencl1").unwrap();
@@ -1234,11 +1234,11 @@ mod tests {
 
         // Overlays: nothing while the prefs are off…
         let mut cache = crate::geometry::SimCache::default();
-        let (markers, labels, wires) = crate::render::collect_meta_overlays(
+        let (markers, labels, wires, normals) = crate::render::collect_meta_overlays(
             &root, 0.02, &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
-        assert!(markers.is_empty() && labels.is_empty() && wires.is_empty());
+        assert!(markers.is_empty() && labels.is_empty() && wires.is_empty() && normals.is_empty());
 
-        // …all three overlays for the flagged sphere: 240 marker verts per
+        // …all four overlays for the flagged sphere: 240 marker verts per
         // deduped point, labels matching the same dedupe, and one LINE_LIST
         // pair per triangle edge (2304 verts = 768 triangles = 4608 pairs).
         {
@@ -1249,19 +1249,34 @@ mod tests {
         assert!(crate::app::meta_pref(&root.children[0], "Point Markers"));
         assert!(crate::app::meta_pref(&root.children[0], "Wireframe"));
         let mut cache = crate::geometry::SimCache::default();
-        let (markers, labels, wires) = crate::render::collect_meta_overlays(
+        let (markers, labels, wires, normals) = crate::render::collect_meta_overlays(
             &root, 0.02, &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
         assert!(!labels.is_empty() && labels.len() < 16 * 24 * 6);
         assert_eq!(markers.len(), labels.len() * 240);
         assert!(labels.iter().any(|(_, i)| *i > 0));
         assert_eq!(wires.len(), (16 * 24 * 6 / 3) * 6);
+        // Normals: one whisker per distinct point, pointing OUT of the
+        // sphere (center (0, 0.55, 0)) — this pins the winding/negation
+        // convention, not just the count.
+        assert_eq!(normals.len(), labels.len() * 2);
+        for pair in normals.chunks_exact(2) {
+            let d = |p: &[f32; 3]| {
+                let (dx, dy, dz) = (p[0], p[1] - 0.55, p[2]);
+                (dx * dx + dy * dy + dz * dz).sqrt()
+            };
+            assert!(
+                d(&pair[1].position) > d(&pair[0].position),
+                "normal points inward at {:?}",
+                pair[0].position
+            );
+        }
 
         // …and none once the node's geometry is hidden.
         root.children[0].geometry_visible = false;
         let mut cache = crate::geometry::SimCache::default();
-        let (markers, labels, wires) = crate::render::collect_meta_overlays(
+        let (markers, labels, wires, normals) = crate::render::collect_meta_overlays(
             &root, 0.02, &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
-        assert!(markers.is_empty() && labels.is_empty() && wires.is_empty());
+        assert!(markers.is_empty() && labels.is_empty() && wires.is_empty() && normals.is_empty());
     }
 
     /// The Plane template's construction controls: Rows/Columns set the grid
diff --git a/src/render.rs b/src/render.rs
index 6e4c13b..c2c5ff0 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -856,7 +856,7 @@ impl State {
         // Per-node meta overlays ride the same rebuild: markers and point
         // numbers for nodes whose meta child asks for them.
         let mut sim_cache = std::mem::take(&mut self.sim_cache);
-        let (markers, labels, wires) = {
+        let (markers, labels, wires, normals) = {
             let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
             collect_meta_overlays(&self.fs_root, self.meta_marker_size, &mut sim)
         };
@@ -864,6 +864,7 @@ impl State {
         self.meta_marker_verts = markers;
         self.meta_number_labels = labels;
         self.meta_wire_verts = wires;
+        self.meta_normal_verts = normals;
         self.meta_points_dirty = true;
     }
 
@@ -899,10 +900,16 @@ pub(crate) fn collect_meta_overlays(
     root: &FsNode,
     point_size: f32,
     sim: &mut crate::geometry::EvalSim,
-) -> (Vec<crate::geometry::Vertex3D>, Vec<([f32; 3], u32)>, Vec<crate::geometry::Vertex3D>) {
+) -> (
+    Vec<crate::geometry::Vertex3D>,
+    Vec<([f32; 3], u32)>,
+    Vec<crate::geometry::Vertex3D>,
+    Vec<crate::geometry::Vertex3D>,
+) {
     let mut markers = Vec::new();
     let mut labels = Vec::new();
     let mut wires = Vec::new();
+    let mut normals = Vec::new();
     fn visit(
         root: &FsNode,
         node: &FsNode,
@@ -911,13 +918,15 @@ pub(crate) fn collect_meta_overlays(
         markers: &mut Vec<crate::geometry::Vertex3D>,
         labels: &mut Vec<([f32; 3], u32)>,
         wires: &mut Vec<crate::geometry::Vertex3D>,
+        normals: &mut Vec<crate::geometry::Vertex3D>,
         sim: &mut crate::geometry::EvalSim,
     ) {
         let is_visible = parent_visible && node.geometry_visible;
         let want_markers = is_visible && crate::app::meta_pref(node, "Point Markers");
         let want_numbers = is_visible && crate::app::meta_pref(node, "Point Numbers");
         let want_wires = is_visible && crate::app::meta_pref(node, "Wireframe");
-        if want_markers || want_numbers || want_wires {
+        let want_normals = is_visible && crate::app::meta_pref(node, "Point Normals");
+        if want_markers || want_numbers || want_wires || want_normals {
             let mut visited = Vec::new();
             let mut err = None;
             if let Some(geom) = crate::geometry::generate_single_node_geometry_with_errors(
@@ -950,6 +959,47 @@ pub(crate) fn collect_meta_overlays(
                         cce_ui::colors::to_linear_rgb([0.85, 0.85, 1.0]),
                     ));
                 }
+                if want_normals {
+                    // Smooth vertex normals from topology: per distinct
+                    // position, the normalized sum of touching triangles'
+                    // face normals. On this repo's meshes cross(B-A, C-A)
+                    // points INWARD (see the node-template kernel notes), so
+                    // it is negated for outward whiskers. The kernel outputs'
+                    // Norm attribute is a default up-vector — useless here.
+                    use glam::Vec3;
+                    let quant = |p: &[f32; 3]| {
+                        (
+                            (p[0] * 1000.0).round() as i32,
+                            (p[1] * 1000.0).round() as i32,
+                            (p[2] * 1000.0).round() as i32,
+                        )
+                    };
+                    let mut acc: std::collections::HashMap<(i32, i32, i32), ([f32; 3], Vec3)> =
+                        std::collections::HashMap::new();
+                    for tri in geom.vertices.chunks_exact(3) {
+                        let a = Vec3::from_array(tri[0].pos);
+                        let b = Vec3::from_array(tri[1].pos);
+                        let c = Vec3::from_array(tri[2].pos);
+                        let n = -(b - a).cross(c - a);
+                        if n.length_squared() <= 1e-12 {
+                            continue;
+                        }
+                        for v in tri {
+                            acc.entry(quant(&v.pos)).or_insert((v.pos, Vec3::ZERO)).1 += n;
+                        }
+                    }
+                    let len = point_size * 4.0;
+                    let color = cce_ui::colors::to_linear_rgb([0.45, 0.8, 1.0]);
+                    for (pos, sum) in acc.values() {
+                        let n = sum.normalize_or_zero();
+                        if n == Vec3::ZERO {
+                            continue;
+                        }
+                        let tip = Vec3::from_array(*pos) + n * len;
+                        normals.push(crate::geometry::Vertex3D { position: *pos, color });
+                        normals.push(crate::geometry::Vertex3D { position: tip.to_array(), color });
+                    }
+                }
                 if want_numbers {
                     let mut seen = std::collections::HashSet::new();
                     for (i, v) in geom.vertices.iter().enumerate() {
@@ -966,11 +1016,11 @@ pub(crate) fn collect_meta_overlays(
             }
         }
         for c in &node.children {
-            visit(root, c, is_visible, point_size, markers, labels, wires, sim);
+            visit(root, c, is_visible, point_size, markers, labels, wires, normals, sim);
         }
     }
     for c in &root.children {
-        visit(root, c, true, point_size, &mut markers, &mut labels, &mut wires, sim);
+        visit(root, c, true, point_size, &mut markers, &mut labels, &mut wires, &mut normals, sim);
     }
     // A dense mesh can label tens of thousands of points; the text pass is
     // per-frame, so cap it rather than melt the frame rate.
@@ -978,5 +1028,5 @@ pub(crate) fn collect_meta_overlays(
     if labels.len() > MAX_LABELS {
         labels.truncate(MAX_LABELS);
     }
-    (markers, labels, wires)
+    (markers, labels, wires, normals)
 }