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

commit4860531f0f12f3353634b8388a05510546d1a036
parenta709382628
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 18:14
feat: selected spreadsheet rows are marked in the scene

A row is a point of the node the spreadsheet shows. Selecting rows
(cce-ui's press, ctrl and shift) stages a marker on each one's point,
from the positions the table was filled from, so nothing is evaluated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md   | 26 +++++++++++++++++++++
 src/app.rs  | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/main.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 171 insertions(+), 2 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index bb87114..deebd54 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1506,6 +1506,32 @@ back over sixty frames from frame 120 went from a mean of 15 ms a frame
 to 1.3, and from frame 240 from 67 to 4. The disk cache (`Cache` on the
 simnet) is unchanged and still holds the one latest frame.
 
+### Selected spreadsheet rows are marked in the scene
+
+A row of the spreadsheet is a POINT of the node it shows, by index, and
+rows can be selected (since 2026-09-29; the selection itself is cce-ui's,
+see its CLAUDE.md, "A spreadsheet's rows can be selected"): a press
+selects one, ctrl toggles one, shift extends a run. Each selected row's
+point wears a marker in the viewport, in the highlight colour the row
+wears and at Group Marker Size.
+
+- **Nothing is evaluated by a selection.** `State::spreadsheet_points` is
+  where the rows' points were when the table was last filled, kept from
+  that evaluation; `rebuild_row_marker_verts` builds the markers from it,
+  and runs from the press, from every refill of the table and from a
+  change of Group Marker Size.
+- **The selection stands across a frame and an edit**, since the rows are
+  still the points they were, and the markers follow the points. It goes
+  when the table becomes ANOTHER node's (`sync_selection_readouts`
+  compares the node id), whose row 3 is some other point.
+- **The markers draw only while the spreadsheet is shown**, with the
+  group markers, before the geometry and at full opacity.
+- A detached spreadsheet window has no viewport, and the selection does
+  not ride the sync channel: rows selected there mark nothing in the main
+  window.
+
+`selected_spreadsheet_rows_are_marked_in_the_scene` drives it by pointer.
+
 ### The volume representation
 
 `src/volume.rs` is a dense signed distance field — `Volume { origin, voxel,
diff --git a/src/app.rs b/src/app.rs
index 699adb2..188c628 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1937,6 +1937,8 @@ pub struct SceneMeshes {
     /// Selected-Group membership markers: while a Group node is selected, one
     /// marker per vertex it tags, so the selection SHOWS the group.
     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,
     /// The Show Point Markers overlay.
     pub overlay_points: cce_ui::vk::MeshId,
     /// The Show Point Normals overlay (LINE_LIST whiskers).
@@ -2366,6 +2368,15 @@ pub struct State {
     /// draw. The key — (node id, params, geometry version) — spares the
     /// re-evaluation on unrelated `sync_nodes` runs.
     pub group_point_verts: Vec<Vertex3D>,
+    /// The spreadsheet's selected rows, shown in the scene: a marker on the
+    /// point each row is. `spreadsheet_points` is where the rows' points
+    /// were when the table was last filled, kept so a selection stages its
+    /// markers without an evaluation; the markers are staged by
+    /// `rebuild_row_marker_verts` and flushed to `meshes.row_points`.
+    pub spreadsheet_points: Vec<[f32; 3]>,
+    pub row_marker_verts: Vec<Vertex3D>,
+    pub row_markers_dirty: bool,
+    pub row_marker_vertex_count: u32,
     pub group_points_dirty: bool,
     pub group_point_vertex_count: u32,
     pub last_group_points_key: Option<(String, Vec<(String, String)>, u64)>,
@@ -6084,6 +6095,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         if self.show_spreadsheet && !cache_hit {
             let mut headers = Vec::new();
             let mut rows = Vec::new();
+            // Where each row's point is: a row is a point, by index.
+            let mut points: Vec<[f32; 3]> = Vec::new();
 
             if let Some(node) = selected_node {
                 let mut visited = Vec::new();
@@ -6093,9 +6106,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     let (h, r) = Self::geometry_to_spreadsheet_data(&geom);
                     headers = h;
                     rows = r;
+                    points = geom.positions().to_vec();
                 }
             }
-            spreadsheet_update = Some((headers, rows));
+            spreadsheet_update = Some((headers, rows, points));
         }
 
         // Selected-Group viewport markers: while the selection is a Group
@@ -6130,8 +6144,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         // `selected_node` is not read past here.
         self.sim_cache = sim_cache;
 
-        if let Some((headers, rows)) = spreadsheet_update {
+        if let Some((headers, rows, points)) = spreadsheet_update {
+            // The selection is of rows by index, which are points of ONE
+            // node's output: it stands across a frame or an edit, and goes
+            // when the table becomes another node's.
+            if current_name != self.last_spreadsheet_node_name {
+                self.spreadsheet_mut().set_selected_rows(&[]);
+            }
             self.spreadsheet_mut().set_spreadsheet_data(headers, rows);
+            self.spreadsheet_points = points;
+            self.rebuild_row_marker_verts();
             self.last_spreadsheet_node_name = current_name;
             self.last_spreadsheet_node_params = current_params;
             self.last_spreadsheet_read_at = read_at;
@@ -6273,7 +6295,36 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         self.overlay_dirty = true;
     }
 
+    /// Stage a marker on the point of every row selected in the
+    /// spreadsheet, at the group markers' size and in the highlight colour
+    /// the rows themselves wear. From the positions the table was filled
+    /// from, so a press on a row evaluates nothing.
+    pub(crate) fn rebuild_row_marker_verts(&mut self) {
+        let _ = self.spreadsheet_mut().take_selection_change();
+        let rows = self.spreadsheet_mut().selected_rows();
+        let at: Vec<Vertex3D> = rows
+            .iter()
+            .filter_map(|&r| self.spreadsheet_points.get(r))
+            .map(|&position| Vertex3D { position, color: [0.0; 3] })
+            .collect();
+        let [r, g, b, _] = cce_ui::colors::highlight_primary_color();
+        self.row_marker_verts = if at.is_empty() {
+            Vec::new()
+        } else {
+            crate::geometry::points_vertices(&at, self.group_marker_size, cce_ui::colors::to_linear_rgb([r, g, b]))
+        };
+        self.row_markers_dirty = true;
+        self.viewport_dirty = true;
+    }
+
+    /// The points whose rows are selected in the spreadsheet, by index.
+    pub(crate) fn selected_spreadsheet_points(&mut self) -> Vec<usize> {
+        self.spreadsheet_mut().selected_rows()
+    }
+
     pub(crate) fn rebuild_group_marker_verts(&mut self) {
+        // One size for both kinds of selection feedback.
+        self.rebuild_row_marker_verts();
         let size = self.group_marker_size;
         self.group_point_verts = crate::geometry::points_vertices(
             &self.group_members,
@@ -6683,6 +6734,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             sorted_fill_key: None,
             scene_smooth_verts: Vec::new(),
             group_point_verts: Vec::new(),
+            spreadsheet_points: Vec::new(),
+            row_marker_verts: Vec::new(),
+            row_markers_dirty: false,
+            row_marker_vertex_count: 0,
             group_points_dirty: false,
             group_point_vertex_count: 0,
             last_group_points_key: None,
@@ -9580,6 +9635,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 if i == PARAM_IDX {
                                     self.sync_parameters_to_project();
                                 }
+                                // A press on a row selects it, and what is
+                                // selected is marked in the scene.
+                                if i == SPREADSHEET_IDX {
+                                    self.rebuild_row_marker_verts();
+                                }
 
                             }
                             if self.slots.draggable(i) {
@@ -10588,6 +10648,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             self.viewport_dirty = true;
         }
 
+        // The spreadsheet's selected rows, staged by their selection.
+        if self.row_markers_dirty {
+            self.row_markers_dirty = false;
+            renderer.update_mesh(meshes.row_points, bytemuck::cast_slice(&self.row_marker_verts));
+            self.row_marker_vertex_count = self.row_marker_verts.len() as u32;
+            self.viewport_dirty = true;
+        }
+
         // The point overlays, staged by rebuild_scene_geometry.
         if self.overlay_dirty {
             self.overlay_dirty = false;
@@ -10656,6 +10724,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             origin: renderer.create_mesh(bytemuck::cast_slice(&origin_verts)),
             pivot: renderer.create_mesh(bytemuck::cast_slice(&pivot_verts)),
             group_points: renderer.create_mesh(&[]),
+            row_points: renderer.create_mesh(&[]),
             overlay_points: renderer.create_mesh(&[]),
             overlay_normals: renderer.create_mesh(&[]),
             // Seeded with what is staged: a replacement renderer gets the
@@ -10933,6 +11002,10 @@ 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 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 });
+                    }
                     // Per-node meta "Point Markers", same full-opacity tier.
                     if self.overlay_point_count > 0 {
                         draws.push(SceneDraw { mesh: meshes.overlay_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/main.rs b/src/main.rs
index 2e5e62f..f764ccc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14652,6 +14652,76 @@ mod tests {
         let _ = fs::remove_dir_all(&dir);
     }
 
+    /// A row of the spreadsheet is a point, and selecting rows marks their
+    /// points in the scene: a press selects one, ctrl adds another, and the
+    /// markers are staged from the positions the table was filled from —
+    /// nothing is evaluated. The selection goes when the table becomes
+    /// another node's.
+    #[test]
+    fn selected_spreadsheet_rows_are_marked_in_the_scene() {
+        use crate::slots::SPREADSHEET_IDX;
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{ElementState, MouseButton};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.execute_menu_action("Show Spreadsheet Pane");
+        state.rebuild_positions();
+        state.apply_layout();
+        state.param_editor = crate::slots::CONTENT_IDX;
+        let mut redraw = false;
+        state.apply_action(McpAction::AddNode { template_name: "Box".into(), name: Some("rows_a".into()), x: 6.0, y: 8.0 }, &mut redraw).unwrap();
+        state.apply_action(McpAction::AddNode { template_name: "Box".into(), name: Some("rows_b".into()), x: 7.0, y: 8.0 }, &mut redraw).unwrap();
+        let slot_of = |state: &State, name: &str| state.current_dir().children.iter().position(|c| c.name == name).expect(name);
+        let (a, b) = (slot_of(&state, "rows_a"), slot_of(&state, "rows_b"));
+        state.apply_action(McpAction::Select { slot: a }, &mut redraw).unwrap();
+        state.sync_nodes();
+        assert_eq!(state.spreadsheet_points.len(), 8, "a box has eight points, a row each");
+        assert!(state.row_marker_verts.is_empty());
+
+        let (sx, sy, sw, sh) = state.positions[SPREADSHEET_IDX];
+        assert!(sw > 0.0 && sh > 60.0, "the spreadsheet is laid out: {sw} x {sh}");
+        // Rows are 24 tall under a 24 header.
+        let press = |state: &mut State, row: usize| {
+            let (x, y) = (sx + 40.0, sy + 24.0 + 24.0 * row as f32 + 12.0);
+            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+            state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+            state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        };
+        let version = state.rt_geometry_version;
+        press(&mut state, 1);
+        assert_eq!(state.selected_spreadsheet_points(), vec![1]);
+        assert!(!state.row_marker_verts.is_empty() && state.row_markers_dirty, "the marker is staged");
+        let one = state.row_marker_verts.len();
+        // The marker stands on the row's point.
+        let p = state.spreadsheet_points[1];
+        let n = one as f32;
+        let mid = state.row_marker_verts.iter().fold([0.0f32; 3], |m, v| [m[0] + v.position[0] / n, m[1] + v.position[1] / n, m[2] + v.position[2] / n]);
+        assert!((0..3).all(|k| (mid[k] - p[k]).abs() < 1e-3), "{mid:?} is not at {p:?}");
+
+        state.modifiers.ctrl = true;
+        press(&mut state, 0);
+        state.modifiers.ctrl = false;
+        assert_eq!(state.selected_spreadsheet_points(), vec![0, 1]);
+        assert_eq!(state.row_marker_verts.len(), 2 * one, "a marker a row");
+        assert_eq!(state.rt_geometry_version, version, "selecting evaluates nothing");
+
+        // A refresh of the same node's table keeps it.
+        state.apply_action(McpAction::SetParam { slot: a, name: "Center".into(), value: "1.00:0.50:0.25".into() }, &mut redraw).unwrap();
+        state.sync_nodes();
+        assert_eq!(state.selected_spreadsheet_points(), vec![0, 1]);
+        let moved = state.spreadsheet_points[1];
+        let mid = state.row_marker_verts[one..].iter().chain(&state.row_marker_verts[..one]).fold([0.0f32; 3], |m, v| [m[0] + v.position[0], m[1] + v.position[1], m[2] + v.position[2]]);
+        let both = [moved, state.spreadsheet_points[0]];
+        let want = [both[0][0] + both[1][0], both[0][1] + both[1][1], both[0][2] + both[1][2]];
+        assert!((0..3).all(|k| (mid[k] / one as f32 - want[k]).abs() < 1e-2), "the markers followed the points");
+
+        // Another node's table is other points.
+        state.apply_action(McpAction::Select { slot: b }, &mut redraw).unwrap();
+        state.sync_nodes();
+        assert!(state.selected_spreadsheet_points().is_empty());
+        assert!(state.row_marker_verts.is_empty());
+    }
+
     /// The spreadsheet and the selected-group markers evaluate through the
     /// SHARED sim cache: with either reading something downstream of a
     /// simnet, a refresh costs no steps beyond the ones the frame itself