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

commitcc2b3553cd117daba67c94bc70bfb8e806e5b00c
parentc7a79f377a
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 10:23
feat: a Point Marker Size slider in the viewport menu

A slider row under Point Size over the palette row's 0.005-0.1 world
units. It re-sizes the Show Point Markers overlay live from the scene
positions rebuild_scene_geometry now keeps while markers are on, landing on
the same spheres a full rebuild would draw without re-evaluating the graph.
CLAUDE.md's app.rs size claim refreshed to ~9.8k.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md     | 10 +++++++---
 src/app.rs    | 48 +++++++++++++++++++++++++++++++++++++++++++++---
 src/main.rs   | 44 ++++++++++++++++++++++++++++++++++++++++++++
 src/render.rs | 10 ++++++++++
 4 files changed, 106 insertions(+), 6 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 3c59ee4..68ba064 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -97,7 +97,7 @@ engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its
 `cce_ui::cosmic_text`; `glyphon` is not a dependency of this crate at all, having
 gone from cce-ui with the wgpu path).
 
-- `src/app.rs` (~8.9k lines) — the heart: `State` (the entire app model), `McpAction` /
+- `src/app.rs` (~9.8k lines) — the heart: `State` (the entire app model), `McpAction` /
   `CustomEvent`, node-template loading, pane layout. `tick_frame` (simulation:
   config polling, inertia, widget ticks) and `stage_frame` (renderer staging) are the
   two halves of the old render loop. GPU mesh updates are staged CPU-side
@@ -1381,7 +1381,8 @@ and View 1:1: the Show Wireframe switch (its registry command), **Flat
 Shading / Smooth Shading** as a radio pair over `toggle_smooth_shading`,
 a **Wire Thickness** slider under the wireframe switch (1–8 px by
 half a pixel, the palette row's range), a **Point Size** slider (0–0.1
-world units by 0.005, no suffix since the World Unit names them), and the
+world units by 0.005, no suffix since the World Unit names them), a
+**Point Marker Size** slider (the palette row's 0.005–0.1), and the
 polygon **Opacity** as a
 SLIDER row — cce-ui's `context_menu::MenuSlider` (2026-09-25), set on the
 shown menu by `open_viewport_context_menu`. `viewport_menu_slider` is the
@@ -1400,7 +1401,10 @@ anywhere over the open menu is swallowed rather than orbiting the scene.
 it feeds: opacity and wire thickness are draw-time, and point size re-bakes
 the Render points (the stage pass's size key) and the Selected-Group
 markers — re-sized from `State::group_members`, the positions `sync_nodes`
-keeps from its evaluation, by `rebuild_group_marker_verts`. None of it
+keeps from its evaluation, by `rebuild_group_marker_verts`; point marker
+size re-sizes the Show Point Markers overlay from the scene positions
+`rebuild_scene_geometry` keeps while it is on (`overlay_marker_points`,
+`rebuild_overlay_marker_verts`). None of it
 re-evaluates the graph, which `apply_setting`'s regenerate pass would do per
 pixel of drag. `sync_nodes` also re-sizes the markers when only the size
 moved (`last_group_marker_size`), so the palette's Point Size and Group
diff --git a/src/app.rs b/src/app.rs
index 7e93205..f02d91a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -457,6 +457,9 @@ pub enum ViewportMenuAction {
     /// Point Size in world units, the Render points' radius and (times
     /// Group Marker Scale) the group markers': 0–0.1 like the palette's row.
     PointSizeSlider,
+    /// Point Marker Size in world units, the Show Point Markers overlay's
+    /// radius: the palette row's 0.005–0.1.
+    PointMarkerSizeSlider,
     /// A "-" row: engraved, inert.
     Separator,
 }
@@ -2103,6 +2106,12 @@ pub struct State {
     /// the palette (`toggle_point_markers` / `_numbers` / `_normals`) over
     /// the flags below, persisted in `ViewportSettings` beside Show Grid.
     pub overlay_marker_verts: Vec<Vertex3D>,
+    /// The scene's point positions, kept by `rebuild_scene_geometry` while
+    /// Show Point Markers is on (empty otherwise), so the markers can be
+    /// re-SIZED without re-evaluating the graph — the viewport menu's Point
+    /// Marker Size slider does that on every motion of a drag
+    /// (`rebuild_overlay_marker_verts`).
+    pub overlay_marker_points: Vec<Vertex3D>,
     pub overlay_dirty: bool,
     pub overlay_point_count: u32,
     pub overlay_number_labels: Vec<([f32; 3], u32)>,
@@ -4371,6 +4380,16 @@ impl State {
                 decimals: 3,
                 suffix: "",
             },
+            // The palette row's spin is 5–100 thousandths; the same range in
+            // world units here, where the readout has room for the decimals.
+            ViewportMenuAction::PointMarkerSizeSlider => MenuSlider {
+                value: self.point_marker_size.clamp(0.005, 0.1),
+                min: 0.005,
+                max: 0.1,
+                step: 0.005,
+                decimals: 3,
+                suffix: "",
+            },
             _ => return None,
         })
     }
@@ -4380,7 +4399,9 @@ impl State {
     /// uniform, a line width and the fill's matching depth bias). Point
     /// size is baked into two meshes: the Render points re-bake in the stage
     /// pass off their own size key, and the group markers are re-sized here
-    /// from their kept members — neither re-evaluates the graph.
+    /// from their kept members. Point Marker Size re-sizes the overlay from
+    /// the scene positions its rebuild kept. None of it re-evaluates the
+    /// graph.
     fn land_viewport_menu_slider(&mut self, action: ViewportMenuAction, v: f32) {
         match action {
             ViewportMenuAction::OpacitySlider => self.geo_opacity = (v / 100.0).clamp(0.0, 1.0),
@@ -4389,6 +4410,10 @@ impl State {
                 self.point_size = v.clamp(0.0, 0.1);
                 self.rebuild_group_marker_verts();
             }
+            ViewportMenuAction::PointMarkerSizeSlider => {
+                self.point_marker_size = v.clamp(0.005, 0.1);
+                self.rebuild_overlay_marker_verts();
+            }
             _ => return,
         }
         self.viewport_dirty = true;
@@ -4420,7 +4445,7 @@ impl State {
 
     /// The viewport menu's rows and what each does: framing, then the
     /// DISPLAY MODE — the wireframe switch and its thickness slider, the
-    /// point size slider, flat or
+    /// point size and point marker size sliders, flat or
     /// smooth shading as a radio pair, the polygon opacity slider and Show
     /// Occluded — then the editor pin. Split from the open so a test can
     /// read it. Marks are the ●/○ the pin rows and the network menu use.
@@ -4438,6 +4463,8 @@ impl State {
         actions.push(ViewportMenuAction::WireThicknessSlider);
         options.push("Point Size".to_string());
         actions.push(ViewportMenuAction::PointSizeSlider);
+        options.push("Point Marker Size".to_string());
+        actions.push(ViewportMenuAction::PointMarkerSizeSlider);
         options.push(format!("{} Flat Shading", mark(!self.smooth_shading)));
         actions.push(ViewportMenuAction::Shading(false));
         options.push(format!("{} Smooth Shading", mark(self.smooth_shading)));
@@ -4502,7 +4529,8 @@ impl State {
             // The slider row is worked, not picked.
             ViewportMenuAction::OpacitySlider
             | ViewportMenuAction::WireThicknessSlider
-            | ViewportMenuAction::PointSizeSlider => {}
+            | ViewportMenuAction::PointSizeSlider
+            | ViewportMenuAction::PointMarkerSizeSlider => {}
             ViewportMenuAction::Separator => {}
         }
     }
@@ -5114,6 +5142,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     /// current size — the cheap half of the markers, with no evaluation, so
     /// a size change can run it on every motion of a drag. The Highlight
     /// bake's warm accent, so the markers and the tint read as one feature.
+    /// Build the Show Point Markers overlay's spheres from the kept scene
+    /// positions at the current Point Marker Size — the same call
+    /// `render::scene_point_overlays` makes, without the evaluation that
+    /// produced the positions.
+    pub(crate) fn rebuild_overlay_marker_verts(&mut self) {
+        self.overlay_marker_verts = crate::geometry::points_vertices(
+            &self.overlay_marker_points,
+            self.point_marker_size,
+            cce_ui::colors::to_linear_rgb(self.point_marker_color),
+        );
+        self.overlay_dirty = true;
+    }
+
     pub(crate) fn rebuild_group_marker_verts(&mut self) {
         let size = self.group_marker_size();
         self.group_point_verts = crate::geometry::points_vertices(
@@ -5539,6 +5580,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             group_members: Vec::new(),
             last_group_marker_size: 0.0,
             overlay_marker_verts: Vec::new(),
+            overlay_marker_points: Vec::new(),
             overlay_dirty: false,
             overlay_point_count: 0,
             overlay_number_labels: Vec::new(),
diff --git a/src/main.rs b/src/main.rs
index 90e4172..82d5320 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1733,6 +1733,50 @@ mod tests {
         assert!((radius(&state) - 0.03).abs() < 1e-4, "{}", radius(&state));
     }
 
+    /// Point Marker Size is a viewport-menu slider over the palette row's
+    /// 0.005–0.1, and it re-sizes the Show Point Markers overlay from the
+    /// scene positions the last rebuild kept — landing on exactly what a full
+    /// rebuild at that size would draw, without re-evaluating the graph.
+    #[test]
+    fn the_viewport_menu_sets_the_point_marker_size_without_a_rebuild() {
+        use crate::app::ViewportMenuAction as A;
+        use crate::window::WindowEvent;
+        use cce_ui::widget::{context_menu, MouseScrollDelta};
+        let mut state = State::new(false);
+        state.show_point_markers = true;
+        state.point_marker_size = 0.02;
+        state.rebuild_scene_geometry();
+        assert!(!state.overlay_marker_points.is_empty(), "the bundled scene has points");
+        let version = state.rt_geometry_version;
+
+        state.cursor_x = 300.0;
+        state.cursor_y = 200.0;
+        state.open_viewport_context_menu();
+        let i = state.viewport_menu_actions.iter().position(|a| *a == A::PointMarkerSizeSlider).expect("a Point Marker Size row");
+        assert_eq!(state.viewport_menu_actions[i - 1], A::PointSizeSlider, "it sits under Point Size");
+        let sl = context_menu::slider(i).expect("a slider");
+        assert_eq!((sl.min, sl.max, sl.step), (0.005, 0.1, 0.005));
+        assert!((sl.value - 0.02).abs() < 1e-6);
+
+        state.cursor_x = context_menu::x() + 20.0;
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 4.0) });
+        assert!((state.point_marker_size - 0.04).abs() < 1e-5, "{}", state.point_marker_size);
+        assert_eq!(state.rt_geometry_version, version, "no rebuild ran");
+        assert!(state.overlay_dirty);
+        let resized: Vec<[f32; 3]> = state.overlay_marker_verts.iter().map(|v| v.position).collect();
+        context_menu::hide();
+
+        // The full path at the same size draws the same spheres.
+        state.rebuild_scene_geometry();
+        let rebuilt: Vec<[f32; 3]> = state.overlay_marker_verts.iter().map(|v| v.position).collect();
+        assert_eq!(resized, rebuilt);
+
+        // Off, nothing is kept to re-size.
+        state.run_command("toggle_point_markers");
+        assert!(state.overlay_marker_points.is_empty());
+    }
+
     /// The dialog plate carries its own backdrop compression, above a
     /// menu's: whatever the plates' own is (0 in a config that keeps the
     /// panes clear), the modal pulls its backdrop toward the tint, and a
diff --git a/src/render.rs b/src/render.rs
index 7bebf50..a62e8da 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1172,6 +1172,16 @@ impl State {
             self.point_marker_color,
         );
         self.overlay_marker_verts = markers;
+        // Kept for re-sizing the markers without this evaluation — see
+        // `State::rebuild_overlay_marker_verts`.
+        self.overlay_marker_points = if self.show_point_markers {
+            geom.positions()
+                .iter()
+                .map(|&position| crate::geometry::Vertex3D { position, color: [0.0; 3] })
+                .collect()
+        } else {
+            Vec::new()
+        };
         self.overlay_number_labels = labels;
         self.overlay_normal_verts = normals;
         // The wire pass's edges, likewise — topological, and only while the