graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: a Group Markers dialog marks the scene's point groups
The group_markers command turns the palette into a list of the scene's
point groups, a switch each; a marked group's members wear a marker in
the viewport whatever is selected. Persisted with the display settings.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 19 ++++++++++
src/app.rs | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
src/command.rs | 1 +
src/dialog.rs | 57 ++++++++++++++++++++++++++++++
src/main.rs | 76 ++++++++++++++++++++++++++++++++++++++++
src/render.rs | 11 ++++++
6 files changed, 270 insertions(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index b558b4e..8988c98 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2870,6 +2870,25 @@ rather than two:
`rename_node` share; a sibling's name is refused in both, since wires
are by name. `State::rename_target` holds the node by id.
`a_node_is_renamed_from_its_menu` is the test.
+- `Mode::Groups` (the `group_markers` command, **Group Markers** in the
+ palette — the palette TRANSFORMS into this list, since 2026-09-29) —
+ the scene's point groups, one row each with a switch and the member
+ count in the chord column, filtered by name. A row's switch marks the
+ group's members in the viewport: a sphere at Group Marker Size in the
+ selected group's amber, on every member, staying on whatever is
+ selected. Enter or a click flips it in place and the list stays up, as
+ the palette's toggles do. `State::marked_groups` is the set, persisted
+ in the viewport block (one comma-joined string — a KDL list of one
+ reads back as a bare string, which a `Vec` refuses, and a settings file
+ that fails to parse reads as the defaults), so it rides the project
+ file too. `State::scene_groups` is every point group of the scene as
+ last built with its members' positions, kept by `rebuild_scene_geometry`
+ so the list and the markers (`rebuild_marked_group_verts`,
+ `meshes.marked_points`) come from what is on screen and a switch
+ evaluates nothing; the markers follow the geometry through a rebuild.
+ A marked name the scene has no group for marks nothing and is kept, so
+ a group that comes and goes with a frame does not lose its switch.
+ `the_group_markers_dialog_marks_a_groups_points` is the test.
- `Mode::AddNode` (**Tab**, in the network pane) — one list of node
templates, and a pick that instantiates at the grid cursor. Tab is what
opened it, so Tab closes it again. The query hint names the mode; there
diff --git a/src/app.rs b/src/app.rs
index a30d34b..24e2dbf 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1290,6 +1290,15 @@ pub struct ViewportSettings {
pub show_vertex_markers: bool,
#[serde(default)]
pub show_vertex_normals: bool,
+ /// The point groups whose members wear a marker in the scene — the
+ /// Group Markers dialog's switches, by group name, joined by commas.
+ /// One string rather than a list: the KDL writer puts a list of one
+ /// back as a bare string, which a `Vec` refuses, and a settings file
+ /// that fails to parse is read as the DEFAULTS. Absent from older
+ /// files — none. `State::marked_groups_of` / `join_marked_groups` are
+ /// the two ends.
+ #[serde(default)]
+ pub marked_groups: String,
/// World-unit radius and colour of the Show Point Markers overlay.
#[serde(default = "default_point_marker_size")]
pub point_marker_size: f32,
@@ -1486,6 +1495,7 @@ impl Default for ViewportSettings {
show_vertex_numbers: false,
show_vertex_markers: false,
show_vertex_normals: false,
+ marked_groups: String::new(),
point_marker_size: default_point_marker_size(),
point_marker_color: default_point_marker_color(),
world_unit: default_world_unit(),
@@ -1941,6 +1951,8 @@ pub struct SceneMeshes {
pub group_points: cce_ui::vk::MeshId,
/// Markers on the points whose rows are selected in the spreadsheet.
pub row_points: cce_ui::vk::MeshId,
+ /// Markers on the members of the marked groups (the Group Markers dialog).
+ pub marked_points: cce_ui::vk::MeshId,
/// The Show Point Markers overlay.
pub overlay_points: cce_ui::vk::MeshId,
/// The Show Point Normals overlay (LINE_LIST whiskers).
@@ -2473,6 +2485,21 @@ pub struct State {
pub show_vertex_numbers: bool,
pub show_vertex_markers: bool,
pub show_vertex_normals: bool,
+ /// The point groups whose members wear a marker in the scene, by name
+ /// — the Group Markers dialog's switches (`group_markers`), persisted
+ /// in the viewport block. A name the scene has no group for stays on
+ /// the list and marks nothing, so a switch set for a group that comes
+ /// and goes with a frame or an edit is not lost with it.
+ pub marked_groups: Vec<String>,
+ /// Every point group of the scene as last built, with its members'
+ /// positions: what the dialog lists and what the markers are built
+ /// from, so a switch flipped evaluates nothing.
+ pub scene_groups: Vec<(String, Vec<[f32; 3]>)>,
+ /// The marked groups' markers, staged by `rebuild_marked_group_verts`,
+ /// flushed to `meshes.marked_points`.
+ pub marked_group_verts: Vec<Vertex3D>,
+ pub marked_groups_dirty: bool,
+ pub marked_group_vertex_count: u32,
/// The visible scene's own edges for the wire pass (LINE_LIST pairs),
/// rebuilt with the scene while Show Wireframe is on and empty while it
/// is off. Topological — see `render::scene_edge_verts`.
@@ -2664,6 +2691,7 @@ impl State {
show_vertex_numbers: self.show_vertex_numbers,
show_vertex_markers: self.show_vertex_markers,
show_vertex_normals: self.show_vertex_normals,
+ marked_groups: Self::join_marked_groups(&self.marked_groups),
point_marker_size: self.point_marker_size,
point_marker_color: self.point_marker_color,
world_unit: self.world_unit.suffix().to_string(),
@@ -2741,6 +2769,7 @@ impl State {
self.show_vertex_numbers = v.show_vertex_numbers;
self.show_vertex_markers = v.show_vertex_markers;
self.show_vertex_normals = v.show_vertex_normals;
+ self.marked_groups = Self::marked_groups_of(&v.marked_groups);
self.point_marker_size = v.point_marker_size;
self.point_marker_color = v.point_marker_color;
if let Some(u) = cce_ui::units::Unit::parse(&v.world_unit) {
@@ -4083,6 +4112,7 @@ impl State {
"Reset Parameters" => {
self.reset_parameters();
}
+ "Group Markers" => self.open_group_markers_dialog(),
"Rename Node" => match self.selected_slots().first().copied() {
Some(slot) => self.open_rename_dialog(slot),
None => self.update_status_text("Select a node to rename."),
@@ -6692,9 +6722,64 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.spreadsheet_mut().selected_rows()
}
+ /// Stage a marker on every member of every marked group, at Group
+ /// Marker Size in the group markers' amber, from the positions the
+ /// last scene rebuild kept — a switch flipped evaluates nothing.
+ pub(crate) fn rebuild_marked_group_verts(&mut self) {
+ let at: Vec<Vertex3D> = self
+ .scene_groups
+ .iter()
+ .filter(|(name, _)| self.marked_groups.contains(name))
+ .flat_map(|(_, members)| members.iter().map(|&position| Vertex3D { position, color: [0.0; 3] }))
+ .collect();
+ self.marked_group_verts = if at.is_empty() {
+ Vec::new()
+ } else {
+ crate::geometry::points_vertices(&at, self.group_marker_size, cce_ui::colors::to_linear_rgb([1.0, 0.78, 0.20]))
+ };
+ self.marked_groups_dirty = true;
+ self.viewport_dirty = true;
+ }
+
+ /// The marked groups as the settings hold them: names joined by commas.
+ pub fn join_marked_groups(groups: &[String]) -> String {
+ groups.join(",")
+ }
+
+ /// The marked groups out of the settings' one string.
+ pub fn marked_groups_of(joined: &str) -> Vec<String> {
+ let mut out: Vec<String> = joined.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
+ out.sort();
+ out.dedup();
+ out
+ }
+
+ /// Whether `group` is marked.
+ pub fn group_marked(&self, group: &str) -> bool {
+ self.marked_groups.iter().any(|g| g == group)
+ }
+
+ /// Mark or unmark a point group: the Group Markers dialog's switch.
+ /// Persisted with the display settings, which the project file carries
+ /// too.
+ pub fn set_group_marked(&mut self, group: &str, on: bool) {
+ let was = self.group_marked(group);
+ if on && !was {
+ self.marked_groups.push(group.to_string());
+ self.marked_groups.sort();
+ } else if !on && was {
+ self.marked_groups.retain(|g| g != group);
+ }
+ if on != was {
+ self.rebuild_marked_group_verts();
+ self.save_settings();
+ }
+ }
+
pub(crate) fn rebuild_group_marker_verts(&mut self) {
- // One size for both kinds of selection feedback.
+ // One size for every kind of marker on a group's members.
self.rebuild_row_marker_verts();
+ self.rebuild_marked_group_verts();
let size = self.group_marker_size;
self.group_point_verts = crate::geometry::points_vertices(
&self.group_members,
@@ -7143,6 +7228,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
show_vertex_numbers: settings.viewport.show_vertex_numbers,
show_vertex_markers: settings.viewport.show_vertex_markers,
show_vertex_normals: settings.viewport.show_vertex_normals,
+ marked_groups: Self::marked_groups_of(&settings.viewport.marked_groups),
+ scene_groups: Vec::new(),
+ marked_group_verts: Vec::new(),
+ marked_groups_dirty: false,
+ marked_group_vertex_count: 0,
scene_edge_verts: Vec::new(),
point_marker_size: settings.viewport.point_marker_size,
point_marker_color: settings.viewport.point_marker_color,
@@ -11089,6 +11179,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.viewport_dirty = true;
}
+ // The marked groups' markers, staged by the dialog's switches and
+ // by every scene rebuild.
+ if self.marked_groups_dirty {
+ self.marked_groups_dirty = false;
+ renderer.update_mesh(meshes.marked_points, bytemuck::cast_slice(&self.marked_group_verts));
+ self.marked_group_vertex_count = self.marked_group_verts.len() as u32;
+ self.viewport_dirty = true;
+ }
+
// The spreadsheet's selected rows, staged by their selection.
if self.row_markers_dirty {
self.row_markers_dirty = false;
@@ -11166,6 +11265,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
pivot: renderer.create_mesh(bytemuck::cast_slice(&pivot_verts)),
group_points: renderer.create_mesh(&[]),
row_points: renderer.create_mesh(&[]),
+ marked_points: renderer.create_mesh(&[]),
overlay_points: renderer.create_mesh(&[]),
overlay_normals: renderer.create_mesh(&[]),
// Seeded with what is staged: a replacement renderer gets the
@@ -11444,6 +11544,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
if self.group_point_vertex_count > 0 {
draws.push(SceneDraw { mesh: meshes.group_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
}
+ // The marked groups: the same amber as a selected
+ // group's markers, and the same tier.
+ if self.marked_group_vertex_count > 0 {
+ draws.push(SceneDraw { mesh: meshes.marked_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
+ }
// The spreadsheet's selected rows, while it is shown.
if self.show_spreadsheet && self.row_marker_vertex_count > 0 {
draws.push(SceneDraw { mesh: meshes.row_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
diff --git a/src/command.rs b/src/command.rs
index ccca4e4..b0eaaf1 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -185,6 +185,7 @@ pub const COMMANDS: &[Command] = &[
// switches, and this acts on what is selected.
Command { id: "bypass_node", label: "Bypass Node", context: Context::Network, run: Run::Key(Action::ToggleBypass), default_chord: Some("b") },
Command { id: "rename_node", label: "Rename Node", context: Context::Network, run: Run::Menu("Rename Node"), default_chord: None },
+ Command { id: "group_markers", label: "Group Markers", context: Context::Viewport, run: Run::Menu("Group Markers"), default_chord: None },
Command { id: "add_node", label: "Add Node", context: Context::Network, run: Run::Menu("Add Node"), default_chord: None },
Command { id: "zoom_in", label: "Zoom In", context: Context::Network, run: Run::Menu("Zoom In"), default_chord: None },
Command { id: "zoom_out", label: "Zoom Out", context: Context::Network, run: Run::Menu("Zoom Out"), default_chord: None },
diff --git a/src/dialog.rs b/src/dialog.rs
index 481ec65..94bb144 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -49,6 +49,11 @@ pub enum Mode {
/// line is the NAME, opened holding the one the node has, and the one
/// row says what Enter will do with it.
Rename,
+ /// The `group_markers` command: the scene's point groups, a switch
+ /// each, marking the group's members in the viewport while it is on.
+ /// The palette TURNS INTO this list, the way it turns into the node
+ /// list — one plate, one filter.
+ Groups,
}
/// The control a row carries, drawn over its right end and worked in place —
@@ -790,6 +795,7 @@ impl Paint for Dialog {
Mode::Commands => "Type to filter commands and settings",
Mode::AddNode => "Add Node: type to filter nodes",
Mode::Rename => "Rename: type the node's name",
+ Mode::Groups => "Group Markers: type to filter groups",
},
q_w,
);
@@ -825,6 +831,7 @@ impl Paint for Dialog {
Mode::Commands => "No matching command or setting",
Mode::AddNode => "No matching node",
Mode::Rename => "No node to rename",
+ Mode::Groups => "No point group in the scene",
};
ctx.text_with(empty, list.x + 8.0, ty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
return;
@@ -1190,6 +1197,9 @@ pub const RENAME_ROW_ID: &str = "rename:";
/// (`default_camera`), there being always exactly one.
pub const CAMERA_ROW_PREFIX: &str = "camera:";
+/// A row of [`Mode::Groups`]: the prefix, then the group's name.
+pub const GROUP_ROW_PREFIX: &str = "group:";
+
/// How many recent projects the list offers. `recent_files` keeps ten; five
/// is what fits above the commands without the palette reading as a file
/// manager, and a query narrows the rest.
@@ -1371,6 +1381,13 @@ impl State {
self.refresh_dialog_rows();
}
+ /// The Group Markers list: the `group_markers` command. From the
+ /// palette this is the palette transformed — the same plate, with the
+ /// groups where the commands were.
+ pub fn open_group_markers_dialog(&mut self) {
+ self.open_dialog_in(Mode::Groups);
+ }
+
/// What renaming the dialog's node to `typed` would do: the name it
/// has and the one it would get, or why not. None when the node is
/// gone.
@@ -1396,6 +1413,7 @@ impl State {
Mode::Commands => "Dialog: type to filter commands and settings, Escape closes.",
Mode::AddNode => "Add Node: type to filter, Enter adds at the cursor, Escape closes.",
Mode::Rename => "Rename: type the name, Enter renames, Escape closes.",
+ Mode::Groups => "Group Markers: Enter or a click marks a group's points in the scene, Escape closes.",
});
}
@@ -1548,6 +1566,24 @@ impl State {
}
rows
}
+ // The scene's point groups, ranked by name, a switch each and the
+ // member count in the chord column.
+ Mode::Groups => {
+ let names: Vec<&str> = self.scene_groups.iter().map(|(n, _)| n.as_str()).collect();
+ crate::command::fuzzy_rank(&query, &names)
+ .into_iter()
+ .map(|i| {
+ let (name, members) = &self.scene_groups[i];
+ Row {
+ id: format!("{GROUP_ROW_PREFIX}{name}"),
+ label: name.clone(),
+ chord: format!("{} point{}", members.len(), if members.len() == 1 { "" } else { "s" }),
+ control: Some(Control::Toggle(self.group_marked(name))),
+ truncate_head: false,
+ }
+ })
+ .collect()
+ }
Mode::AddNode => {
// Every template, everywhere. The settings directories that
// refused geometry were the root meta node's utility subnets,
@@ -1693,6 +1729,16 @@ impl State {
/// moved, and re-ranking would throw the selection back to the top of a
/// list the user is still working down.
pub(crate) fn refresh_dialog_controls(&mut self) {
+ if self.slots.dialog.mode == Mode::Groups {
+ let ids: Vec<String> = self.slots.dialog.rows.iter().map(|r| r.id.clone()).collect();
+ for id in ids {
+ if let Some(name) = id.strip_prefix(GROUP_ROW_PREFIX) {
+ let on = self.group_marked(name);
+ self.slots.dialog.set_control(&id, Some(Control::Toggle(on)));
+ }
+ }
+ return;
+ }
if self.slots.dialog.mode != Mode::Commands {
return;
}
@@ -2205,6 +2251,16 @@ impl State {
if mode == Mode::Commands && id == ZOOM_ROW_ID {
return;
}
+ // A group's row is its switch: flipped in place, the list stays up.
+ if mode == Mode::Groups {
+ if let Some(name) = id.strip_prefix(GROUP_ROW_PREFIX) {
+ let name = name.to_string();
+ let on = !self.group_marked(&name);
+ self.set_group_marked(&name, on);
+ self.refresh_dialog_controls();
+ }
+ return;
+ }
if mode == Mode::Commands {
if let Some(s) = setting_of_row(&id) {
let control = self.slots.dialog.rows.iter().find(|r| r.id == id).and_then(|r| r.control.clone());
@@ -2273,6 +2329,7 @@ impl State {
Ok(said) | Err(said) => self.update_status_text(&said),
}
}
+ Mode::Groups => {}
Mode::AddNode => {
let mut redraw = false;
let action = crate::app::McpAction::AddNode {
diff --git a/src/main.rs b/src/main.rs
index 5ecd48f..0b25e21 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7323,6 +7323,82 @@ mod tests {
/// Rename is a row of the node's menu and a command: the dialog opens
/// holding the node's name, the row says what Enter will do, and a name
/// that cannot be written is refused there and not on the way in.
+ /// The Group Markers dialog: the palette turned into a list of the
+ /// scene's point groups, a switch each. Enter or a click flips the
+ /// switch in place and the list stays up; a marked group's members
+ /// wear a marker in the scene, built from what the last rebuild kept,
+ /// following the geometry through a rebuild and persisted with the
+ /// display settings.
+ #[test]
+ fn the_group_markers_dialog_marks_a_groups_points() {
+ use crate::dialog::{Mode, GROUP_ROW_PREFIX};
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+ state.rebuild_positions();
+ state.apply_layout();
+ state.param_editor = crate::slots::CONTENT_IDX;
+ let mut redraw = false;
+ // A group of five points on the sphere, shown.
+ state.apply_action(McpAction::AddNode { template_name: "Group".into(), name: Some("tagged".into()), x: 7.0, y: 8.0 }, &mut redraw).unwrap();
+ let tagged = state.current_dir().children.iter().position(|c| c.name == "tagged").unwrap();
+ for (name, value) in [("Input", "sphere1"), ("Group Name", "five"), ("Mode", "Random"), ("Count", "5")] {
+ state.apply_action(McpAction::SetParam { slot: tagged, name: name.into(), value: value.into() }, &mut redraw).unwrap();
+ }
+ state.current_dir_mut().set_child_geometry_visible(tagged, true);
+ state.rebuild_scene_geometry();
+ assert!(state.scene_groups.iter().any(|(n, m)| n == "five" && m.len() == 5), "{:?}", state.scene_groups.iter().map(|(n, m)| (n.clone(), m.len())).collect::<Vec<_>>());
+ assert!(state.marked_group_verts.is_empty(), "nothing is marked yet");
+
+ // From the palette: the command turns it into the groups list.
+ state.run_command("command_palette");
+ assert_eq!(state.slots.dialog.mode, Mode::Commands);
+ assert!(state.run_command("group_markers"));
+ assert!(state.dialog_visible());
+ assert_eq!(state.slots.dialog.mode, Mode::Groups);
+ let row = state.slots.dialog.rows.iter().position(|r| r.id == format!("{GROUP_ROW_PREFIX}five")).expect("a row for the group");
+ assert_eq!(state.slots.dialog.rows[row].chord, "5 points");
+ assert_eq!(state.slots.dialog.rows[row].toggle(), Some(false));
+
+ // Enter on the row marks the group, and the list stays up.
+ state.slots.dialog.selected = row;
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+ assert!(state.dialog_visible(), "a switch is worked in place");
+ assert_eq!(state.slots.dialog.rows[row].toggle(), Some(true));
+ assert!(state.group_marked("five"));
+ assert!(!state.marked_group_verts.is_empty() && state.marked_groups_dirty, "the markers are staged");
+ let one = state.marked_group_verts.len();
+ // On the group's points, at Group Marker Size.
+ let members: Vec<[f32; 3]> = state.scene_groups.iter().find(|(n, _)| n == "five").unwrap().1.clone();
+ for m in &members {
+ assert!(state.marked_group_verts.iter().any(|v| (0..3).all(|k| (v.position[k] - m[k]).abs() <= state.group_marker_size + 1e-4)), "a marker at {m:?}");
+ }
+ let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+ assert_eq!(crate::app::DesignSettings::from_kdl_str(&kdl).viewport.marked_groups, "five", "persisted");
+ assert_eq!(State::marked_groups_of("b, a,,a"), vec!["a".to_string(), "b".to_string()]);
+
+ // The markers follow the geometry: a bigger sphere, farther points.
+ let sphere = state.current_dir().children.iter().position(|c| c.name == "sphere1").unwrap();
+ let far = |state: &State| state.marked_group_verts.iter().map(|v| (v.position[0].powi(2) + v.position[2].powi(2)).sqrt()).fold(0.0f32, f32::max);
+ let before = far(&state);
+ state.apply_action(McpAction::SetParam { slot: sphere, name: "Radius".into(), value: "2.0".into() }, &mut redraw).unwrap();
+ assert!(far(&state) > before * 1.5, "{} against {before}", far(&state));
+ assert_eq!(state.marked_group_verts.len(), one);
+
+ // Enter again unmarks; Escape closes; a query filters the names.
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+ assert!(!state.group_marked("five"));
+ assert!(state.marked_group_verts.is_empty());
+ state.dialog_key_input(&typed("z"));
+ assert!(state.slots.dialog.rows.is_empty(), "no group matches");
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Escape)));
+ assert!(!state.dialog_visible());
+
+ // A marked name the scene has no group for marks nothing and is kept.
+ state.set_group_marked("gone", true);
+ assert!(state.marked_group_verts.is_empty());
+ assert!(state.group_marked("gone"));
+ }
+
#[test]
fn a_node_is_renamed_from_its_menu() {
use crate::dialog::{Mode, RENAME_ROW_ID};
diff --git a/src/render.rs b/src/render.rs
index cceb6a4..824ba73 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1261,6 +1261,17 @@ impl State {
self.rt_geometry_version += 1;
self.viewport_dirty = true;
+ // The scene's point groups, with where their members are: what the
+ // Group Markers dialog lists, and what the marked ones' markers are
+ // built from.
+ self.scene_groups = geom
+ .points()
+ .group_names()
+ .iter()
+ .map(|g| (g.to_string(), geom.points().group_members(g).iter().map(|&p| geom.positions()[p as usize]).collect()))
+ .collect();
+ self.rebuild_marked_group_verts();
+
// The point overlays ride the same rebuild, off the same `geom`:
// they annotate what is on screen, and what is on screen is exactly
// this Detail.