graphic design tool
git clone https://git.lucas.co/cce-designer.git
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();