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

commitc7a79f377a72e057d4e62805e350b99aa2fe6d92
parentd7ada7e8a1
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 10:20
feat: a Point Size slider in the viewport menu

A slider row over the palette row's 0-0.1 world units, 0.005 a wheel
notch. It re-bakes the Render points and re-sizes the Selected-Group
markers live without re-evaluating anything: sync_nodes now keeps the
group's member positions, and rebuild_group_marker_verts sizes spheres from
them (also run by sync_nodes when only the size moved, so the palette's
Point Size and Group Marker Scale rows reach the markers the same way).

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

 CLAUDE.md   |  17 +++++++---
 src/app.rs  | 101 +++++++++++++++++++++++++++++++++++++++++++++---------------
 src/main.rs |  49 +++++++++++++++++++++++++++++
 3 files changed, 138 insertions(+), 29 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7b79938..3c59ee4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1380,7 +1380,9 @@ The viewport's right-click menu carries the DISPLAY MODE under Frame All
 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), and the polygon **Opacity** as a
+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
+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
 one table of the menu's sliders (read from the live value) and
@@ -1393,9 +1395,16 @@ in `handle_viewport_menu_click`, `slider_dragging` in CursorMoved (after
 `cursor_moved`, which moves the held value), `slider_release` at the top of
 MouseInput, and `mouse_wheel` at the top of MouseWheel — where a wheel
 anywhere over the open menu is swallowed rather than orbiting the scene.
-`drain_viewport_menu_slider` lands a change by setting `geo_opacity`
-directly: opacity is a draw-time uniform, and `apply_setting`'s regenerate
-pass would re-evaluate the graph per pixel of drag. `viewport_menu_rows`
+`drain_viewport_menu_slider` lands a change through
+`land_viewport_menu_slider`, which sets the live field and redoes only what
+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
+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
+Marker Scale rows reach them the same way. `viewport_menu_rows`
 and `run_viewport_menu_action` are split from the open and the click so a
 test reads and runs the rows.
 
diff --git a/src/app.rs b/src/app.rs
index 51503a9..7e93205 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -454,6 +454,9 @@ pub enum ViewportMenuAction {
     /// The wire pass's thickness in px, a slider row under Show Wireframe:
     /// 1–8 like the palette's Wire Thickness row, half a pixel a notch.
     WireThicknessSlider,
+    /// 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,
     /// A "-" row: engraved, inert.
     Separator,
 }
@@ -2075,12 +2078,19 @@ pub struct State {
     /// Selected-Group membership markers: marker vertices staged CPU-side by
     /// `sync_nodes` whenever the selection is a Group node (empty otherwise),
     /// flushed to `meshes.group_points`; `group_point_vertex_count` gates the
-    /// draw. The key — (node id, params, geometry version, quantized point
-    /// size) — spares the re-evaluation on unrelated `sync_nodes` runs.
+    /// draw. The key — (node id, params, geometry version) — spares the
+    /// re-evaluation on unrelated `sync_nodes` runs.
     pub group_point_verts: Vec<Vertex3D>,
     pub group_points_dirty: bool,
     pub group_point_vertex_count: u32,
-    pub last_group_points_key: Option<(String, Vec<(String, String)>, u64, i32)>,
+    pub last_group_points_key: Option<(String, Vec<(String, String)>, u64)>,
+    /// The selected Group's member positions, kept from the evaluation so
+    /// the markers can be re-SIZED without re-evaluating the node — a point
+    /// size or marker scale change (the viewport menu's slider, per motion
+    /// of a drag) only rebuilds the spheres (`rebuild_group_marker_verts`).
+    pub group_members: Vec<Vertex3D>,
+    /// The marker radius `group_point_verts` was built at.
+    pub last_group_marker_size: f32,
     /// 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`
@@ -4350,17 +4360,35 @@ impl State {
                 decimals: 1,
                 suffix: " px",
             },
+            // World units, so no suffix — the World Unit declaration is what
+            // names them, and a readout saying "mm" under a cm declaration
+            // would be wrong.
+            ViewportMenuAction::PointSizeSlider => MenuSlider {
+                value: self.point_size.clamp(0.0, 0.1),
+                min: 0.0,
+                max: 0.1,
+                step: 0.005,
+                decimals: 3,
+                suffix: "",
+            },
             _ => return None,
         })
     }
 
-    /// Write a slider row's value onto the live field. Both are draw-time
-    /// values — a uniform, a line width and the fill's matching depth bias —
-    /// so a redraw is all either needs.
+    /// Write a slider row's value onto the live field, and redo only what
+    /// that value feeds. Opacity and wire thickness are draw-time values (a
+    /// 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.
     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),
             ViewportMenuAction::WireThicknessSlider => self.wire_width = v.clamp(1.0, 8.0),
+            ViewportMenuAction::PointSizeSlider => {
+                self.point_size = v.clamp(0.0, 0.1);
+                self.rebuild_group_marker_verts();
+            }
             _ => return,
         }
         self.viewport_dirty = true;
@@ -4391,7 +4419,8 @@ impl State {
     }
 
     /// The viewport menu's rows and what each does: framing, then the
-    /// DISPLAY MODE — the wireframe switch and its thickness slider, flat or
+    /// DISPLAY MODE — the wireframe switch and its thickness slider, the
+    /// point size slider, 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.
@@ -4407,6 +4436,8 @@ impl State {
         actions.push(ViewportMenuAction::Command("toggle_wireframe"));
         options.push("Wire Thickness".to_string());
         actions.push(ViewportMenuAction::WireThicknessSlider);
+        options.push("Point Size".to_string());
+        actions.push(ViewportMenuAction::PointSizeSlider);
         options.push(format!("{} Flat Shading", mark(!self.smooth_shading)));
         actions.push(ViewportMenuAction::Shading(false));
         options.push(format!("{} Smooth Shading", mark(self.smooth_shading)));
@@ -4469,7 +4500,9 @@ impl State {
                 }
             }
             // The slider row is worked, not picked.
-            ViewportMenuAction::OpacitySlider | ViewportMenuAction::WireThicknessSlider => {}
+            ViewportMenuAction::OpacitySlider
+            | ViewportMenuAction::WireThicknessSlider
+            | ViewportMenuAction::PointSizeSlider => {}
             ViewportMenuAction::Separator => {}
         }
     }
@@ -5035,12 +5068,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 n.id.clone(),
                 n.params.iter().map(|p| (p.name.clone(), p.default.clone())).collect::<Vec<_>>(),
                 self.rt_geometry_version,
-                (self.point_size * self.group_marker_scale * 1000.0).round() as i32,
             )
         });
         let mut group_update = None;
         if group_key != self.last_group_points_key {
-            let mut marker_verts = Vec::new();
+            let mut member_verts = Vec::new();
             if let Some(node) = selected_node.filter(|n| n.node_type.eq_ignore_ascii_case("group")) {
                 let group_name = node_param_str(node, "Group Name", "group1");
                 let mut visited = Vec::new();
@@ -5049,19 +5081,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 let mut sim_cache = crate::geometry::SimCache::default();
                 let mut sim = crate::geometry::EvalSim::new(sim_frame, sim_start, &mut sim_cache);
                 if let Some(geom) = generate_single_node_geometry_with_errors(&self.fs_root, node, &mut visited, &mut ocl_error, &mut sim) {
-                    let members = crate::geometry::group_member_positions(&geom, &group_name);
-                    // The Highlight bake's warm accent, so the markers and the
-                    // tint read as one feature. Larger than the Render node's
-                    // points by Group Marker Scale so both stay legible
-                    // together.
-                    marker_verts = crate::geometry::points_vertices(
-                        &members,
-                        self.point_size * self.group_marker_scale,
-                        cce_ui::colors::to_linear_rgb([1.0, 0.78, 0.20]),
-                    );
+                    member_verts = crate::geometry::group_member_positions(&geom, &group_name);
                 }
             }
-            group_update = Some(marker_verts);
+            group_update = Some(member_verts);
         }
 
         if let Some((headers, rows)) = spreadsheet_update {
@@ -5069,11 +5092,37 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             self.last_spreadsheet_node_name = current_name;
             self.last_spreadsheet_node_params = current_params;
         }
-        if let Some(marker_verts) = group_update {
-            self.group_point_verts = marker_verts;
-            self.group_points_dirty = true;
+        if let Some(members) = group_update {
+            self.group_members = members;
             self.last_group_points_key = group_key;
-        }
+            self.rebuild_group_marker_verts();
+        } else if (self.group_marker_size() - self.last_group_marker_size).abs() > f32::EPSILON {
+            // Same members, new size (the palette's Point Size or Group
+            // Marker Scale): re-size without re-evaluating.
+            self.rebuild_group_marker_verts();
+        }
+    }
+
+    /// The Selected-Group markers' radius: Point Size times Group Marker
+    /// Scale, larger than the Render node's points so both stay legible
+    /// together.
+    pub fn group_marker_size(&self) -> f32 {
+        self.point_size * self.group_marker_scale
+    }
+
+    /// Build the Selected-Group marker spheres from the kept members at the
+    /// 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.
+    pub(crate) fn rebuild_group_marker_verts(&mut self) {
+        let size = self.group_marker_size();
+        self.group_point_verts = crate::geometry::points_vertices(
+            &self.group_members,
+            size,
+            cce_ui::colors::to_linear_rgb([1.0, 0.78, 0.20]),
+        );
+        self.group_points_dirty = true;
+        self.last_group_marker_size = size;
     }
 
 
@@ -5487,6 +5536,8 @@ 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,
+            group_members: Vec::new(),
+            last_group_marker_size: 0.0,
             overlay_marker_verts: Vec::new(),
             overlay_dirty: false,
             overlay_point_count: 0,
diff --git a/src/main.rs b/src/main.rs
index a4ca94d..90e4172 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1684,6 +1684,55 @@ mod tests {
         context_menu::hide();
     }
 
+    /// Point Size is a viewport-menu slider over the palette row's 0–0.1,
+    /// and it re-sizes what it feeds without re-evaluating anything: the
+    /// Selected-Group markers are rebuilt from their KEPT members at Point
+    /// Size x Group Marker Scale on every step.
+    #[test]
+    fn the_viewport_menu_sets_the_point_size_and_resizes_the_group_markers() {
+        use crate::app::ViewportMenuAction as A;
+        use crate::window::WindowEvent;
+        use cce_ui::widget::{context_menu, MouseScrollDelta};
+        let mut state = State::new(false);
+        state.point_size = 0.02;
+        state.group_marker_scale = 2.0;
+        let centre = [0.3f32, 0.4, 0.5];
+        state.group_members = vec![crate::geometry::Vertex3D { position: centre, color: [0.0; 3] }];
+        state.rebuild_group_marker_verts();
+        let radius = |state: &State| {
+            state.group_point_verts.iter().map(|v| {
+                let d = [v.position[0] - centre[0], v.position[1] - centre[1], v.position[2] - centre[2]];
+                (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
+            }).fold(0.0f32, f32::max)
+        };
+        assert!((radius(&state) - 0.04).abs() < 1e-4, "{}", radius(&state));
+
+        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::PointSizeSlider).expect("a Point Size row");
+        let sl = context_menu::slider(i).expect("a slider");
+        assert_eq!((sl.min, sl.max, sl.step, sl.decimals), (0.0, 0.1, 0.005, 3));
+        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, 2.0) });
+        assert!((state.point_size - 0.03).abs() < 1e-5, "{}", state.point_size);
+        assert!((radius(&state) - 0.06).abs() < 1e-4, "the markers re-sized: {}", radius(&state));
+        assert!(state.group_points_dirty, "and will re-upload");
+        let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+        let saved = crate::app::DesignSettings::from_kdl_str(&kdl).render.point_size;
+        assert!((saved - 0.03).abs() < 1e-6, "persisted: {saved}");
+
+        // A size change arriving another way (the palette's Group Marker
+        // Scale) re-sizes too, through sync_nodes' size check.
+        context_menu::hide();
+        state.group_marker_scale = 1.0;
+        state.sync_nodes();
+        assert!((radius(&state) - 0.03).abs() < 1e-4, "{}", radius(&state));
+    }
+
     /// 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