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