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

commit4ee92131b4357c2e452112aea8790f039d214374
parent3083349a4a
authorLucas Galante <[email protected]>
date2026-08-25 08:17
feat: selecting a Group node marks its member points in the viewport

While the selection is a Group node, sync_nodes evaluates it and stages one
warm-accent marker (the Highlight bake's color) at every vertex the node
tags into group:<name> — so the selection SHOWS the group, independent of
the Highlight toggle and of which node holds the display flag. Markers ride
a new group_points scene mesh through the existing points_vertices
expansion, rebuild when the node, its params, the geometry version, or the
point size change, follow param edits live, and clear on deselection. Full
opacity by design: selection feedback, not part of the Render node's
Opacity. group_member_positions is the pure membership read, unit-tested
against the box partition on a sphere.

 src/app.rs      | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/geometry.rs | 13 +++++++++
 src/main.rs     | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 176 insertions(+)

diff --git a/src/app.rs b/src/app.rs
index eb08006..9e47f5a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -643,6 +643,9 @@ pub struct SceneMeshes {
     pub pivot: cce_ui::vk::MeshId,
     /// The Render node's point display (one octahedron per distinct vertex).
     pub points: cce_ui::vk::MeshId,
+    /// 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,
 }
 
 /// A left-press on the detached circular window's chrome that becomes an
@@ -896,6 +899,15 @@ pub struct State {
     pub last_viewport_render_points: bool,
     pub last_viewport_point_size: f32,
     pub last_viewport_point_color: [f32; 3],
+    /// 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.
+    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_viewport_rt_mode: bool,
     /// Sphere-geometry cache for the path tracer (a copy of the last
     /// `rebuild_scene_geometry` output, so entering RT mode never re-runs
@@ -2647,6 +2659,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             }
         }
 
+        // Both refresh passes below evaluate against `selected_node`, which
+        // borrows self — so each computes OWNED results here and the mutation
+        // tail applies them once the borrow is dead.
+        let mut spreadsheet_update = None;
         if self.show_spreadsheet && !cache_hit {
             let mut headers = Vec::new();
             let mut rows = Vec::new();
@@ -2666,11 +2682,58 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     rows = r;
                 }
             }
+            spreadsheet_update = Some((headers, rows));
+        }
 
+        // Selected-Group viewport markers: while the selection is a Group
+        // node, evaluate it and stage a marker at every vertex it tags, so
+        // selecting the node SHOWS the group in the viewport — independent of
+        // the Highlight bake and of which node holds the display flag. The
+        // geometry version keeps the key honest against upstream edits (the
+        // scene rebuild bumps it); a non-group selection clears the markers.
+        let group_key = selected_node.filter(|n| n.node_type.eq_ignore_ascii_case("group")).map(|n| {
+            (
+                n.id.clone(),
+                n.params.iter().map(|p| (p.name.clone(), p.default.clone())).collect::<Vec<_>>(),
+                self.rt_geometry_version,
+                (self.point_size * 1000.0).round() as i32,
+            )
+        });
+        let mut group_update = None;
+        if group_key != self.last_group_points_key {
+            let mut marker_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();
+                let mut ocl_error = None;
+                // Throwaway sim cache, as for the spreadsheet above.
+                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. Slightly larger than the
+                    // Render node's points so both stay legible together.
+                    marker_verts = crate::geometry::points_vertices(
+                        &members,
+                        self.point_size * 1.25,
+                        cce_ui::colors::to_linear_rgb([1.0, 0.78, 0.20]),
+                    );
+                }
+            }
+            group_update = Some(marker_verts);
+        }
+
+        if let Some((headers, rows)) = spreadsheet_update {
             self.spreadsheet_mut().set_spreadsheet_data(headers, rows);
             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;
+            self.last_group_points_key = group_key;
+        }
     }
 
 
@@ -3002,6 +3065,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             last_viewport_render_points: false,
             last_viewport_point_size: 0.0,
             last_viewport_point_color: [0.0, 0.0, 0.0],
+            group_point_verts: Vec::new(),
+            group_points_dirty: false,
+            group_point_vertex_count: 0,
+            last_group_points_key: None,
             last_viewport_rt_mode: false,
             rt_sphere_verts: Vec::new(),
             rt_geometry_version: 0,
@@ -5541,6 +5608,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 self.viewport_dirty = true;
             }
         }
+
+        // Selected-Group markers, staged by sync_nodes.
+        if self.group_points_dirty {
+            self.group_points_dirty = false;
+            renderer.update_mesh(meshes.group_points, bytemuck::cast_slice(&self.group_point_verts));
+            self.group_point_vertex_count = self.group_point_verts.len() as u32;
+            self.viewport_dirty = true;
+        }
     }
 
     /// One-time renderer setup (engine `renderer_init` hook): the persistent
@@ -5563,6 +5638,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             origin: renderer.create_mesh(bytemuck::cast_slice(&origin_verts)),
             pivot: renderer.create_mesh(bytemuck::cast_slice(&pivot_verts)),
             points: renderer.create_mesh(&[]),
+            group_points: renderer.create_mesh(&[]),
         });
         // Scene geometry built during `State::new` (before the renderer
         // existed) uploads on the first frame's flush.
@@ -5718,6 +5794,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     if self.render_points && self.point_vertex_count > 0 {
                         draws.push(SceneDraw { mesh: meshes.points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: geo_opacity, line_width: 1.0, wire_base_width: 0.0 });
                     }
+                    // Selected-Group markers: full-opacity selection feedback,
+                    // deliberately outside the Render node's Opacity.
+                    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 });
+                    }
                     if self.vertex_count_spheres > 0 {
                         // With wires coming, the fill is pushed back by its
                         // slope-scaled offset so the lattice reads solid.
diff --git a/src/geometry.rs b/src/geometry.rs
index 97c9c75..8dea668 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -675,6 +675,19 @@ pub fn resolve_group_geometry_with_errors(
     Some(geom)
 }
 
+/// Positions of the vertices a Group node tagged into `group:<name>` — the
+/// source data for the selected-Group viewport markers. Duplicate positions
+/// (the triangle soup repeats shared corners) are left in; `points_vertices`
+/// dedupes by quantized position.
+pub fn group_member_positions(geom: &Geometry, group_name: &str) -> Vec<Vertex3D> {
+    let attr = format!("group:{}", group_name.trim());
+    geom.vertices
+        .iter()
+        .filter(|v| v.attributes.contains_key(&attr))
+        .map(|v| Vertex3D { position: v.pos, color: [0.0; 3] })
+        .collect()
+}
+
 struct PrecomputedTriangle {
     v0: Vec3,
     edge1: Vec3,
diff --git a/src/main.rs b/src/main.rs
index c08c04e..fedafdc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -864,6 +864,88 @@ mod tests {
         assert_eq!(geom2.vertices.len(), 16128);
     }
 
+    /// Group membership → viewport markers: the `group:<name>` tags a Group
+    /// node writes partition the geometry on its box, `group_member_positions`
+    /// reads exactly the tagged vertices back out, and `points_vertices`
+    /// expands them into marker geometry (what the viewport draws while the
+    /// Group node is selected).
+    #[test]
+    fn test_group_member_positions() {
+        let templates_root = crate::app::load_fs_tree();
+        let sphere_template = templates_root.children.iter().find(|t| t.name == "Sphere").unwrap();
+        let group_template = templates_root
+            .children
+            .iter()
+            .find(|t| t.name == "Group")
+            .expect("Group template should be loaded");
+
+        let mut sphere_instance = sphere_template.clone();
+        sphere_instance.id = "sphere_inst".to_string();
+        sphere_instance.name = "Sphere 1".to_string();
+        for child in &mut sphere_instance.children {
+            child.id = format!("{}_{}", sphere_instance.id, child.name);
+        }
+
+        // Box over the sphere's upper half: the default sphere is radius 0.5
+        // centered at (0, 0.55, 0), so y ∈ [0.55, 1.05] selects the top
+        // hemisphere's vertices.
+        let mut group_instance = group_template.clone();
+        group_instance.id = "group_inst".to_string();
+        group_instance.name = "Group 1".to_string();
+        let set = |inst: &mut FsNode, name: &str, val: &str| {
+            inst.params.iter_mut().find(|p| p.name == name).unwrap().default = val.to_string();
+        };
+        set(&mut group_instance, "Input", "Sphere 1");
+        set(&mut group_instance, "Center", "0.00:0.80:0.00");
+        set(&mut group_instance, "Size", "2.00:0.50:2.00");
+
+        let root = FsNode {
+            id: "root".to_string(),
+            name: "root".to_string(),
+            node_type: "node".to_string(),
+            children: vec![sphere_instance, group_instance],
+            params: vec![],
+            geometry_visible: true,
+            position: (0.0, 0.0),
+            inputs: 0,
+            outputs: 0,
+        };
+
+        let mut visited = Vec::new();
+        let mut ocl_err = None;
+        let geom = crate::geometry::generate_single_node_geometry_with_errors(
+            &root,
+            &root.children[1],
+            &mut visited,
+            &mut ocl_err,
+            &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
+        ).expect("Group geometry generation failed");
+        assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
+
+        let members = crate::geometry::group_member_positions(&geom, "group1");
+        assert!(!members.is_empty(), "the box should tag the upper hemisphere");
+        assert!(members.len() < geom.vertices.len(), "the box must not tag everything");
+        for m in &members {
+            assert!(m.position[1] >= 0.55 - 1e-4, "member below the box: y={}", m.position[1]);
+        }
+        // The tags and the box agree: every untagged vertex is outside it.
+        let tagged: usize = geom.vertices.iter().filter(|v| v.attributes.contains_key("group:group1")).count();
+        assert_eq!(tagged, members.len());
+        for v in &geom.vertices {
+            if !v.attributes.contains_key("group:group1") {
+                assert!(v.pos[1] <= 0.55 + 1e-4, "non-member inside the box: y={}", v.pos[1]);
+            }
+        }
+
+        // A name the node never wrote reads back empty.
+        assert!(crate::geometry::group_member_positions(&geom, "nope").is_empty());
+
+        // Marker expansion: 4x10 lat/lon sphere = 240 vertices per distinct point.
+        let markers = crate::geometry::points_vertices(&members, 0.025, [1.0, 0.78, 0.20]);
+        assert!(!markers.is_empty());
+        assert_eq!(markers.len() % 240, 0);
+    }
+
     /// The Plane template mirrors the Sphere subnet (an opencl node feeding an
     /// output node); its kernel generates a divs x divs grid on XZ at y = 0,
     /// with the Size param as the side length.