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

commite6de7c7745b6226babc70fa93aa74e9762bfc7ef
parentdcfc28f7b7
authorLucas Galante <[email protected]>
date2026-08-25 10:37
feat: the meta node — per-node preferences, with point markers and numbers

Every geometry-producing node (subnet instances and the native geometry
types; not cameras, not the Session tree) carries a permanent 'meta' child
holding that node's preferences. ensure_meta_on/ensure_meta_children create
it at instantiation and on every project load — the Session migration
pattern — and restore missing pref params, so old saves gain it and future
prefs retrofit automatically. It is undeletable (the delete_node gate,
alongside session), shows no geometry toggle, and evaluation ignores it.

Two prefs to start, both viewport overlays on the node's output:
- Point Markers: light markers at each distinct vertex, a meta_points scene
  mesh rebuilt with the scene (collect_meta_overlays walks the same
  visibility chain as the scene walk).
- Point Numbers: vertex indices as 2D text, projected through the raster
  scene's cached mvp (stage_frame caches the matrix and the pane rect in
  logical px; append_meta_point_numbers clips to the viewport pane and
  drops behind-camera and off-frustum labels; capped at 2000 labels).
Positions dedupe the triangle soup; a label keeps the FIRST index at its
position, matching the spreadsheet's numbering.

 CLAUDE.md      |  15 +++++++
 src/app.rs     | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 src/main.rs    | 102 ++++++++++++++++++++++++++++++++++++++++++++
 src/project.rs |   2 +
 src/render.rs  | 118 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/window.rs  |   1 +
 6 files changed, 363 insertions(+), 6 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 5c347ad..c9eba88 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -175,6 +175,21 @@ and the graph draws it without a geometry toggle. `State::session_node()` /
 `current_path`, since a first-segment check stopped working the day the
 settings nodes gained a parent.
 
+### The meta node (per-node preferences)
+
+Every geometry-producing node carries a **`meta` child** (node type `meta`) —
+per-node preferences, edited by entering the node and selecting it. Current
+prefs: "Point Markers" and "Point Numbers" (viewport overlays on that node's
+output; numbers project through the cached raster mvp into the 2D text pass,
+`append_meta_point_numbers`). `ensure_meta_on` / `ensure_meta_children`
+(src/app.rs) create it at instantiation and at every project load — the same
+migration pattern as the Session node — and also restore missing pref params,
+so adding a pref is one entry in `ensure_meta_on`'s list plus its consumer.
+`meta_pref(node, name)` is the read. Meta is undeletable (the `delete_node`
+gate alongside `session`), shows no geometry toggle, and is invisible to
+evaluation. Overlay data rebuilds with the scene (`collect_meta_overlays` in
+src/render.rs, walked with the scene's visibility chain).
+
 ### App-written settings: `~/.config/cce/cce-designer/state.kdl`
 
 `default_project` in state.kdl points at the project the main window opens on
diff --git a/src/app.rs b/src/app.rs
index a6942e9..c5cdbd5 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -295,6 +295,77 @@ 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")] {
+            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,
+                });
+            }
+        }
+    }
+    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) {
+    for c in &mut root.children {
+        ensure_meta_on(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,
@@ -732,6 +803,8 @@ 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,
 }
 
 /// A left-press on the detached circular window's chrome that becomes an
@@ -994,6 +1067,18 @@ 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)>,
+    /// The raster scene's model-view-projection and the viewport pane rect in
+    /// LOGICAL px, cached at staging so the 2D pass can project 3D overlays.
+    pub last_scene_mvp: Option<Mat4>,
+    pub last_scene_view_rect: (f32, f32, f32, f32),
     pub last_viewport_rt_mode: bool,
     /// Sphere-geometry cache for the path tracer (a copy of the last
     /// `rebuild_scene_geometry` output, so entering RT mode never re-runs
@@ -2181,14 +2266,17 @@ impl State {
             let Some(node) = dir.children.get(slot) else { return };
             let enterable = node.is_enterable();
             (
-                matches!(node.node_type.as_str(), "utility" | "session"),
+                matches!(node.node_type.as_str(), "utility" | "session" | "meta"),
                 node.geometry_visible,
                 enterable,
             )
         };
         let deletable = {
             let dir = self.current_dir();
-            dir.children.get(slot).map(|n| n.node_type != "session").unwrap_or(false)
+            dir.children
+                .get(slot)
+                .map(|n| !matches!(n.node_type.as_str(), "session" | "meta"))
+                .unwrap_or(false)
         };
         let mut options: Vec<String> = Vec::new();
         let mut actions: Vec<NodeMenuAction> = Vec::new();
@@ -2658,9 +2746,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
 
     pub fn delete_node(&mut self, slot: usize) -> bool {
         let len = self.current_dir().children.len();
-        // The Session node is permanent: every deletion route (context menu,
-        // Delete key, MCP) funnels through here, so this is the one gate.
-        if slot < len && self.current_dir().children[slot].node_type == "session" {
+        // 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.
+        if slot < len
+            && matches!(self.current_dir().children[slot].node_type.as_str(), "session" | "meta")
+        {
             return false;
         }
         if slot < len {
@@ -2859,6 +2950,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             if let Ok(content) = fs::read_to_string(&default_proj_path) {
                 if let Ok(mut proj) = serde_json::from_str::<Project>(&content) {
                     merge_template_defs(&mut proj.root, &node_templates);
+                    ensure_meta_children(&mut proj.root);
                     loaded_project = Some(proj);
                 }
             }
@@ -3156,6 +3248,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             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(),
+            last_scene_mvp: None,
+            last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
             last_viewport_rt_mode: false,
             rt_sphere_verts: Vec::new(),
             rt_geometry_version: 0,
@@ -5703,6 +5801,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.group_point_vertex_count = self.group_point_verts.len() as u32;
             self.viewport_dirty = true;
         }
+
+        // Per-node meta markers, 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;
+            self.viewport_dirty = true;
+        }
     }
 
     /// One-time renderer setup (engine `renderer_init` hook): the persistent
@@ -5726,6 +5832,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             pivot: renderer.create_mesh(bytemuck::cast_slice(&pivot_verts)),
             points: renderer.create_mesh(&[]),
             group_points: renderer.create_mesh(&[]),
+            meta_points: renderer.create_mesh(&[]),
         });
         // Scene geometry built during `State::new` (before the renderer
         // existed) uploads on the first frame's flush.
@@ -5849,7 +5956,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     if !rt_mode {
                     let aspect = cw as f32 / ch as f32;
                     let (proj, view_mat, model) = self.viewport().get_matrices(aspect, Some(camera_pos), Some(Vec3::new(rx, ry, rz)), Some(pivot));
-                    let mvp = (proj * view_mat * model).to_cols_array_2d();
+                    let mvp_mat = proj * view_mat * model;
+                    let mvp = mvp_mat.to_cols_array_2d();
+                    // Cache for the 2D pass's 3D-overlay projection (point
+                    // numbers): the matrix, and the pane rect back in logical
+                    // px. Refreshed exactly when the camera/pane changes.
+                    let s = self.scale as f32;
+                    self.last_scene_mvp = Some(mvp_mat);
+                    self.last_scene_view_rect =
+                        (sx as f32 / s, sy as f32 / s, cw as f32 / s, ch as f32 / s);
 
                     // The camera-pivot marker is WORLD-FIXED at the pivot point, like
                     // the origin gizmo. Its old yaw rotation existed to keep it glued
@@ -5886,6 +6001,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     if self.group_point_vertex_count > 0 {
                         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.vertex_count_spheres > 0 {
                         // With wires coming, the fill is pushed back by its
                         // slope-scaled offset so the lattice reads solid.
diff --git a/src/main.rs b/src/main.rs
index e4bacb4..d330ebc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1131,6 +1131,108 @@ mod tests {
         assert!(geom.vertices.iter().all(|v| !v.attributes.contains_key("mass")));
     }
 
+    /// 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.
+    #[test]
+    fn test_meta_node_prefs_and_overlays() {
+        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();
+
+        let mut sphere = sphere_t.clone();
+        sphere.id = "s".to_string();
+        sphere.name = "Sphere 1".to_string();
+        for child in &mut sphere.children {
+            child.id = format!("{}_{}", sphere.id, child.name);
+        }
+        let mut camera = camera_t.clone();
+        camera.id = "cam".to_string();
+        camera.name = "Camera 1".to_string();
+
+        let mut root = FsNode {
+            id: "root".to_string(),
+            name: "root".to_string(),
+            node_type: "node".to_string(),
+            children: vec![sphere, camera],
+            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"]
+        );
+        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"));
+
+        // Idempotent, and stripped params come back.
+        let before = serde_json::to_string(&root).unwrap();
+        crate::app::ensure_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.
+        let mut visited = Vec::new();
+        let mut err = None;
+        let mut cache = crate::geometry::SimCache::default();
+        let geom = crate::geometry::generate_single_node_geometry_with_errors(
+            &root,
+            &root.children[0],
+            &mut visited,
+            &mut err,
+            &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
+        ).expect("sphere with meta evaluates");
+        assert!(err.is_none(), "{err:?}");
+        assert_eq!(geom.vertices.len(), 16 * 24 * 6);
+
+        // Overlays: nothing while the prefs are off…
+        let mut cache = crate::geometry::SimCache::default();
+        let (markers, labels) = crate::render::collect_meta_overlays(
+            &root, 0.02, &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
+        assert!(markers.is_empty() && labels.is_empty());
+
+        // …both overlays for the flagged sphere (240 marker verts per
+        // deduped point, labels matching the same dedupe)…
+        {
+            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"));
+        let mut cache = crate::geometry::SimCache::default();
+        let (markers, labels) = 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));
+
+        // …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) = crate::render::collect_meta_overlays(
+            &root, 0.02, &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
+        assert!(markers.is_empty() && labels.is_empty());
+    }
+
     /// The Plane template's construction controls: Rows/Columns set the grid
     /// tessellation (vertex count = rows * columns * 6 — coverage the
     /// long-standing spinboxes never had), and the Center X/Y/Z channels
diff --git a/src/project.rs b/src/project.rs
index 167bf2b..973173d 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -129,6 +129,7 @@ impl State {
             let content = fs::read_to_string(path)?;
             let mut proj: Project = serde_json::from_str(&content)?;
             crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
+            crate::app::ensure_meta_children(&mut proj.root);
             self.fs_root = proj.root;
             self.ensure_menubar_subnets();
             self.apply_settings_from_menubar_subnets();
@@ -184,6 +185,7 @@ impl State {
         let content = fs::read_to_string(&state_file_path)?;
         let mut proj: Project = serde_json::from_str(&content)?;
         crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
+        crate::app::ensure_meta_children(&mut proj.root);
         self.fs_root = proj.root;
         self.ensure_menubar_subnets();
         self.apply_settings_from_menubar_subnets();
diff --git a/src/render.rs b/src/render.rs
index 7a1325e..05fbf95 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -171,6 +171,7 @@ impl State {
 
         self.append_context_border(&mut pc);
         self.append_frame_text(&mut pc);
+        self.append_meta_point_numbers(&mut pc);
         self.append_popovers(&mut pc);
         self.append_dock_drag_overlay(&mut pc);
         self.append_plate_corners(&mut pc);
@@ -778,6 +779,37 @@ impl State {
         }
     }
 
+    /// The meta "Point Numbers" overlay: each collected (position, index)
+    /// label projects through the raster scene's cached mvp into 2D text,
+    /// 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() {
+            return;
+        }
+        let Some(mvp) = self.last_scene_mvp else { return };
+        let (vx, vy, vw, vh) = self.last_scene_view_rect;
+        if vw <= 0.0 || vh <= 0.0 {
+            return;
+        }
+        pc.clip(rect(vx, vy, vw, vh), |pc| {
+            for (pos, idx) in &self.meta_number_labels {
+                let clip_pos = mvp * glam::Vec4::new(pos[0], pos[1], pos[2], 1.0);
+                if clip_pos.w <= 0.0 {
+                    continue;
+                }
+                let ndc = clip_pos / clip_pos.w;
+                if ndc.x.abs() > 1.02 || ndc.y.abs() > 1.02 {
+                    continue;
+                }
+                let sx = vx + (ndc.x * 0.5 + 0.5) * vw;
+                let sy = vy + (0.5 - ndc.y * 0.5) * vh;
+                pc.text(idx.to_string(), sx + 4.0, sy - 6.0, 10.0, [0xee, 0xee, 0xff]);
+            }
+        });
+    }
+
     pub(crate) fn rebuild_scene_geometry(&mut self) {
         let mut ocl_error = None;
         // The sim cache lives on State so playing forward steps each simnet once
@@ -820,6 +852,18 @@ impl State {
         self.spheres_dirty = true;
         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) = {
+            let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
+            collect_meta_overlays(&self.fs_root, self.point_size, &mut sim)
+        };
+        self.sim_cache = sim_cache;
+        self.meta_marker_verts = markers;
+        self.meta_number_labels = labels;
+        self.meta_points_dirty = true;
     }
 
     /// The path tracer's scene: the sphere geometry (and the reference cube if
@@ -843,3 +887,77 @@ 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.
+pub(crate) fn collect_meta_overlays(
+    root: &FsNode,
+    point_size: f32,
+    sim: &mut crate::geometry::EvalSim,
+) -> (Vec<crate::geometry::Vertex3D>, Vec<([f32; 3], u32)>) {
+    let mut markers = Vec::new();
+    let mut labels = Vec::new();
+    fn visit(
+        root: &FsNode,
+        node: &FsNode,
+        parent_visible: bool,
+        point_size: f32,
+        markers: &mut Vec<crate::geometry::Vertex3D>,
+        labels: &mut Vec<([f32; 3], u32)>,
+        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");
+        if want_markers || want_numbers {
+            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_markers {
+                    let src: Vec<crate::geometry::Vertex3D> = geom
+                        .vertices
+                        .iter()
+                        .map(|v| crate::geometry::Vertex3D { position: v.pos, color: [0.0; 3] })
+                        .collect();
+                    markers.extend(crate::geometry::points_vertices(
+                        &src,
+                        point_size,
+                        cce_ui::colors::to_linear_rgb([0.85, 0.85, 1.0]),
+                    ));
+                }
+                if want_numbers {
+                    let mut seen = std::collections::HashSet::new();
+                    for (i, v) in geom.vertices.iter().enumerate() {
+                        let key = (
+                            (v.pos[0] * 1000.0).round() as i32,
+                            (v.pos[1] * 1000.0).round() as i32,
+                            (v.pos[2] * 1000.0).round() as i32,
+                        );
+                        if seen.insert(key) {
+                            labels.push((v.pos, i as u32));
+                        }
+                    }
+                }
+            }
+        }
+        for c in &node.children {
+            visit(root, c, is_visible, point_size, markers, labels, sim);
+        }
+    }
+    for c in &root.children {
+        visit(root, c, true, point_size, &mut markers, &mut labels, 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.
+    const MAX_LABELS: usize = 2000;
+    if labels.len() > MAX_LABELS {
+        labels.truncate(MAX_LABELS);
+    }
+    (markers, labels)
+}
diff --git a/src/window.rs b/src/window.rs
index e89c93c..4b5f157 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -797,6 +797,7 @@ impl State {
                         } else {
                             node.name = state.get_lowest_unused_name(&node.name);
                         }
+                        crate::app::ensure_meta_on(&mut node);
                         state.current_dir_mut().children.push(node);
                         state.sync_nodes();
                         state.rebuild_positions();