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