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

commit5f7edb0299f234d8680def9575e6b24859e2d2ea
parent590f54bdae
authorLucas Galante <[email protected]>
date2026-07-30 10:36
feat: Render node point display — Render Points / Point Size / Point Color

Three new params on the Render node: a Render Points toggle, a Point Size
slider (0.01–0.30), and a Point Color selector. When on, the viewport's
raster pass draws one small octahedron per DISTINCT vertex of the evaluated
geometry (positions deduped from the triangle soup; both windings emitted
so backface culling can't eat half a diamond), sized and tinted by the
params. The points mesh rebuilds only when its key (geometry version, size,
color) moves and the draw stages between the reference meshes and the
shaded geometry; params persist with the project like the wireframe
toggles and sync through the same Render-node display path.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs      | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/geometry.rs | 46 +++++++++++++++++++++++++++++++++++++++++++
 src/project.rs  | 12 ++++++++++++
 3 files changed, 117 insertions(+), 1 deletion(-)

diff --git a/src/app.rs b/src/app.rs
index c1f8335..6b7c9f9 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -807,6 +807,8 @@ pub struct SceneMeshes {
     pub grid: cce_ui::vk::MeshId,
     pub origin: cce_ui::vk::MeshId,
     pub pivot: cce_ui::vk::MeshId,
+    /// The Render node's point display (one octahedron per distinct vertex).
+    pub points: cce_ui::vk::MeshId,
 }
 
 /// A left-press on the detached circular window's chrome that becomes an
@@ -991,6 +993,19 @@ pub struct State {
     /// filled primitives. Wins over `wireframe` when both are set.
     pub wireframe_overlay: bool,
     pub last_viewport_wireframe_overlay: bool,
+    /// Point display of the node geometry (the Render node's "Render Points"
+    /// toggle): one small octahedron per distinct vertex, sized by
+    /// "Point Size" and tinted by "Point Color".
+    pub render_points: bool,
+    pub point_size: f32,
+    pub point_color: [f32; 3],
+    /// (geometry version, quantized size, color) the points mesh was last
+    /// built from; `point_vertex_count` gates the draw.
+    pub last_points_key: Option<(u64, i32, [u8; 3])>,
+    pub point_vertex_count: u32,
+    pub last_viewport_render_points: bool,
+    pub last_viewport_point_size: f32,
+    pub last_viewport_point_color: [f32; 3],
     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
@@ -2797,6 +2812,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             last_viewport_wireframe: false,
             wireframe_overlay: false,
             last_viewport_wireframe_overlay: false,
+            render_points: false,
+            point_size: 0.02,
+            point_color: [1.0, 1.0, 1.0],
+            last_points_key: None,
+            point_vertex_count: 0,
+            last_viewport_render_points: false,
+            last_viewport_point_size: 0.0,
+            last_viewport_point_color: [0.0, 0.0, 0.0],
             last_viewport_rt_mode: false,
             rt_sphere_verts: Vec::new(),
             rt_geometry_version: 0,
@@ -5143,6 +5166,31 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.spheres_dirty = false;
             renderer.update_mesh(meshes.spheres, bytemuck::cast_slice(&self.rt_sphere_verts));
         }
+
+        // The Render node's point display: rebuilt whenever the geometry or
+        // the point params moved (the key), skipped entirely while off.
+        if self.render_points {
+            let key = (
+                self.rt_geometry_version,
+                (self.point_size * 1000.0).round() as i32,
+                [
+                    (self.point_color[0] * 255.0).round().clamp(0.0, 255.0) as u8,
+                    (self.point_color[1] * 255.0).round().clamp(0.0, 255.0) as u8,
+                    (self.point_color[2] * 255.0).round().clamp(0.0, 255.0) as u8,
+                ],
+            );
+            if self.last_points_key != Some(key) {
+                let verts = crate::geometry::points_vertices(
+                    &self.rt_sphere_verts,
+                    self.point_size,
+                    cce_ui::colors::to_linear_rgb(self.point_color),
+                );
+                self.point_vertex_count = verts.len() as u32;
+                renderer.update_mesh(meshes.points, bytemuck::cast_slice(&verts));
+                self.last_points_key = Some(key);
+                self.viewport_dirty = true;
+            }
+        }
     }
 
     /// One-time renderer setup (engine `renderer_init` hook): the persistent
@@ -5163,6 +5211,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             grid: renderer.create_mesh(bytemuck::cast_slice(&grid_verts)),
             origin: renderer.create_mesh(bytemuck::cast_slice(&origin_verts)),
             pivot: renderer.create_mesh(bytemuck::cast_slice(&pivot_verts)),
+            points: renderer.create_mesh(&[]),
         });
         // Scene geometry built during `State::new` (before the renderer
         // existed) uploads on the first frame's flush.
@@ -5276,7 +5325,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     || self.last_viewport_active_camera != self.active_camera
                     || self.last_viewport_show_viewport != self.show_viewport
                     || self.last_viewport_wireframe != self.wireframe
-                    || self.last_viewport_wireframe_overlay != self.wireframe_overlay;
+                    || self.last_viewport_wireframe_overlay != self.wireframe_overlay
+                    || self.last_viewport_render_points != self.render_points
+                    || self.last_viewport_point_size != self.point_size
+                    || self.last_viewport_point_color != self.point_color;
 
                 if viewport_changed {
                     if !rt_mode {
@@ -5308,6 +5360,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     if self.viewport().show_cube {
                         draws.push(SceneDraw { mesh: meshes.cube, mvp, wireframe: false, wire_tint: NO_TINT });
                     }
+                    if self.render_points && self.point_vertex_count > 0 {
+                        draws.push(SceneDraw { mesh: meshes.points, mvp, wireframe: false, wire_tint: NO_TINT });
+                    }
                     if self.vertex_count_spheres > 0 {
                         if self.wireframe_overlay {
                             // Shaded + wireframe: the filled mesh, then its
@@ -5343,6 +5398,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     self.last_viewport_show_viewport = self.show_viewport;
                     self.last_viewport_wireframe = self.wireframe;
                     self.last_viewport_wireframe_overlay = self.wireframe_overlay;
+                    self.last_viewport_render_points = self.render_points;
+                    self.last_viewport_point_size = self.point_size;
+                    self.last_viewport_point_color = self.point_color;
                     self.last_viewport_rt_mode = rt_mode;
                     self.viewport_dirty = false;
                 }
diff --git a/src/geometry.rs b/src/geometry.rs
index 62a50fb..becfb51 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1424,6 +1424,52 @@ pub fn origin_vectors_vertices(scale: f32) -> Vec<Vertex3D> {
     verts
 }
 
+/// The Render node's point display: one small octahedron per DISTINCT vertex
+/// position of `src` (positions quantized for the dedup — the raw triangle
+/// soup repeats each vertex per face). Every face is emitted in both windings
+/// so the raster pass's backface cull can't eat half the diamond.
+pub fn points_vertices(src: &[Vertex3D], size: f32, color: [f32; 3]) -> Vec<Vertex3D> {
+    let mut seen = std::collections::HashSet::new();
+    let mut out = Vec::new();
+    let r = size.max(0.001);
+    for v in src {
+        let key = (
+            (v.position[0] * 1000.0).round() as i32,
+            (v.position[1] * 1000.0).round() as i32,
+            (v.position[2] * 1000.0).round() as i32,
+        );
+        if !seen.insert(key) {
+            continue;
+        }
+        let [px, py, pz] = v.position;
+        let top = [px, py + r, pz];
+        let bot = [px, py - r, pz];
+        let xp = [px + r, py, pz];
+        let xm = [px - r, py, pz];
+        let zp = [px, py, pz + r];
+        let zm = [px, py, pz - r];
+        let faces = [
+            [top, zp, xp],
+            [top, xp, zm],
+            [top, zm, xm],
+            [top, xm, zp],
+            [bot, xp, zp],
+            [bot, zm, xp],
+            [bot, xm, zm],
+            [bot, zp, xm],
+        ];
+        for f in faces {
+            for p in f {
+                out.push(Vertex3D { position: p, color });
+            }
+            for p in [f[0], f[2], f[1]] {
+                out.push(Vertex3D { position: p, color });
+            }
+        }
+    }
+    out
+}
+
 pub fn camera_pivot_vertices(scale: f32) -> Vec<Vertex3D> {
     let mut verts = Vec::new();
     let t = 0.002 * scale; 
diff --git a/src/project.rs b/src/project.rs
index e35389b..c2ad3b4 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -271,6 +271,9 @@ impl State {
         let show_playbar = self.show_playbar;
         let wireframe = self.wireframe;
         let wireframe_overlay = self.wireframe_overlay;
+        let render_points = self.render_points;
+        let point_size = self.point_size;
+        let point_color = self.point_color;
         let square_viewport = self.square_viewport;
         let bool_str = |b: bool| if b { "true" } else { "false" };
 
@@ -520,11 +523,17 @@ impl State {
         ensure_param(render_node, "Render Settings", "section", "", &[], None, None, None);
         ensure_param(render_node, "Show Wireframe", "toggle", bool_str(wireframe), &[], None, None, None);
         ensure_param(render_node, "Wireframe Overlay", "toggle", bool_str(wireframe_overlay), &[], None, None, None);
+        ensure_param(render_node, "Render Points", "toggle", bool_str(render_points), &[], None, None, None);
+        ensure_param(render_node, "Point Size", "slider:0.01:0.30", &format!("{:.2}", point_size), &[], Some(0.01), Some(0.30), None);
+        ensure_param(render_node, "Point Color", "color", &color_to_hex(point_color), &[], None, None, None);
 
         for p in render_node.params.iter_mut() {
             match p.name.as_str() {
                 "Show Wireframe" => set_toggle(p, wireframe),
                 "Wireframe Overlay" => set_toggle(p, wireframe_overlay),
+                "Render Points" => set_toggle(p, render_points),
+                "Point Size" => p.default = format!("{:.2}", point_size),
+                "Point Color" => p.default = color_to_hex(point_color),
                 _ => {}
             }
             if p.param_type == "toggle" {
@@ -661,6 +670,9 @@ impl State {
                 match p.name.as_str() {
                     "Show Wireframe" => if let Ok(val) = p.default.parse::<bool>() { self.wireframe = val; }
                     "Wireframe Overlay" => if let Ok(val) = p.default.parse::<bool>() { self.wireframe_overlay = val; }
+                    "Render Points" => if let Ok(val) = p.default.parse::<bool>() { self.render_points = val; }
+                    "Point Size" => if let Ok(val) = p.default.parse::<f32>() { self.point_size = val.clamp(0.005, 1.0); }
+                    "Point Color" => if let Some(col) = hex_to_color(&p.default) { self.point_color = col; }
                     _ => {}
                 }
             }