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

commitaabd0446558d1f817cdc0a6bc39655124cdd55ce
parentf9d93d1986
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 21:57
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.