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

commit377e327bec7ec6d47060c2c1644f339324759911
parent8fd0c2976f
authorLucas Galante <[email protected]>
date2026-09-23 14:37
refactor: the point overlays are a view setting, not a node property

Every geometry node carried a hidden `meta` child holding four display
switches — Point Markers, Point Numbers, Point Normals, Wireframe — so
seeing the point numbering of what was on screen meant diving into each
node and flipping its own switch, one node at a time.

They are display settings, and display settings belong to the view. The
three that had no global counterpart become commands in the palette
(`toggle_point_markers` / `_numbers` / `_normals`, switches in the
dialog's Commands list like every other viewport toggle), persisted in
`ViewportSettings` beside Show Grid. Wireframe already HAD a global
command; the per-node flag was a duplicate of it and simply goes.

Two things fall out of the collapse:

- The overlays read the merged scene `Detail` the geometry rebuild has
  already produced, rather than walking the tree and re-evaluating every
  flagged node on its own. That second walk was the repeated evaluation
  the Embryo's one-visible-child rule exists to avoid.
- There is one wireframe, and it draws the TOPOLOGICAL edges. The global
  toggle drew the triangle soup (every shared edge twice, every quad's
  fan diagonal); the per-node flag drew the real edge list. Folding them
  together keeps the better one and its colour/width controls both.

Per-node `meta` children ride saved projects, so they are stripped in
`merge_template_defs` — the one function every deserialization runs.
Their values are deliberately dropped: four per-node booleans do not
reduce to one global switch, and inferring one would turn a single
node's preference into a setting over the whole scene.

The root `meta` node is a different thing (the session-settings
container) and is untouched here.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/app.rs      | 305 +++++++++++++++++++++++++++-----------------------------
 src/command.rs  |   6 ++
 src/dialog.rs   |   3 +
 src/main.rs     | 155 +++++++++++++++-------------
 src/project.rs  |  16 ++-
 src/render.rs   | 267 ++++++++++++++++++++++---------------------------
 src/shortcut.rs |   8 ++
 src/window.rs   |   1 -
 8 files changed, 377 insertions(+), 384 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 9827c1e..7bffd1d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -519,83 +519,40 @@ pub fn flatten_node_templates(root: &FsNode) -> Vec<NodeTemplate> {
     out
 }
 
-/// Whether a node gets a `meta` (per-node preferences) child: every node the
-/// user places that can produce geometry — subnet instances and the native
-/// geometry types. Cameras, settings containers, and meta itself do not.
-fn meta_eligible(node: &FsNode) -> bool {
-    node.node_type.eq_ignore_ascii_case("node")
-        || crate::geometry::is_geometry_node_type(&node.node_type)
-}
-
-/// Ensure `node` (and its subtree) carries the per-node `meta` child where
-/// eligible, and that every existing meta has the full preference set —
-/// the migration path for saved scenes, and the instantiation path for new
-/// nodes. Idempotent; never touches the Session settings tree.
-pub fn ensure_meta_on(node: &mut FsNode) {
-    if node.node_type.eq_ignore_ascii_case("session") || node.node_type.eq_ignore_ascii_case("meta")
-    {
-        return;
-    }
-    if meta_eligible(node) {
-        if !node.children.iter().any(|c| c.node_type == "meta") {
-            node.children.push(FsNode {
-                id: generate_node_id(),
-                name: "meta".to_string(),
-                node_type: "meta".to_string(),
-                children: vec![],
-                params: vec![],
-                geometry_visible: false,
-                position: (0.0, 4.0),
-                inputs: 0,
-                outputs: 0,
-            });
-        }
-        let meta = node.children.iter_mut().find(|c| c.node_type == "meta").unwrap();
-        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(),
-                    label: String::new(),
-                    param_type: "toggle".to_string(),
-                    default: default.to_string(),
-                    options: Vec::new(),
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(),
-                });
-            }
+/// Strip the per-node `meta` (preferences) children from a loaded tree.
+///
+/// Every geometry node used to carry one, holding four display switches —
+/// Point Markers, Point Numbers, Point Normals, Wireframe. They were
+/// retired on 2026-09-23: what they controlled is a property of the VIEW,
+/// not of the scene, so it belongs to the viewport's own settings and the
+/// command palette (`toggle_point_markers` and friends), not to a hidden
+/// child you had to dive into a node to find and set one node at a time.
+///
+/// A migration rather than a no-op because those children ride saved
+/// projects: left in place they would show in every network as a child that
+/// does nothing, and the loader would keep them alive forever. Their VALUES
+/// are deliberately dropped — four per-node booleans do not reduce to one
+/// global switch, and inferring one ("any node asked for markers") would
+/// turn one node's preference into a setting over the whole scene.
+///
+/// The root `meta` node is a different thing entirely and is not touched
+/// here: it is the session-settings container, reached through `fs_root`'s
+/// own children rather than as a child of a placed node.
+pub fn strip_meta_children(root: &mut FsNode) {
+    fn strip(node: &mut FsNode) {
+        node.children.retain(|c| c.node_type != "meta");
+        for c in &mut node.children {
+            strip(c);
         }
     }
-    for c in &mut node.children {
-        ensure_meta_on(c);
-    }
-}
-
-/// [`ensure_meta_on`] over every node of a project tree (the root itself is a
-/// container, not a placed node).
-pub fn ensure_meta_children(root: &mut FsNode) {
+    // Entered through the root's children, exactly as `ensure_meta_children`
+    // was: the root is a container, not a placed node, and its own direct
+    // `meta` child is the SESSION node, which this must not take.
     for c in &mut root.children {
-        ensure_meta_on(c);
+        strip(c);
     }
 }
 
-/// Read a boolean preference off a node's `meta` child; absent meta or
-/// absent param reads false.
-pub fn meta_pref(node: &FsNode, name: &str) -> bool {
-    node.children
-        .iter()
-        .find(|c| c.node_type == "meta")
-        .and_then(|m| m.params.iter().find(|p| p.name == name))
-        .map(|p| p.default == "true")
-        .unwrap_or(false)
-}
-
 /// Merge template evolution into a loaded project tree, so saved scenes gain
 /// controls added to a template after they were saved. Every deserialized
 /// project routes through this (file load, the detached-window sync reload,
@@ -725,6 +682,15 @@ impl Project {
 }
 
 pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
+    // The retired per-node `meta` children go first, before any matching:
+    // the merge compares a subnet instance's children against its template's
+    // name-for-name, and a stale `meta` on one side and not the other is a
+    // mismatch that costs the instance its refresh. Here rather than at the
+    // call sites because this is the one function EVERY deserialization runs
+    // — file load, the sync-channel reload, the thumbnail and the export CLI
+    // — and a migration missed at one load path is the whole failure mode.
+    strip_meta_children(root);
+
     // Legacy retypes, session->meta style: renamed native types are rewritten
     // in place (params and name intact) BEFORE matching, so old saves find the
     // renamed template and gain its new params through the normal merge.
@@ -742,8 +708,7 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
     // A NATIVE embryo (node type "embryo", 2026-09-21 only) is recomposed as
     // an instance of the Embryo template, which is the same pipeline as a
     // network: id, name, position, display flag and every parameter value
-    // carry over by name, the meta child is kept, and the template's
-    // children arrive with fresh ids. Wholesale rather than through the
+    // carry over by name, and the template's children arrive with fresh ids. Wholesale rather than through the
     // merge below, which never injects children.
     fn recompose_native_embryo(node: &mut FsNode, templates: &[NodeTemplate]) {
         for c in &mut node.children {
@@ -971,6 +936,17 @@ pub struct ViewportSettings {
     /// the project, but whether its surface is drawn is how you like to work.
     #[serde(default = "default_network_plate")]
     pub network_plate: bool,
+    /// The three point overlays. Display settings like the guide toggles
+    /// above, and persisted in the same place: they were per-node `meta`
+    /// child preferences until 2026-09-23, which made a view choice into a
+    /// property of the scene. Absent from older files — off, as the meta
+    /// defaults were.
+    #[serde(default)]
+    pub show_point_markers: bool,
+    #[serde(default)]
+    pub show_point_numbers: bool,
+    #[serde(default)]
+    pub show_point_normals: bool,
 }
 
 fn default_network_plate() -> bool {
@@ -988,6 +964,9 @@ impl Default for ViewportSettings {
             show_cube_enabled: false,
             show_origin_enabled: true,
             network_plate: true,
+            show_point_markers: false,
+            show_point_numbers: false,
+            show_point_normals: false,
             origin_size: 1.0,
             grid_thickness: default_grid_thickness(),
             grid_color: default_grid_color(),
@@ -1352,12 +1331,10 @@ pub struct SceneMeshes {
     /// Selected-Group membership markers: while a Group node is selected, one
     /// marker per vertex it tags, so the selection SHOWS the group.
     pub group_points: cce_ui::vk::MeshId,
-    /// Per-node meta "Point Markers" overlay.
-    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,
+    /// The Show Point Markers overlay.
+    pub overlay_points: cce_ui::vk::MeshId,
+    /// The Show Point Normals overlay (LINE_LIST whiskers).
+    pub overlay_normals: cce_ui::vk::MeshId,
 }
 
 /// A left-press on the detached circular window's chrome that becomes an
@@ -1741,29 +1718,39 @@ pub struct State {
     pub group_points_dirty: bool,
     pub group_point_vertex_count: u32,
     pub last_group_points_key: Option<(String, Vec<(String, String)>, u64, i32)>,
-    /// Per-node meta (preferences) overlays, rebuilt with the scene: marker
-    /// geometry for nodes whose meta asks for Point Markers, and (position,
-    /// vertex index) labels for Point Numbers — the labels project through
-    /// `last_scene_mvp` into 2D text each frame.
-    pub meta_marker_verts: Vec<Vertex3D>,
-    pub meta_points_dirty: bool,
-    pub meta_point_count: u32,
-    pub meta_number_labels: Vec<([f32; 3], u32)>,
-    /// Per-node meta "Wireframe": LINE_LIST edge pairs of the flagged nodes'
-    /// 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,
-    /// sRGB color of the meta "Point Markers" overlay — the Guides subnet's
-    /// "Point Marker Color" control (stored there as hex, like Grid Color).
-    pub meta_marker_color: [f32; 3],
+    /// The point overlays on the visible scene, rebuilt with it: marker
+    /// geometry for Show Point Markers, and (position, vertex index) labels
+    /// for Show Point Numbers — the labels project through `last_scene_mvp`
+    /// into 2D text each frame.
+    ///
+    /// These were per-node preferences on a hidden `meta` child until
+    /// 2026-09-23, so seeing the point numbering of what was on screen meant
+    /// diving into each node and flipping its own switch. They are display
+    /// settings, and display settings belong to the view: three commands in
+    /// the palette (`toggle_point_markers` / `_numbers` / `_normals`) over
+    /// the flags below, persisted in `ViewportSettings` beside Show Grid.
+    pub overlay_marker_verts: Vec<Vertex3D>,
+    pub overlay_dirty: bool,
+    pub overlay_point_count: u32,
+    pub overlay_number_labels: Vec<([f32; 3], u32)>,
+    /// Show 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 overlay_normal_verts: Vec<Vertex3D>,
+    pub overlay_normal_count: u32,
+    /// The three overlay switches, flipped by their palette commands and
+    /// persisted in `ViewportSettings`.
+    pub show_point_markers: bool,
+    pub show_point_numbers: bool,
+    pub show_point_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`.
+    pub scene_edge_verts: Vec<Vertex3D>,
+    /// World-unit radius of the Show Point Markers overlay.
+    pub point_marker_size: f32,
+    /// sRGB color of the Show Point Markers overlay.
+    pub point_marker_color: [f32; 3],
     /// What one world unit IS — the Guides subnet's "World Unit" choice
     /// (mm / cm / m / in), persisted with the project. Geometry never
     /// converts; this is the declaration that lets the viewport state its
@@ -1935,6 +1922,9 @@ impl State {
                 grid_thickness: self.grid_thickness,
                 grid_color: self.viewport().grid_color,
                 network_plate: self.network_plate,
+                show_point_markers: self.show_point_markers,
+                show_point_numbers: self.show_point_numbers,
+                show_point_normals: self.show_point_normals,
             },
             default_project: self.default_project_setting.clone(),
         };
@@ -4251,9 +4241,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
 
     pub fn delete_node(&mut self, slot: usize) -> bool {
         let len = self.current_dir().children.len();
-        // The Session node is permanent, and so is each node's meta
-        // (preferences) child: every deletion route (context menu, Delete
-        // key, MCP) funnels through here, so this is the one gate.
+        // The root meta node (nee Session) is permanent: every deletion
+        // route (context menu, Delete key, MCP) funnels through here, so
+        // this is the one gate.
         if slot < len
             && matches!(self.current_dir().children[slot].node_type.as_str(), "session" | "meta")
         {
@@ -4478,7 +4468,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 if let Ok(mut proj) = serde_json::from_str::<Project>(&content) {
                     proj.sanitize_node_names();
                     merge_template_defs(&mut proj.root, &node_templates);
-                    ensure_meta_children(&mut proj.root);
                     loaded_project = Some(proj);
                 }
             }
@@ -4848,16 +4837,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             group_points_dirty: false,
             group_point_vertex_count: 0,
             last_group_points_key: None,
-            meta_marker_verts: Vec::new(),
-            meta_points_dirty: false,
-            meta_point_count: 0,
-            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,
-            meta_marker_color: [0.85, 0.85, 1.0],
+            overlay_marker_verts: Vec::new(),
+            overlay_dirty: false,
+            overlay_point_count: 0,
+            overlay_number_labels: Vec::new(),
+            overlay_normal_verts: Vec::new(),
+            overlay_normal_count: 0,
+            show_point_markers: settings.viewport.show_point_markers,
+            show_point_numbers: settings.viewport.show_point_numbers,
+            show_point_normals: settings.viewport.show_point_normals,
+            scene_edge_verts: Vec::new(),
+            point_marker_size: 0.02,
+            point_marker_color: [0.85, 0.85, 1.0],
             world_unit: cce_ui::units::Unit::Mm,
             pick_cache: None,
             last_scene_mvp: None,
@@ -6457,6 +6448,29 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 let val = !self.wireframe;
                 self.wireframe = val;
                 self.write_render_toggle("Show Wireframe", val);
+                // The edge list is collected with the scene and dropped
+                // while the wireframe is off, so switching it on has to
+                // rebuild — there is nothing staged to draw otherwise.
+                self.rebuild_scene_geometry();
+            }
+            // The three point overlays. Each is collected in
+            // `rebuild_scene_geometry` off the scene's own Detail, so the
+            // flip has to re-run it: the meshes are built from the flags,
+            // not filtered at draw time.
+            Action::TogglePointMarkers => {
+                self.show_point_markers = !self.show_point_markers;
+                self.rebuild_scene_geometry();
+                settings_changed = true;
+            }
+            Action::TogglePointNumbers => {
+                self.show_point_numbers = !self.show_point_numbers;
+                self.rebuild_scene_geometry();
+                settings_changed = true;
+            }
+            Action::TogglePointNormals => {
+                self.show_point_normals = !self.show_point_normals;
+                self.rebuild_scene_geometry();
+                settings_changed = true;
             }
             Action::WireframeColor => self.open_dialog_on_settings(),
             Action::ToggleSquareViewport => {
@@ -8576,16 +8590,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         if self.spheres_dirty {
             self.spheres_dirty = false;
             renderer.update_mesh(meshes.spheres, bytemuck::cast_slice(&self.rt_sphere_verts));
-            // Edge mesh for the wire pass: each triangle's three edges as
-            // LINE_LIST vertex pairs, carrying the same colors.
-            let mut edges = Vec::with_capacity(self.rt_sphere_verts.len() * 2);
-            for tri in self.rt_sphere_verts.chunks_exact(3) {
-                for (a, b) in [(0, 1), (1, 2), (2, 0)] {
-                    edges.push(tri[a]);
-                    edges.push(tri[b]);
-                }
-            }
-            renderer.update_mesh(meshes.sphere_edges, bytemuck::cast_slice(&edges));
+            // Edge mesh for the wire pass, collected with the scene.
+            renderer.update_mesh(meshes.sphere_edges, bytemuck::cast_slice(&self.scene_edge_verts));
         }
 
         // The Render node's point display: rebuilt whenever the geometry or
@@ -8621,16 +8627,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             self.viewport_dirty = true;
         }
 
-        // Per-node meta markers and wires, staged by rebuild_scene_geometry.
-        if self.meta_points_dirty {
-            self.meta_points_dirty = false;
-            renderer.update_mesh(meshes.meta_points, bytemuck::cast_slice(&self.meta_marker_verts));
-            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;
+        // The point overlays, staged by rebuild_scene_geometry.
+        if self.overlay_dirty {
+            self.overlay_dirty = false;
+            renderer.update_mesh(meshes.overlay_points, bytemuck::cast_slice(&self.overlay_marker_verts));
+            self.overlay_point_count = self.overlay_marker_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;
+                .update_mesh(meshes.overlay_normals, bytemuck::cast_slice(&self.overlay_normal_verts));
+            self.overlay_normal_count = self.overlay_normal_verts.len() as u32;
             self.viewport_dirty = true;
         }
     }
@@ -8687,9 +8691,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             pivot: renderer.create_mesh(bytemuck::cast_slice(&pivot_verts)),
             points: renderer.create_mesh(&[]),
             group_points: renderer.create_mesh(&[]),
-            meta_points: renderer.create_mesh(&[]),
-            meta_wires: renderer.create_mesh(&[]),
-            meta_normals: renderer.create_mesh(&[]),
+            overlay_points: renderer.create_mesh(&[]),
+            overlay_normals: renderer.create_mesh(&[]),
         });
         // Scene geometry built during `State::new` (before the renderer
         // existed) uploads on the first frame's flush.
@@ -8863,18 +8866,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         draws.push(SceneDraw { mesh: meshes.group_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
                     }
                     // Per-node meta "Point Markers", same full-opacity tier.
-                    if self.meta_point_count > 0 {
-                        draws.push(SceneDraw { mesh: meshes.meta_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
+                    if self.overlay_point_count > 0 {
+                        draws.push(SceneDraw { mesh: meshes.overlay_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
                     }
                     if self.vertex_count_spheres > 0 {
-                        // With wires coming — the global toggle's or any
-                        // node's meta Wireframe — the fill is pushed back by
-                        // its slope-scaled offset so the lattice reads solid.
-                        let base = if self.wireframe || self.meta_wire_count > 0 {
-                            self.wire_width
-                        } else {
-                            0.0
-                        };
+                        // With wires coming, the fill is pushed back by its
+                        // slope-scaled offset so the lattice reads solid.
+                        let base = if self.wireframe { self.wire_width } else { 0.0 };
                         draws.push(SceneDraw { mesh: meshes.spheres, mvp, wireframe: false, wire_tint: NO_TINT, opacity: geo_opacity, line_width: 1.0, wire_base_width: base });
                         if self.wireframe {
                             // The wire pass rides ON TOP of the fill (never
@@ -8896,16 +8894,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             let wire_alpha = self.wire_color[3].clamp(0.0, 1.0);
                             draws.push(SceneDraw { mesh: meshes.sphere_edges, mvp, wireframe: true, wire_tint: tint, opacity: wire_alpha, line_width: self.wire_width, wire_base_width: 0.0 });
                         }
-                        // Per-node meta Wireframe: the same wire pass, scoped
-                        // to the flagged nodes' edges, in geometry colors.
-                        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,
+                        // Show 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 });
+                        if self.overlay_normal_count > 0 {
+                            draws.push(SceneDraw { mesh: meshes.overlay_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/command.rs b/src/command.rs
index 623e28d..4d76fa5 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -190,6 +190,12 @@ pub const COMMANDS: &[Command] = &[
     Command { id: "toggle_camera_pivot", label: "Show Camera Pivot", context: Context::Viewport, run: Run::Key(Action::ToggleCameraPivot), default_chord: None },
     Command { id: "toggle_wireframe", label: "Show Wireframe", context: Context::Viewport, run: Run::Key(Action::ToggleWireframe), default_chord: None },
     Command { id: "wireframe_color", label: "Wireframe Color", context: Context::Viewport, run: Run::Key(Action::WireframeColor), default_chord: None },
+    // The point overlays on the visible scene. Per-node `meta` child
+    // preferences until 2026-09-23; global display settings now, reached
+    // here like every other viewport toggle.
+    Command { id: "toggle_point_markers", label: "Show Point Markers", context: Context::Viewport, run: Run::Key(Action::TogglePointMarkers), default_chord: None },
+    Command { id: "toggle_point_numbers", label: "Show Point Numbers", context: Context::Viewport, run: Run::Key(Action::TogglePointNumbers), default_chord: None },
+    Command { id: "toggle_point_normals", label: "Show Point Normals", context: Context::Viewport, run: Run::Key(Action::TogglePointNormals), default_chord: None },
     Command { id: "toggle_square_viewport", label: "Square Aspect", context: Context::Viewport, run: Run::Key(Action::ToggleSquareViewport), default_chord: Some("Ctrl+a") },
 
     // --- Parameters ---
diff --git a/src/dialog.rs b/src/dialog.rs
index 9529bb1..8d107a5 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1494,6 +1494,9 @@ impl State {
             "toggle_origin" => self.viewport().show_origin,
             "toggle_camera_pivot" => self.viewport().show_camera_pivot,
             "toggle_wireframe" => self.wireframe,
+            "toggle_point_markers" => self.show_point_markers,
+            "toggle_point_numbers" => self.show_point_numbers,
+            "toggle_point_normals" => self.show_point_normals,
             "toggle_square_viewport" => self.square_viewport,
             "toggle_network_plate" => self.network_plate,
             "toggle_circular_pane" => self.circular_network_pane,
diff --git a/src/main.rs b/src/main.rs
index 043c985..23d4d2a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -914,10 +914,10 @@ mod tests {
         state.apply_settings_from_menubar_subnets();
         assert_eq!(state.world_unit, cce_ui::units::Unit::Cm);
         assert!((state.world_unit_mm() - 10.0).abs() < 1e-4);
-        assert!((state.meta_marker_size - 0.05).abs() < 1e-6);
-        assert!((state.meta_marker_color[0] - 1.0).abs() < 0.01);
-        assert!((state.meta_marker_color[1] - 0.5).abs() < 0.01);
-        assert!((state.meta_marker_color[2] - 0.0).abs() < 0.01);
+        assert!((state.point_marker_size - 0.05).abs() < 1e-6);
+        assert!((state.point_marker_color[0] - 1.0).abs() < 0.01);
+        assert!((state.point_marker_color[1] - 0.5).abs() < 0.01);
+        assert!((state.point_marker_color[2] - 0.0).abs() < 0.01);
     }
 
     /// An old save carries Main/View/Guides/Render at the root with the user's
@@ -2529,13 +2529,13 @@ mod tests {
         assert_eq!(gn.2, "text");
     }
 
-    /// The per-node meta (preferences) child: ensure adds it to every
-    /// geometry-producing node (idempotently, restoring stripped params),
-    /// leaves cameras and the Session tree alone, evaluation ignores it,
-    /// and the overlay walk turns its Point Markers / Point Numbers prefs
-    /// into marker geometry and index labels for visible nodes only.
+    /// The point overlays are a VIEW setting, not a node property: the
+    /// three flags decide them for the whole displayed scene, read off the
+    /// merged Detail the geometry rebuild already produced. Their per-node
+    /// `meta` children are stripped from any tree that still carries them,
+    /// cameras and the root meta node untouched.
     #[test]
-    fn test_meta_node_prefs_and_overlays() {
+    fn test_point_overlays_are_a_global_view_setting() {
         let templates_root = crate::app::load_fs_tree();
         let sphere_t = templates_root.children.iter().find(|t| t.name == "Sphere").unwrap();
         let camera_t = templates_root.children.iter().find(|t| t.name == "Camera").unwrap();
@@ -2550,45 +2550,55 @@ mod tests {
         camera.id = "cam".to_string();
         camera.name = "Camera 1".to_string();
 
+        let meta_child = |id: &str| FsNode {
+            id: id.to_string(),
+            name: "meta".to_string(),
+            node_type: "meta".to_string(),
+            children: vec![],
+            params: vec![],
+            geometry_visible: false,
+            position: (0.0, 4.0),
+            inputs: 0,
+            outputs: 0,
+        };
+        // A tree as an older save carries it: a meta child on the sphere and
+        // on its internal stages, plus the ROOT meta node beside them.
+        sphere.children.push(meta_child("s_meta"));
+        let opencl_idx = sphere.children.iter().position(|c| c.name == "opencl1").unwrap();
+        sphere.children[opencl_idx].children.push(meta_child("s_ocl_meta"));
+        let mut session = meta_child("root_meta");
+        session.geometry_visible = true;
+
         let mut root = FsNode {
             id: "root".to_string(),
             name: "root".to_string(),
             node_type: "node".to_string(),
-            children: vec![sphere, camera],
+            children: vec![sphere, camera, session],
             params: vec![],
             geometry_visible: true,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
         };
-        crate::app::ensure_meta_children(&mut root);
 
-        // The sphere gains a meta child with both prefs; so do its opencl and
-        // output stages (uniform rule: every geometry-producing node).
-        let s = &root.children[0];
-        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", "Point Normals", "Wireframe"]
-        );
-        assert!(meta.params.iter().all(|p| p.default == "false"));
-        let opencl = s.children.iter().find(|c| c.name == "opencl1").unwrap();
-        assert!(opencl.children.iter().any(|c| c.node_type == "meta"));
-        // The camera does not.
-        assert!(!root.children[1].children.iter().any(|c| c.node_type == "meta"));
+        crate::app::strip_meta_children(&mut root);
 
-        // Idempotent, and stripped params come back.
+        // Gone from the placed nodes, at every depth…
+        assert!(!root.children[0].children.iter().any(|c| c.node_type == "meta"));
+        let opencl = root.children[0].children.iter().find(|c| c.name == "opencl1").unwrap();
+        assert!(!opencl.children.iter().any(|c| c.node_type == "meta"));
+        // …and the root meta node, which is the SESSION container and not a
+        // per-node child at all, is still standing.
+        assert!(
+            root.children.iter().any(|c| c.id == "root_meta" && c.node_type == "meta"),
+            "the strip took the root meta node"
+        );
+        // Idempotent.
         let before = serde_json::to_string(&root).unwrap();
-        crate::app::ensure_meta_children(&mut root);
+        crate::app::strip_meta_children(&mut root);
         assert_eq!(before, serde_json::to_string(&root).unwrap());
-        root.children[0].children.iter_mut().find(|c| c.node_type == "meta").unwrap()
-            .params.retain(|p| p.name != "Point Numbers");
-        crate::app::ensure_meta_children(&mut root);
-        assert!(crate::app::meta_pref(&root.children[0], "Point Numbers") == false);
-        assert!(root.children[0].children.iter().find(|c| c.node_type == "meta").unwrap()
-            .params.iter().any(|p| p.name == "Point Numbers"));
-
-        // Evaluation is unaffected by the meta children.
+
+        // Evaluation is unaffected by their going.
         let mut visited = Vec::new();
         let mut err = None;
         let mut cache = crate::geometry::SimCache::default();
@@ -2598,41 +2608,30 @@ mod tests {
             &mut visited,
             &mut err,
             &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
-        ).expect("sphere with meta evaluates");
+        ).expect("the stripped sphere evaluates");
         assert!(err.is_none(), "{err:?}");
-        assert_eq!(geom.num_points(), crate::geometry::sphere_point_len(16, 24));
-
-        // Overlays: nothing while the prefs are off…
-        let mut cache = crate::geometry::SimCache::default();
-        let (markers, labels, wires, normals) = crate::render::collect_meta_overlays(
-            &root, &root, 0.02, [1.0, 0.5, 0.0], &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
-        assert!(markers.is_empty() && labels.is_empty() && wires.is_empty() && normals.is_empty());
-
-        // …all four overlays for the flagged sphere: 240 marker verts per
-        // POINT, one label per point, and one LINE_LIST pair per mesh edge.
-        // All three used to be "per distinct quantized position", reconstructed
-        // every frame; they are now just the point and edge lists.
-        {
-            let meta = root.children[0].children.iter_mut()
-                .find(|c| c.node_type == "meta").unwrap();
-            for p in meta.params.iter_mut() { p.default = "true".to_string(); }
-        }
-        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, normals) = crate::render::collect_meta_overlays(
-            &root, &root, 0.02, [1.0, 0.5, 0.0], &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
-        assert_eq!(labels.len(), crate::geometry::sphere_point_len(16, 24), "one label per point");
-        assert_eq!(markers.len(), labels.len() * 240);
+        let points = crate::geometry::sphere_point_len(16, 24);
+        assert_eq!(geom.num_points(), points);
+
+        // Nothing while the three flags are off…
+        let (markers, labels, normals) =
+            crate::render::scene_point_overlays(&geom, false, false, false, 0.02, [1.0, 0.5, 0.0]);
+        assert!(markers.is_empty() && labels.is_empty() && normals.is_empty());
+
+        // …and all three off the one Detail: 240 marker verts per POINT, one
+        // label per point, one whisker pair per point.
+        let (markers, labels, normals) =
+            crate::render::scene_point_overlays(&geom, true, true, true, 0.02, [1.0, 0.5, 0.0]);
+        assert_eq!(labels.len(), points, "one label per point");
+        assert_eq!(markers.len(), points * 240);
         assert!(labels.iter().any(|(_, i)| *i > 0));
         // The marker color parameter flows into the vertices (linearized).
         let expect = cce_ui::colors::to_linear_rgb([1.0, 0.5, 0.0]);
         assert!(markers.iter().all(|v| v.color == expect));
-        assert_eq!(wires.len(), geom.edges().len() * 2, "one pair per unique edge");
-        // 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);
+        // Normals: one whisker per 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(), points * 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]);
@@ -2645,12 +2644,21 @@ mod tests {
             );
         }
 
-        // …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, normals) = crate::render::collect_meta_overlays(
-            &root, &root, 0.02, [1.0, 0.5, 0.0], &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
-        assert!(markers.is_empty() && labels.is_empty() && wires.is_empty() && normals.is_empty());
+        // Each flag is independent — no flag drags another in.
+        let (m, l, n) =
+            crate::render::scene_point_overlays(&geom, true, false, false, 0.02, [1.0, 0.5, 0.0]);
+        assert!(!m.is_empty() && l.is_empty() && n.is_empty());
+        let (m, l, n) =
+            crate::render::scene_point_overlays(&geom, false, true, false, 0.02, [1.0, 0.5, 0.0]);
+        assert!(m.is_empty() && !l.is_empty() && n.is_empty());
+
+        // The wire pass draws the mesh's TOPOLOGICAL edges — one pair per
+        // unique edge, not per triangle side. This is what the global Show
+        // Wireframe draws now; it drew the triangle soup until the per-node
+        // meta Wireframe (which drew this) was retired into it.
+        let wires = crate::render::scene_edge_verts(&geom);
+        assert_eq!(wires.len(), geom.edges().len() * 2, "one pair per unique edge");
+        assert!(wires.len() < geom.num_prims() * 6, "still drawing the soup's edges");
     }
 
     /// The Plane template's construction controls: Rows/Columns set the grid
@@ -6023,7 +6031,10 @@ mod tests {
         assert_eq!(get("Radius"), "0.7");
         assert_eq!(get("Scatter Seed"), "1.1", "a param the native node lacked takes the template default");
         assert!(e.children.iter().any(|c| c.name == "hull1"));
-        assert!(e.children.iter().any(|c| c.node_type == "meta" && c.params.iter().any(|p| p.name == "Point Markers")), "the meta child is kept");
+        // The per-node meta child an older save carried is stripped, here as
+        // everywhere else: merge_template_defs takes them before it matches
+        // anything, so a recompose never has one to carry over.
+        assert!(!e.children.iter().any(|c| c.node_type == "meta"), "a meta child survived the recompose");
         let (g, err) = eval(&root, e);
         assert!(err.is_none(), "{err:?}");
         let g = g.unwrap();
diff --git a/src/project.rs b/src/project.rs
index a52fcef..693a3ae 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -386,7 +386,6 @@ impl State {
             let mut proj: Project = serde_json::from_str(&content)?;
             proj.sanitize_node_names();
             crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
-            crate::app::ensure_meta_children(&mut proj.root);
             let saved_pane_vis = Self::project_pane_visibility(&proj.root);
             self.fs_root = proj.root;
             // The project's viewport settings — the Guides and Render
@@ -453,7 +452,6 @@ impl State {
         let mut proj: Project = serde_json::from_str(&content)?;
         proj.sanitize_node_names();
         crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
-        crate::app::ensure_meta_children(&mut proj.root);
         let saved_pane_vis = Self::project_pane_visibility(&proj.root);
         self.fs_root = proj.root;
         // As in the default-project branch: the file's viewport settings
@@ -996,11 +994,11 @@ impl State {
         ensure_param(guides_node, "Origin Guide Size", "spinbox", &origin_size_seed, &[], Some(1.0), Some(50.0), Some(1.0));
         let grid_color_seed = migrated_grid_color.unwrap_or_else(|| color_to_hex(vp_grid_color));
         ensure_param(guides_node, "Grid Color", "color", &grid_color_seed, &[], None, None, None);
-        // Size of the per-node meta "Point Markers" overlay, in thousandths
+        // Size of the Show Point Markers overlay, in thousandths
         // (the Grid Thickness convention): 20 = 0.02 world units.
-        let marker_size_seed = ((self.meta_marker_size * 1000.0).round() as i32).to_string();
+        let marker_size_seed = ((self.point_marker_size * 1000.0).round() as i32).to_string();
         ensure_param(guides_node, "Point Marker Size", "spinbox", &marker_size_seed, &[], Some(5.0), Some(100.0), Some(1.0));
-        let marker_color_seed = color_to_hex(self.meta_marker_color);
+        let marker_color_seed = color_to_hex(self.point_marker_color);
         ensure_param(guides_node, "Point Marker Color", "color", &marker_color_seed, &[], None, None, None);
         // What a world unit is in the real world. The geometry never
         // converts; the viewport's scale readout and `View 1:1` do.
@@ -1152,16 +1150,16 @@ impl State {
                     "Grid Color" => if let Some(col) = hex_to_color(&p.default) { self.viewport_mut().grid_color = col; }
                     "Point Marker Size" => if let Ok(val) = p.default.parse::<f32>() {
                         let size = val / 1000.0;
-                        if (size - self.meta_marker_size).abs() > 1e-6 {
-                            self.meta_marker_size = size;
+                        if (size - self.point_marker_size).abs() > 1e-6 {
+                            self.point_marker_size = size;
                             // The marker geometry bakes the radius in, so a
                             // size change re-collects the overlays.
                             self.rebuild_scene_geometry();
                         }
                     }
                     "Point Marker Color" => if let Some(col) = hex_to_color(&p.default) {
-                        if col != self.meta_marker_color {
-                            self.meta_marker_color = col;
+                        if col != self.point_marker_color {
+                            self.point_marker_color = col;
                             // Baked into the marker verts, like the radius.
                             self.rebuild_scene_geometry();
                         }
diff --git a/src/render.rs b/src/render.rs
index 97b59d0..4fc1421 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -185,7 +185,7 @@ impl State {
 
         self.append_context_border(&mut pc);
         self.append_frame_text(&mut pc);
-        self.append_meta_point_numbers(&mut pc);
+        self.append_point_numbers(&mut pc);
         self.append_scale_readout(&mut pc);
         self.append_viewer_state_overlay(&mut pc);
         self.append_popovers(&mut pc);
@@ -996,8 +996,8 @@ impl State {
     /// clipped to the viewport pane. The mvp cache refreshes whenever the
     /// camera or pane changes (`stage_frame`), so the labels track orbits;
     /// a frame staged before the first scene staging simply draws none.
-    fn append_meta_point_numbers(&self, pc: &mut PaintCtx) {
-        if !self.show_viewport || self.meta_number_labels.is_empty() {
+    fn append_point_numbers(&self, pc: &mut PaintCtx) {
+        if !self.show_viewport || self.overlay_number_labels.is_empty() {
             return;
         }
         let Some(mvp) = self.last_scene_mvp else { return };
@@ -1006,7 +1006,7 @@ impl State {
             return;
         }
         pc.clip(rect(vx, vy, vw, vh), |pc| {
-            for (pos, idx) in &self.meta_number_labels {
+            for (pos, idx) in &self.overlay_number_labels {
                 let clip_pos = mvp * glam::Vec4::new(pos[0], pos[1], pos[2], 1.0);
                 if clip_pos.w <= 0.0 {
                     continue;
@@ -1200,28 +1200,32 @@ impl State {
         self.rt_geometry_version += 1;
         self.viewport_dirty = true;
 
-        // 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, normals) = {
-            let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
-            // Same scoping as the scene walk above: overlays annotate what is
-            // on screen, so they walk the same current level.
-            collect_meta_overlays(&self.fs_root, self.viewport_editor_dir(), self.meta_marker_size, self.meta_marker_color, &mut sim)
-        };
-        self.sim_cache = sim_cache;
-        self.meta_marker_verts = markers;
-        self.meta_number_labels = labels;
-        self.meta_wire_verts = wires;
-        self.meta_normal_verts = normals;
+        // The point overlays ride the same rebuild, off the same `geom`:
+        // they annotate what is on screen, and what is on screen is exactly
+        // this Detail.
+        let (markers, labels, normals) = scene_point_overlays(
+            &geom,
+            self.show_point_markers,
+            self.show_point_numbers,
+            self.show_point_normals,
+            self.point_marker_size,
+            self.point_marker_color,
+        );
+        self.overlay_marker_verts = markers;
+        self.overlay_number_labels = labels;
+        self.overlay_normal_verts = normals;
+        // The wire pass's edges, likewise — topological, and only while the
+        // wireframe is actually on.
+        self.scene_edge_verts =
+            if self.wireframe { scene_edge_verts(&geom) } else { Vec::new() };
 
         // Visualize's vector markers ride the same LINE_LIST channel as the
         // normal whiskers.
-        self.meta_normal_verts.extend(crate::geometry::vis_marker_vertices(
+        self.overlay_normal_verts.extend(crate::geometry::vis_marker_vertices(
             &geom,
             cce_ui::colors::to_linear_rgb,
         ));
-        self.meta_points_dirty = true;
+        self.overlay_dirty = true;
 
         // Last, not first: the page's status line would otherwise be
         // overwritten by the geometry pass's own, and a level showing a page
@@ -1251,147 +1255,118 @@ impl State {
     }
 }
 
-/// The per-node meta (preferences) overlay walk: for every node whose `meta`
-/// child asks for Point Markers or Point Numbers — and whose geometry is
-/// visible through the same parent chain the scene walk uses — evaluate the
-/// node and collect marker geometry and/or (position, vertex index) labels.
-/// Positions dedupe the triangle soup's repeats; a label keeps the FIRST
-/// index at its position, matching the spreadsheet's vertex numbering.
-/// `start` scopes the walk to the displayed network level (the scene walk's
-/// contract — pass `root` for both to cover the whole tree); evaluation
-/// stays rooted at `root`.
-pub(crate) fn collect_meta_overlays(
-    root: &FsNode,
-    start: &FsNode,
+/// The point overlays on the displayed scene: marker geometry for Show
+/// Point Markers, `(position, index)` labels for Show Point Numbers, and
+/// normal whiskers for Show Point Normals — each read straight off the
+/// merged scene `Detail` the geometry rebuild has already produced.
+///
+/// It reads that one `Detail` rather than walking the tree because the
+/// overlays are a property of the VIEW, not of individual nodes. While they
+/// were per-node `meta` preferences this had to be a second walk that
+/// re-evaluated every flagged node on its own — the same repeated evaluation
+/// that made a five-flag Embryo cost 2.4 s an edit. The scene is evaluated
+/// once now, and the overlays cost a pass over its points.
+pub(crate) fn scene_point_overlays(
+    geom: &crate::detail::Detail,
+    markers_on: bool,
+    numbers_on: bool,
+    normals_on: bool,
     point_size: f32,
     marker_color: [f32; 3],
-    sim: &mut crate::geometry::EvalSim,
 ) -> (
     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,
-        parent_visible: bool,
-        point_size: f32,
-        marker_color: [f32; 3],
-        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");
-        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(
-                root, node, &mut visited, &mut err, sim,
-            ) {
-                if want_wires {
-                    // The geometry's own edge list as LINE_LIST pairs, carrying
-                    // its own colors. Two changes from the soup version, both
-                    // of them the topology finally being visible: an edge two
-                    // faces share is drawn once instead of twice, and a quad
-                    // shows as a quad — the fan diagonal was never an edge of
-                    // the mesh, only of its triangulation.
-                    for e in geom.edges() {
-                        for &p in e {
-                            let p = p as usize;
-                            wires.push(crate::geometry::Vertex3D {
-                                position: geom.positions()[p],
-                                color: geom.color(p),
-                            });
-                        }
-                    }
-                }
-                if want_markers {
-                    // One marker per point. The soup emitted one per corner and
-                    // leaned on points_vertices deduping by position.
-                    let src: Vec<crate::geometry::Vertex3D> = geom
-                        .positions()
-                        .iter()
-                        .map(|&position| crate::geometry::Vertex3D { position, color: [0.0; 3] })
-                        .collect();
-                    markers.extend(crate::geometry::points_vertices(
-                        &src,
-                        point_size,
-                        cce_ui::colors::to_linear_rgb(marker_color),
-                    ));
-                }
-                if want_normals {
-                    // Smooth point normals: for each point, the normalized sum
-                    // of the face normals of the primitives touching it.
-                    // Template meshes wind CCW seen from outside (the raster
-                    // culling convention), so the plain cross(B-A, C-A) points
-                    // outward. The kernel outputs' Norm attribute is a default
-                    // up-vector — useless here.
-                    //
-                    // The soup had to reconstruct "which triangles touch this
-                    // point" by hashing quantized positions, every frame. That
-                    // was a weld in all but name, and it is what point_prims
-                    // answers directly.
-                    use glam::Vec3;
-                    let len = point_size * 4.0;
-                    let color = cce_ui::colors::to_linear_rgb([0.45, 0.8, 1.0]);
-                    for p in 0..geom.num_points() {
-                        let mut sum = Vec3::ZERO;
-                        for &prim in geom.point_prims(p) {
-                            let pts = geom.prim_points(prim as usize);
-                            if pts.len() < 3 {
-                                continue;
-                            }
-                            let a = geom.pos(pts[0] as usize);
-                            let b = geom.pos(pts[1] as usize);
-                            let c = geom.pos(pts[2] as usize);
-                            let n = (b - a).cross(c - a);
-                            if n.length_squared() > 1e-12 {
-                                sum += n;
-                            }
-                        }
-                        let n = sum.normalize_or_zero();
-                        if n == Vec3::ZERO {
-                            continue;
-                        }
-                        let pos = geom.positions()[p];
-                        let tip = geom.pos(p) + n * len;
-                        normals.push(crate::geometry::Vertex3D { position: pos, color });
-                        normals.push(crate::geometry::Vertex3D { position: tip.to_array(), color });
-                    }
+    if markers_on {
+        // One marker per point. The soup emitted one per corner and leaned
+        // on points_vertices deduping by position.
+        let src: Vec<crate::geometry::Vertex3D> = geom
+            .positions()
+            .iter()
+            .map(|&position| crate::geometry::Vertex3D { position, color: [0.0; 3] })
+            .collect();
+        markers.extend(crate::geometry::points_vertices(
+            &src,
+            point_size,
+            cce_ui::colors::to_linear_rgb(marker_color),
+        ));
+    }
+    if normals_on {
+        // Smooth point normals: for each point, the normalized sum of the
+        // face normals of the primitives touching it. Template meshes wind
+        // CCW seen from outside (the raster culling convention), so the
+        // plain cross(B-A, C-A) points outward. The kernel outputs' Norm
+        // attribute is a default up-vector — useless here.
+        //
+        // The soup had to reconstruct "which triangles touch this point" by
+        // hashing quantized positions, every frame. That was a weld in all
+        // but name, and it is what point_prims answers directly.
+        use glam::Vec3;
+        let len = point_size * 4.0;
+        let color = cce_ui::colors::to_linear_rgb([0.45, 0.8, 1.0]);
+        for p in 0..geom.num_points() {
+            let mut sum = Vec3::ZERO;
+            for &prim in geom.point_prims(p) {
+                let pts = geom.prim_points(prim as usize);
+                if pts.len() < 3 {
+                    continue;
                 }
-                if want_numbers {
-                    // The point's index, which is now also its spreadsheet row.
-                    // The soup numbered by first-corner-at-this-position, so
-                    // the overlay and the spreadsheet disagreed.
-                    for p in 0..geom.num_points() {
-                        labels.push((geom.positions()[p], p as u32));
-                    }
+                let a = geom.pos(pts[0] as usize);
+                let b = geom.pos(pts[1] as usize);
+                let c = geom.pos(pts[2] as usize);
+                let n = (b - a).cross(c - a);
+                if n.length_squared() > 1e-12 {
+                    sum += n;
                 }
             }
-        }
-        for c in &node.children {
-            visit(root, c, is_visible, point_size, marker_color, markers, labels, wires, normals, sim);
+            let n = sum.normalize_or_zero();
+            if n == Vec3::ZERO {
+                continue;
+            }
+            let pos = geom.positions()[p];
+            let tip = geom.pos(p) + n * len;
+            normals.push(crate::geometry::Vertex3D { position: pos, color });
+            normals.push(crate::geometry::Vertex3D { position: tip.to_array(), color });
         }
     }
-    for c in &start.children {
-        visit(root, c, true, point_size, marker_color, &mut markers, &mut labels, &mut wires, &mut normals, sim);
+    if numbers_on {
+        // The point's index, which is also its spreadsheet row. The soup
+        // numbered by first-corner-at-this-position, so the overlay and the
+        // spreadsheet disagreed.
+        //
+        // A dense mesh can label tens of thousands of points and the text
+        // pass is per-frame, so cap it rather than melt the frame rate.
+        const MAX_LABELS: usize = 2000;
+        for p in 0..geom.num_points().min(MAX_LABELS) {
+            labels.push((geom.positions()[p], p as u32));
+        }
     }
-    // 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.
-    const MAX_LABELS: usize = 2000;
-    if labels.len() > MAX_LABELS {
-        labels.truncate(MAX_LABELS);
+    (markers, labels, normals)
+}
+
+/// The scene's own edges as LINE_LIST pairs for the wire pass, carrying the
+/// geometry's vertex colours.
+///
+/// The TOPOLOGICAL edge list, not the triangle soup's: an edge two faces
+/// share is drawn once instead of twice, and a quad shows as a quad — the
+/// fan diagonal was never an edge of the mesh, only of its triangulation.
+/// The soup version was what the global Show Wireframe drew until
+/// 2026-09-23, while the per-node meta Wireframe drew this one; with the
+/// per-node flag retired there is one wireframe, and it is this one.
+pub(crate) fn scene_edge_verts(geom: &crate::detail::Detail) -> Vec<crate::geometry::Vertex3D> {
+    let mut wires = Vec::new();
+    for e in geom.edges() {
+        for &p in e {
+            let p = p as usize;
+            wires.push(crate::geometry::Vertex3D {
+                position: geom.positions()[p],
+                color: geom.color(p),
+            });
+        }
     }
-    (markers, labels, wires, normals)
+    wires
 }
diff --git a/src/shortcut.rs b/src/shortcut.rs
index bdc1b56..9827df0 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -12,6 +12,14 @@ pub enum Action {
     ToggleCameraPivot,
     ToggleWireframe,
     WireframeColor,
+    /// The three point overlays on the visible scene — markers, index
+    /// numbers, normal whiskers. Global display settings reached from the
+    /// command palette; they were per-node `meta` child preferences until
+    /// 2026-09-23, which meant a display choice had to be made one node at
+    /// a time, on a hidden child you had to dive in to find.
+    TogglePointMarkers,
+    TogglePointNumbers,
+    TogglePointNormals,
     ToggleCircularPane,
     DetachCircularWindow,
     Save,
diff --git a/src/window.rs b/src/window.rs
index 956ec39..6815e18 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -816,7 +816,6 @@ impl State {
                         } else {
                             node.name = state.get_lowest_unused_name(&node.name);
                         }
-                        crate::app::ensure_meta_on(&mut node);
                         // New nodes arrive with their display flag OFF: the
                         // one-visible-per-directory rule means showing is an
                         // explicit act ('e', the click toggle), never a side