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