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