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

commitf8322802e4b2f1d09977471974476c2b713d4a14
parentbf87c41845
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 17:21
feat: vertex markers and vertex normals

A vertex's marker and the foot of its normal stand where its number
does, inset from its point toward its primitive's centroid. The normal
is the vertex's own N where the detail carries one, its primitive's
where it does not. The markers share the point markers' mesh and are
re-sized with them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md       |  18 +++++++-
 src/app.rs      |  45 ++++++++++++++++--
 src/command.rs  |   2 +
 src/dialog.rs   |   2 +
 src/main.rs     |  48 +++++++++++++++++--
 src/render.rs   | 140 +++++++++++++++++++++++++++++++++++++++-----------------
 src/shortcut.rs |   2 +
 7 files changed, 205 insertions(+), 52 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index faa5af2..c53de60 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2184,7 +2184,7 @@ opacity and Show Occluded) and **Markers** (what is drawn on it: Show
 Points, Point Size, Group Marker Scale and Pull Arrow Scale; then the
 overlays a class at a time — Show Point Markers and its size, Show Point
 Numbers, Show Point Normals; Show Primitive Numbers, Show Primitive
-Normals; Show Vertex Numbers).
+Normals; Show Vertex Markers, Show Vertex Numbers, Show Vertex Normals).
 
 **The submenus are cce-ui's** (`context_menu::set_row_submenu`, see its
 CLAUDE.md, "A row can open a submenu"): a second menu in its own popup
@@ -2220,6 +2220,22 @@ labels a class, and dimmed by the fill in front of them as the point
 numbers are (one `point_transmittance` call over all three lists).
 `primitives_and_vertices_are_numbered_where_they_are` is the test.
 
+**Vertex markers and vertex normals** (`toggle_vertex_markers`,
+`toggle_vertex_normals`, later the same day) stand where the vertex's
+number does, so all three of a vertex's overlays name one place. The
+markers are the point markers' spheres at `VERTEX_MARKER_SCALE` (0.6) of
+Point Marker Size, in the vertex green, smaller so a point's marker is not
+lost among the markers of the vertices around it; they share the point
+markers' mesh, and `rebuild_overlay_marker_verts` builds both lists, so
+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`
+of what is wanted and returns an `ElementOverlayGeometry`.
+
 `the_viewport_menu_groups_its_display_rows` holds the
 order of all three. The rows: the Show Wireframe switch (its registry command), **Flat
 Shading / Smooth Shading** as a radio pair over `toggle_smooth_shading`,
diff --git a/src/app.rs b/src/app.rs
index d5c693e..c18bf75 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1283,6 +1283,10 @@ pub struct ViewportSettings {
     pub show_prim_normals: bool,
     #[serde(default)]
     pub show_vertex_numbers: bool,
+    #[serde(default)]
+    pub show_vertex_markers: bool,
+    #[serde(default)]
+    pub show_vertex_normals: bool,
     /// World-unit radius and colour of the Show Point Markers overlay.
     #[serde(default = "default_point_marker_size")]
     pub point_marker_size: f32,
@@ -1485,6 +1489,8 @@ impl Default for ViewportSettings {
             show_prim_numbers: false,
             show_prim_normals: false,
             show_vertex_numbers: false,
+            show_vertex_markers: false,
+            show_vertex_normals: false,
             point_marker_size: default_point_marker_size(),
             point_marker_color: default_point_marker_color(),
             world_unit: default_world_unit(),
@@ -2424,6 +2430,9 @@ pub struct State {
     /// Marker Size slider does that on every motion of a drag
     /// (`rebuild_overlay_marker_verts`).
     pub overlay_marker_points: Vec<Vertex3D>,
+    /// The same for Show Vertex Markers: where each vertex's marker
+    /// stands, inset from its point as its number is.
+    pub overlay_vertex_marker_points: Vec<Vertex3D>,
     pub overlay_dirty: bool,
     pub overlay_point_count: u32,
     pub overlay_number_labels: Vec<([f32; 3], u32)>,
@@ -2454,6 +2463,8 @@ pub struct State {
     pub show_prim_numbers: bool,
     pub show_prim_normals: bool,
     pub show_vertex_numbers: bool,
+    pub show_vertex_markers: bool,
+    pub show_vertex_normals: bool,
     /// The visible scene's own edges for the wire pass (LINE_LIST pairs),
     /// rebuilt with the scene while Show Wireframe is on and empty while it
     /// is off. Topological — see `render::scene_edge_verts`.
@@ -2636,6 +2647,8 @@ impl State {
                 show_prim_numbers: self.show_prim_numbers,
                 show_prim_normals: self.show_prim_normals,
                 show_vertex_numbers: self.show_vertex_numbers,
+                show_vertex_markers: self.show_vertex_markers,
+                show_vertex_normals: self.show_vertex_normals,
                 point_marker_size: self.point_marker_size,
                 point_marker_color: self.point_marker_color,
                 world_unit: self.world_unit.suffix().to_string(),
@@ -2714,6 +2727,8 @@ impl State {
         self.show_prim_numbers = v.show_prim_numbers;
         self.show_prim_normals = v.show_prim_normals;
         self.show_vertex_numbers = v.show_vertex_numbers;
+        self.show_vertex_markers = v.show_vertex_markers;
+        self.show_vertex_normals = v.show_vertex_normals;
         self.point_marker_size = v.point_marker_size;
         self.point_marker_color = v.point_marker_color;
         if let Some(u) = cce_ui::units::Unit::parse(&v.world_unit) {
@@ -5387,7 +5402,9 @@ impl State {
                 toggle(&mut options, &mut actions, "toggle_prim_numbers");
                 toggle(&mut options, &mut actions, "toggle_prim_normals");
                 row(&mut options, &mut actions, "-".into(), sep);
+                toggle(&mut options, &mut actions, "toggle_vertex_markers");
                 toggle(&mut options, &mut actions, "toggle_vertex_numbers");
+                toggle(&mut options, &mut actions, "toggle_vertex_normals");
                 return (options, actions);
             }
             None => {}
@@ -6267,16 +6284,23 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     /// current size — the cheap half of the markers, with no evaluation, so
     /// a size change can run it on every motion of a drag. The Highlight
     /// bake's warm accent, so the markers and the tint read as one feature.
-    /// Build the Show Point Markers overlay's spheres from the kept scene
-    /// positions at the current Point Marker Size — the same call
-    /// `render::scene_point_overlays` makes, without the evaluation that
-    /// produced the positions.
+    /// Build the marker overlays' spheres from the kept scene positions at
+    /// the current Point Marker Size, without the evaluation that produced
+    /// the positions: the points' in the marker colour, and the vertices'
+    /// in the vertex overlays' green at `VERTEX_MARKER_SCALE` of the size —
+    /// smaller, so that a point's marker is not lost among the markers of
+    /// the vertices around it.
     pub(crate) fn rebuild_overlay_marker_verts(&mut self) {
         self.overlay_marker_verts = crate::geometry::points_vertices(
             &self.overlay_marker_points,
             self.point_marker_size,
             cce_ui::colors::to_linear_rgb(self.point_marker_color),
         );
+        self.overlay_marker_verts.extend(crate::geometry::points_vertices(
+            &self.overlay_vertex_marker_points,
+            self.point_marker_size * crate::render::VERTEX_MARKER_SCALE,
+            cce_ui::colors::to_linear_rgb(crate::render::VERTEX_LABEL_COLOR.map(|c| c as f32 / 255.0)),
+        ));
         self.overlay_dirty = true;
     }
 
@@ -6711,6 +6735,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             last_group_marker_size: 0.0,
             overlay_marker_verts: Vec::new(),
             overlay_marker_points: Vec::new(),
+            overlay_vertex_marker_points: Vec::new(),
             overlay_dirty: false,
             overlay_point_count: 0,
             overlay_number_labels: Vec::new(),
@@ -6727,6 +6752,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             show_prim_numbers: settings.viewport.show_prim_numbers,
             show_prim_normals: settings.viewport.show_prim_normals,
             show_vertex_numbers: settings.viewport.show_vertex_numbers,
+            show_vertex_markers: settings.viewport.show_vertex_markers,
+            show_vertex_normals: settings.viewport.show_vertex_normals,
             scene_edge_verts: Vec::new(),
             point_marker_size: settings.viewport.point_marker_size,
             point_marker_color: settings.viewport.point_marker_color,
@@ -8371,6 +8398,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 self.rebuild_scene_geometry();
                 settings_changed = true;
             }
+            Action::ToggleVertexMarkers => {
+                self.show_vertex_markers = !self.show_vertex_markers;
+                self.rebuild_scene_geometry();
+                settings_changed = true;
+            }
+            Action::ToggleVertexNormals => {
+                self.show_vertex_normals = !self.show_vertex_normals;
+                self.rebuild_scene_geometry();
+                settings_changed = true;
+            }
             Action::ToggleVertexNumbers => {
                 self.show_vertex_numbers = !self.show_vertex_numbers;
                 self.rebuild_scene_geometry();
diff --git a/src/command.rs b/src/command.rs
index 9b32993..5c19447 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -214,7 +214,9 @@ pub const COMMANDS: &[Command] = &[
     Command { id: "toggle_point_normals", label: "Show Point Normals", context: Context::Viewport, run: Run::Key(Action::TogglePointNormals), default_chord: None },
     Command { id: "toggle_prim_numbers", label: "Show Primitive Numbers", context: Context::Viewport, run: Run::Key(Action::TogglePrimNumbers), default_chord: None },
     Command { id: "toggle_prim_normals", label: "Show Primitive Normals", context: Context::Viewport, run: Run::Key(Action::TogglePrimNormals), default_chord: None },
+    Command { id: "toggle_vertex_markers", label: "Show Vertex Markers", context: Context::Viewport, run: Run::Key(Action::ToggleVertexMarkers), default_chord: None },
     Command { id: "toggle_vertex_numbers", label: "Show Vertex Numbers", context: Context::Viewport, run: Run::Key(Action::ToggleVertexNumbers), default_chord: None },
+    Command { id: "toggle_vertex_normals", label: "Show Vertex Normals", context: Context::Viewport, run: Run::Key(Action::ToggleVertexNormals), default_chord: None },
     Command { id: "toggle_render_points", label: "Show Points", context: Context::Viewport, run: Run::Key(Action::ToggleRenderPoints), default_chord: None },
     Command { id: "toggle_wire_single_color", label: "Wireframe Single Color", context: Context::Viewport, run: Run::Key(Action::ToggleWireSingleColor), default_chord: None },
     Command { id: "toggle_ray_traced_preview", label: "Ray Traced Preview", context: Context::Viewport, run: Run::Key(Action::ToggleRayTracedPreview), default_chord: None },
diff --git a/src/dialog.rs b/src/dialog.rs
index a009d60..7eae6b9 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1611,6 +1611,8 @@ impl State {
             "toggle_prim_numbers" => self.show_prim_numbers,
             "toggle_prim_normals" => self.show_prim_normals,
             "toggle_vertex_numbers" => self.show_vertex_numbers,
+            "toggle_vertex_markers" => self.show_vertex_markers,
+            "toggle_vertex_normals" => self.show_vertex_normals,
             "toggle_render_points" => self.render_points,
             "toggle_wire_single_color" => self.wire_single_color,
             "toggle_ray_traced_preview" => self.viewport().rt_mode,
diff --git a/src/main.rs b/src/main.rs
index 3949b7c..3b0551e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2064,7 +2064,11 @@ mod tests {
                     A::Command("toggle_point_normals"),
                 ],
                 vec![A::Command("toggle_prim_numbers"), A::Command("toggle_prim_normals")],
-                vec![A::Command("toggle_vertex_numbers")],
+                vec![
+                    A::Command("toggle_vertex_markers"),
+                    A::Command("toggle_vertex_numbers"),
+                    A::Command("toggle_vertex_normals"),
+                ],
             ]
         );
     }
@@ -2144,10 +2148,13 @@ mod tests {
             .collect();
         d.add_prim(&[p[0], p[1], p[2], p[3]]);
         d.add_prim(&[p[1], p[4], p[2]]);
-        let (prims, verts, normals) = crate::render::scene_element_overlays(&d, false, false, false, 1.0);
-        assert!(prims.is_empty() && verts.is_empty() && normals.is_empty());
+        use crate::render::ElementOverlays as Want;
+        let none = crate::render::scene_element_overlays(&d, Want::default(), 1.0);
+        assert!(none.prim_labels.is_empty() && none.vertex_labels.is_empty() && none.normals.is_empty() && none.vertex_markers.is_empty());
 
-        let (prims, verts, normals) = crate::render::scene_element_overlays(&d, true, true, true, 0.5);
+        let want = Want { prim_numbers: true, prim_normals: true, vertex_numbers: true, ..Want::default() };
+        let got = crate::render::scene_element_overlays(&d, want, 0.5);
+        let (prims, verts, normals) = (got.prim_labels, got.vertex_labels, got.normals);
         assert_eq!(prims.len(), 2);
         assert_eq!(prims[0], ([1.0, 1.0, 0.0], 0));
         assert_eq!(prims[1].1, 1);
@@ -2161,6 +2168,39 @@ mod tests {
         let n = Vec3::from_array(normals[1].position) - Vec3::from_array(normals[0].position);
         assert!((n - Vec3::new(0.0, 0.0, 0.5)).length() < 1e-6, "{n:?}");
 
+        // A vertex's marker stands where its number does, and its normal
+        // is its primitive's: on a mesh that carries no vertex normals the
+        // corners of one face agree and the faces around a point do not.
+        let got = crate::render::scene_element_overlays(&d, Want { vertex_markers: true, vertex_normals: true, ..Want::default() }, 0.5);
+        assert_eq!(got.vertex_markers.len(), 7);
+        assert_eq!(got.vertex_markers.iter().map(|m| m.position).collect::<Vec<_>>(), verts.iter().map(|(p, _)| *p).collect::<Vec<_>>());
+        assert_eq!(got.normals.len(), 14, "a whisker a vertex");
+        assert_eq!(got.normals[2].position, verts[1].0, "from where the vertex stands");
+        let n = Vec3::from_array(got.normals[3].position) - Vec3::from_array(got.normals[2].position);
+        assert!((n - Vec3::new(0.0, 0.0, 0.5 * crate::render::VERTEX_MARKER_SCALE)).length() < 1e-6, "{n:?}");
+        // Its own N, where the detail carries one on its vertices.
+        let mut tilted = d.clone();
+        tilted.verts_mut().create("N", crate::detail::AttribValue::Float3([1.0, 0.0, 0.0]));
+        let got = crate::render::scene_element_overlays(&tilted, Want { vertex_normals: true, ..Want::default() }, 1.0);
+        let n = Vec3::from_array(got.normals[1].position) - Vec3::from_array(got.normals[0].position);
+        assert!((n.normalize() - Vec3::X).length() < 1e-6, "{n:?}");
+
+        // The markers are built with the points', and re-sized with them.
+        let mut state = State::new(false);
+        state.show_point_markers = false;
+        state.show_vertex_markers = false;
+        state.rebuild_scene_geometry();
+        assert!(state.overlay_marker_verts.is_empty());
+        state.run_command("toggle_vertex_markers");
+        let built = state.overlay_marker_verts.clone();
+        assert!(!built.is_empty() && !state.overlay_vertex_marker_points.is_empty());
+        state.point_marker_size *= 2.0;
+        state.rebuild_overlay_marker_verts();
+        assert_eq!(state.overlay_marker_verts.len(), built.len());
+        assert_ne!(state.overlay_marker_verts[0].position, built[0].position, "re-sized");
+        state.run_command("toggle_vertex_markers");
+        assert!(state.overlay_marker_verts.is_empty(), "and gone with the switch");
+
         // And the app collects them by its switches, and persists those.
         let mut state = State::new(false);
         state.show_prim_numbers = false;
diff --git a/src/render.rs b/src/render.rs
index e6d6e80..47a3dcb 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1247,18 +1247,27 @@ impl State {
         self.overlay_number_labels = labels;
         self.overlay_number_alpha.clear();
         self.overlay_normal_verts = normals;
-        let (prim_labels, vertex_labels, prim_normals) = scene_element_overlays(
+        let elements = scene_element_overlays(
             &geom,
-            self.show_prim_numbers,
-            self.show_vertex_numbers,
-            self.show_prim_normals,
+            ElementOverlays {
+                prim_numbers: self.show_prim_numbers,
+                prim_normals: self.show_prim_normals,
+                vertex_numbers: self.show_vertex_numbers,
+                vertex_markers: self.show_vertex_markers,
+                vertex_normals: self.show_vertex_normals,
+            },
             self.point_marker_size * 4.0,
         );
-        self.overlay_prim_labels = prim_labels;
+        self.overlay_prim_labels = elements.prim_labels;
         self.overlay_prim_alpha.clear();
-        self.overlay_vertex_labels = vertex_labels;
+        self.overlay_vertex_labels = elements.vertex_labels;
         self.overlay_vertex_alpha.clear();
-        self.overlay_normal_verts.extend(prim_normals);
+        self.overlay_normal_verts.extend(elements.normals);
+        self.overlay_vertex_marker_points = elements.vertex_markers;
+        if !self.overlay_vertex_marker_points.is_empty() {
+            // Both marker lists, by the one builder the size slider uses.
+            self.rebuild_overlay_marker_verts();
+        }
         // The wire pass's edges, likewise — topological, and only while the
         // wireframe is actually on.
         self.scene_edge_verts =
@@ -1428,35 +1437,62 @@ pub(crate) const VERTEX_LABEL_COLOR: [u8; 3] = [0xa0, 0xf0, 0xb0];
 /// near enough to say which corner each is.
 pub(crate) const VERTEX_LABEL_INSET: f32 = 0.3;
 
+/// A vertex marker's radius as a fraction of Point Marker Size.
+pub(crate) const VERTEX_MARKER_SCALE: f32 = 0.6;
+
+/// Which of the primitive and vertex overlays to collect.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub(crate) struct ElementOverlays {
+    pub prim_numbers: bool,
+    pub prim_normals: bool,
+    pub vertex_numbers: bool,
+    pub vertex_markers: bool,
+    pub vertex_normals: bool,
+}
+
+/// What `scene_element_overlays` collected: `(position, index)` labels for
+/// the two kinds of number, the places the vertex markers stand, and the
+/// LINE_LIST whiskers of both kinds of normal.
+#[derive(Debug, Clone, Default)]
+pub(crate) struct ElementOverlayGeometry {
+    pub prim_labels: Vec<([f32; 3], u32)>,
+    pub vertex_labels: Vec<([f32; 3], u32)>,
+    pub vertex_markers: Vec<crate::geometry::Vertex3D>,
+    pub normals: Vec<crate::geometry::Vertex3D>,
+}
+
 /// The overlays of the other two element classes, read off the same scene
-/// `Detail` as the points': `(position, index)` labels for Show Primitive
-/// Numbers, at each primitive's centroid; the same for Show Vertex Numbers,
-/// each vertex inset from its point toward its primitive's centroid; and
-/// LINE_LIST whiskers for Show Primitive Normals, from the centroid along
-/// the primitive's own normal, `len` long.
+/// `Detail` as the points'. A PRIMITIVE's number stands at its centroid and
+/// its normal is a whisker from there, `len` long. A VERTEX stands inset
+/// from its point toward its primitive's centroid — its number, its marker
+/// and the foot of its normal all there — so the vertices that share a
+/// point are apart, each inside its own primitive.
 ///
 /// A vertex's number is its index in the detail, which is its row in the
-/// spreadsheet, as a point's is. The normal is Newell's, so a quad that is
-/// not quite flat still has one; a primitive of fewer than three points has
-/// a number and no normal. Each list of labels is capped as the points' is.
+/// spreadsheet, as a point's is. A primitive's normal is Newell's, so a
+/// quad that is not quite flat still has one; a primitive of fewer than
+/// three points has a number and no normal. **A vertex's normal is its `N`
+/// attribute where the detail carries one on its vertices, and its
+/// primitive's normal where it does not** — which is what a vertex normal
+/// is for: the normal of this corner of this face, where a point's is the
+/// average over every face around it. So on a mesh with no vertex normals
+/// the whiskers show the faceting, a fan of them at every shared point.
+/// Each list of labels is capped as the points' is.
 pub(crate) fn scene_element_overlays(
     geom: &crate::detail::Detail,
-    prim_numbers: bool,
-    vertex_numbers: bool,
-    prim_normals: bool,
+    want: ElementOverlays,
     len: f32,
-) -> (
-    Vec<([f32; 3], u32)>,
-    Vec<([f32; 3], u32)>,
-    Vec<crate::geometry::Vertex3D>,
-) {
+) -> ElementOverlayGeometry {
     use glam::Vec3;
     const MAX_LABELS: usize = 2000;
-    let (mut prims, mut verts, mut normals) = (Vec::new(), Vec::new(), Vec::new());
-    if !(prim_numbers || vertex_numbers || prim_normals) {
-        return (prims, verts, normals);
+    let mut out = ElementOverlayGeometry::default();
+    if want == ElementOverlays::default() {
+        return out;
     }
-    let color = cce_ui::colors::to_linear_rgb(PRIM_LABEL_COLOR.map(|c| c as f32 / 255.0));
+    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 by_vertex = want.vertex_numbers || want.vertex_markers || want.vertex_normals;
     for prim in 0..geom.num_prims() {
         let pts = geom.prim_points(prim);
         if pts.is_empty() {
@@ -1464,31 +1500,49 @@ pub(crate) fn scene_element_overlays(
         }
         let at: Vec<Vec3> = pts.iter().map(|&p| geom.pos(p as usize)).collect();
         let centroid = at.iter().copied().sum::<Vec3>() / at.len() as f32;
-        if prim_numbers && prims.len() < MAX_LABELS {
-            prims.push((centroid.to_array(), prim as u32));
-        }
-        if vertex_numbers {
-            for (v, &p) in geom.prim_verts(prim).zip(&at) {
-                if verts.len() >= MAX_LABELS {
-                    break;
-                }
-                verts.push((p.lerp(centroid, VERTEX_LABEL_INSET).to_array(), v as u32));
-            }
-        }
-        if prim_normals && at.len() >= 3 {
+        let normal = if (want.prim_normals || want.vertex_normals) && at.len() >= 3 {
             let mut n = Vec3::ZERO;
             for (k, &a) in at.iter().enumerate() {
                 let b = at[(k + 1) % at.len()];
                 n += Vec3::new((a.y - b.y) * (a.z + b.z), (a.z - b.z) * (a.x + b.x), (a.x - b.x) * (a.y + b.y));
             }
-            let n = n.normalize_or_zero();
+            n.normalize_or_zero()
+        } else {
+            Vec3::ZERO
+        };
+        let mut whisker = |from: Vec3, n: Vec3, len: f32, color: [f32; 3]| {
             if n != Vec3::ZERO {
-                normals.push(crate::geometry::Vertex3D { position: centroid.to_array(), color });
-                normals.push(crate::geometry::Vertex3D { position: (centroid + n * len).to_array(), color });
+                out.normals.push(crate::geometry::Vertex3D { position: from.to_array(), color });
+                out.normals.push(crate::geometry::Vertex3D { position: (from + n * len).to_array(), color });
+            }
+        };
+        if want.prim_normals {
+            whisker(centroid, normal, len, prim_color);
+        }
+        if by_vertex {
+            for (v, &p) in geom.prim_verts(prim).zip(&at) {
+                let stands = p.lerp(centroid, VERTEX_LABEL_INSET);
+                if want.vertex_normals {
+                    let n = own_normals
+                        .and_then(|n| n.get(v))
+                        .map(|n| n.as_vec3().normalize_or_zero())
+                        .filter(|n| *n != Vec3::ZERO)
+                        .unwrap_or(normal);
+                    whisker(stands, n, len * VERTEX_MARKER_SCALE, vertex_color);
+                }
+                if want.vertex_markers {
+                    out.vertex_markers.push(crate::geometry::Vertex3D { position: stands.to_array(), color: [0.0; 3] });
+                }
+                if want.vertex_numbers && out.vertex_labels.len() < MAX_LABELS {
+                    out.vertex_labels.push((stands.to_array(), v as u32));
+                }
             }
         }
+        if want.prim_numbers && out.prim_labels.len() < MAX_LABELS {
+            out.prim_labels.push((centroid.to_array(), prim as u32));
+        }
     }
-    (prims, verts, normals)
+    out
 }
 
 /// The scene's own edges as LINE_LIST pairs for the wire pass, carrying the
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 6a40238..3e82278 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -31,6 +31,8 @@ pub enum Action {
     TogglePrimNumbers,
     TogglePrimNormals,
     ToggleVertexNumbers,
+    ToggleVertexMarkers,
+    ToggleVertexNormals,
     /// The path-traced preview, the wireframe's single-colour mode, and the
     /// point display. All three were toggles on the root meta node's utility
     /// subnets and reachable ONLY there; with those nodes retired they are