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

commitfa802140b6ca9be58b5af797ff26a0de25aa17a3
parentcc2b3553cd
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 10:24
feat: a Group Marker Scale slider in the viewport menu

A slider row under Point Marker Size over the palette row's 0.5-4, 0.05 a
wheel notch, read as a multiple ("1.25x"). It re-sizes the Selected-Group
markers live from their kept members, without re-evaluating the Group node.

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

 CLAUDE.md   |  3 ++-
 src/app.rs  | 25 +++++++++++++++++++++++--
 src/main.rs | 39 +++++++++++++++++++++++++++++++++++++++
 3 files changed, 64 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 68ba064..f2e29db 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1382,7 +1382,8 @@ 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), a
-**Point Marker Size** slider (the palette row's 0.005–0.1), and the
+**Point Marker Size** slider (the palette row's 0.005–0.1), a **Group
+Marker Scale** slider (0.5–4 by 0.05, read "1.25x"), 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
diff --git a/src/app.rs b/src/app.rs
index f02d91a..5d4d24d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -460,6 +460,9 @@ pub enum ViewportMenuAction {
     /// Point Marker Size in world units, the Show Point Markers overlay's
     /// radius: the palette row's 0.005–0.1.
     PointMarkerSizeSlider,
+    /// Group Marker Scale, the Selected-Group markers' radius as a multiple
+    /// of Point Size: the palette row's 0.5–4.
+    GroupMarkerScaleSlider,
     /// A "-" row: engraved, inert.
     Separator,
 }
@@ -4390,6 +4393,17 @@ impl State {
                 decimals: 3,
                 suffix: "",
             },
+            // A multiple of Point Size, read as one ("1.25x"): a plain x
+            // rather than a multiplication sign, which the menu face may not
+            // carry and which a fallback glyph would then under-measure.
+            ViewportMenuAction::GroupMarkerScaleSlider => MenuSlider {
+                value: self.group_marker_scale.clamp(0.5, 4.0),
+                min: 0.5,
+                max: 4.0,
+                step: 0.05,
+                decimals: 2,
+                suffix: "x",
+            },
             _ => return None,
         })
     }
@@ -4414,6 +4428,10 @@ impl State {
                 self.point_marker_size = v.clamp(0.005, 0.1);
                 self.rebuild_overlay_marker_verts();
             }
+            ViewportMenuAction::GroupMarkerScaleSlider => {
+                self.group_marker_scale = v.clamp(0.5, 4.0);
+                self.rebuild_group_marker_verts();
+            }
             _ => return,
         }
         self.viewport_dirty = true;
@@ -4445,7 +4463,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 and point marker size sliders, flat or
+    /// point size, point marker size and group marker scale 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.
@@ -4465,6 +4483,8 @@ impl State {
         actions.push(ViewportMenuAction::PointSizeSlider);
         options.push("Point Marker Size".to_string());
         actions.push(ViewportMenuAction::PointMarkerSizeSlider);
+        options.push("Group Marker Scale".to_string());
+        actions.push(ViewportMenuAction::GroupMarkerScaleSlider);
         options.push(format!("{} Flat Shading", mark(!self.smooth_shading)));
         actions.push(ViewportMenuAction::Shading(false));
         options.push(format!("{} Smooth Shading", mark(self.smooth_shading)));
@@ -4530,7 +4550,8 @@ impl State {
             ViewportMenuAction::OpacitySlider
             | ViewportMenuAction::WireThicknessSlider
             | ViewportMenuAction::PointSizeSlider
-            | ViewportMenuAction::PointMarkerSizeSlider => {}
+            | ViewportMenuAction::PointMarkerSizeSlider
+            | ViewportMenuAction::GroupMarkerScaleSlider => {}
             ViewportMenuAction::Separator => {}
         }
     }
diff --git a/src/main.rs b/src/main.rs
index 82d5320..02cddf9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1777,6 +1777,45 @@ mod tests {
         assert!(state.overlay_marker_points.is_empty());
     }
 
+    /// Group Marker Scale is a viewport-menu slider over the palette row's
+    /// 0.5–4, re-sizing the Selected-Group markers from their kept members
+    /// at Point Size x the scale — no re-evaluation of the Group node.
+    #[test]
+    fn the_viewport_menu_sets_the_group_marker_scale() {
+        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 = 1.25;
+        let centre = [0.0f32, 1.0, 0.0];
+        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| (v.position[1] - centre[1]).abs()).fold(0.0f32, f32::max)
+        };
+        assert!((radius(&state) - 0.025).abs() < 1e-5);
+
+        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::GroupMarkerScaleSlider).expect("a Group Marker Scale row");
+        assert_eq!(state.viewport_menu_actions[i - 1], A::PointMarkerSizeSlider);
+        let sl = context_menu::slider(i).expect("a slider");
+        assert_eq!((sl.min, sl.max, sl.step, sl.suffix), (0.5, 4.0, 0.05, "x"));
+        assert_eq!(sl.readout(), "1.25x");
+
+        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, 15.0) });
+        assert!((state.group_marker_scale - 2.0).abs() < 1e-5, "{}", state.group_marker_scale);
+        assert!((radius(&state) - 0.04).abs() < 1e-5, "the markers re-sized: {}", radius(&state));
+        let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+        let saved = crate::app::DesignSettings::from_kdl_str(&kdl).render.group_marker_scale;
+        assert!((saved - 2.0).abs() < 1e-5, "persisted: {saved}");
+        context_menu::hide();
+    }
+
     /// 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