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