graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: meta Point Normals — per-node normal whiskers
Fourth meta pref: Point Normals draws a thin cyan whisker from each
distinct point along its smooth vertex normal, length 4x the Guides
Point Marker Size. Normals come from topology — per position, the
normalized sum of touching triangles' face normals, with the cross
negated per this repo's winding convention (kernel outputs' Norm
attribute is a default up-vector, useless for display). Whisker width is
deliberately fixed at 1: Wire Width is wireframe styling, not a normals
choice. The sphere test asserts every whisker tip sits farther from the
center than its base, pinning the outwardness convention.
src/app.rs | 28 ++++++++++++++++++++++++---
src/main.rs | 29 +++++++++++++++++++++-------
src/render.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++------
3 files changed, 103 insertions(+), 16 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index 0dfaba8..d962c90 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -327,9 +327,12 @@ pub fn ensure_meta_on(node: &mut FsNode) {
});
}
let meta = node.children.iter_mut().find(|c| c.node_type == "meta").unwrap();
- for (name, default) in
- [("Point Markers", "false"), ("Point Numbers", "false"), ("Wireframe", "false")]
- {
+ for (name, default) in [
+ ("Point Markers", "false"),
+ ("Point Numbers", "false"),
+ ("Point Normals", "false"),
+ ("Wireframe", "false"),
+ ] {
if !meta.params.iter().any(|p| p.name == name) {
meta.params.push(ParamDef {
name: name.to_string(),
@@ -809,6 +812,8 @@ pub struct SceneMeshes {
pub meta_points: cce_ui::vk::MeshId,
/// Per-node meta "Wireframe" overlay (LINE_LIST edge pairs).
pub meta_wires: cce_ui::vk::MeshId,
+ /// Per-node meta "Point Normals" overlay (LINE_LIST whiskers).
+ pub meta_normals: cce_ui::vk::MeshId,
}
/// A left-press on the detached circular window's chrome that becomes an
@@ -1083,6 +1088,11 @@ pub struct State {
/// triangles, drawn as a wire pass over the scene fill.
pub meta_wire_verts: Vec<Vertex3D>,
pub meta_wire_count: u32,
+ /// Per-node meta "Point Normals": LINE_LIST whiskers from each distinct
+ /// point along its smooth vertex normal (computed from topology — the
+ /// kernel outputs carry only a default up-normal attribute).
+ pub meta_normal_verts: Vec<Vertex3D>,
+ pub meta_normal_count: u32,
/// World-unit radius of the meta "Point Markers" overlay — the Guides
/// subnet's "Point Marker Size" control (stored there in thousandths).
pub meta_marker_size: f32,
@@ -3265,6 +3275,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
meta_number_labels: Vec::new(),
meta_wire_verts: Vec::new(),
meta_wire_count: 0,
+ meta_normal_verts: Vec::new(),
+ meta_normal_count: 0,
meta_marker_size: 0.02,
last_scene_mvp: None,
last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
@@ -5823,6 +5835,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
self.meta_point_count = self.meta_marker_verts.len() as u32;
renderer.update_mesh(meshes.meta_wires, bytemuck::cast_slice(&self.meta_wire_verts));
self.meta_wire_count = self.meta_wire_verts.len() as u32;
+ renderer
+ .update_mesh(meshes.meta_normals, bytemuck::cast_slice(&self.meta_normal_verts));
+ self.meta_normal_count = self.meta_normal_verts.len() as u32;
self.viewport_dirty = true;
}
}
@@ -5850,6 +5865,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
group_points: renderer.create_mesh(&[]),
meta_points: renderer.create_mesh(&[]),
meta_wires: renderer.create_mesh(&[]),
+ meta_normals: renderer.create_mesh(&[]),
});
// Scene geometry built during `State::new` (before the renderer
// existed) uploads on the first frame's flush.
@@ -6057,6 +6073,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
if self.meta_wire_count > 0 {
draws.push(SceneDraw { mesh: meshes.meta_wires, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: self.wire_width, wire_base_width: 0.0 });
}
+ // Per-node meta Point Normals: thin cyan whiskers,
+ // width deliberately fixed (a chunky Wire Width is a
+ // wireframe styling choice, not a normals one).
+ if self.meta_normal_count > 0 {
+ draws.push(SceneDraw { mesh: meshes.meta_normals, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
+ }
}
renderer.stage_scene((sx, sy, cw, ch), draws);
}
diff --git a/src/main.rs b/src/main.rs
index 6b52605..d1b6acd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1199,7 +1199,7 @@ mod tests {
let meta = s.children.iter().find(|c| c.node_type == "meta").expect("sphere meta");
assert_eq!(
meta.params.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
- ["Point Markers", "Point Numbers", "Wireframe"]
+ ["Point Markers", "Point Numbers", "Point Normals", "Wireframe"]
);
assert!(meta.params.iter().all(|p| p.default == "false"));
let opencl = s.children.iter().find(|c| c.name == "opencl1").unwrap();
@@ -1234,11 +1234,11 @@ mod tests {
// Overlays: nothing while the prefs are off…
let mut cache = crate::geometry::SimCache::default();
- let (markers, labels, wires) = crate::render::collect_meta_overlays(
+ let (markers, labels, wires, normals) = crate::render::collect_meta_overlays(
&root, 0.02, &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
- assert!(markers.is_empty() && labels.is_empty() && wires.is_empty());
+ assert!(markers.is_empty() && labels.is_empty() && wires.is_empty() && normals.is_empty());
- // …all three overlays for the flagged sphere: 240 marker verts per
+ // …all four overlays for the flagged sphere: 240 marker verts per
// deduped point, labels matching the same dedupe, and one LINE_LIST
// pair per triangle edge (2304 verts = 768 triangles = 4608 pairs).
{
@@ -1249,19 +1249,34 @@ mod tests {
assert!(crate::app::meta_pref(&root.children[0], "Point Markers"));
assert!(crate::app::meta_pref(&root.children[0], "Wireframe"));
let mut cache = crate::geometry::SimCache::default();
- let (markers, labels, wires) = crate::render::collect_meta_overlays(
+ let (markers, labels, wires, normals) = crate::render::collect_meta_overlays(
&root, 0.02, &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
assert!(!labels.is_empty() && labels.len() < 16 * 24 * 6);
assert_eq!(markers.len(), labels.len() * 240);
assert!(labels.iter().any(|(_, i)| *i > 0));
assert_eq!(wires.len(), (16 * 24 * 6 / 3) * 6);
+ // Normals: one whisker per distinct point, pointing OUT of the
+ // sphere (center (0, 0.55, 0)) — this pins the winding/negation
+ // convention, not just the count.
+ assert_eq!(normals.len(), labels.len() * 2);
+ for pair in normals.chunks_exact(2) {
+ let d = |p: &[f32; 3]| {
+ let (dx, dy, dz) = (p[0], p[1] - 0.55, p[2]);
+ (dx * dx + dy * dy + dz * dz).sqrt()
+ };
+ assert!(
+ d(&pair[1].position) > d(&pair[0].position),
+ "normal points inward at {:?}",
+ pair[0].position
+ );
+ }
// …and none once the node's geometry is hidden.
root.children[0].geometry_visible = false;
let mut cache = crate::geometry::SimCache::default();
- let (markers, labels, wires) = crate::render::collect_meta_overlays(
+ let (markers, labels, wires, normals) = crate::render::collect_meta_overlays(
&root, 0.02, &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
- assert!(markers.is_empty() && labels.is_empty() && wires.is_empty());
+ assert!(markers.is_empty() && labels.is_empty() && wires.is_empty() && normals.is_empty());
}
/// The Plane template's construction controls: Rows/Columns set the grid
diff --git a/src/render.rs b/src/render.rs
index 6e4c13b..c2c5ff0 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -856,7 +856,7 @@ impl State {
// Per-node meta overlays ride the same rebuild: markers and point
// numbers for nodes whose meta child asks for them.
let mut sim_cache = std::mem::take(&mut self.sim_cache);
- let (markers, labels, wires) = {
+ let (markers, labels, wires, normals) = {
let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
collect_meta_overlays(&self.fs_root, self.meta_marker_size, &mut sim)
};
@@ -864,6 +864,7 @@ impl State {
self.meta_marker_verts = markers;
self.meta_number_labels = labels;
self.meta_wire_verts = wires;
+ self.meta_normal_verts = normals;
self.meta_points_dirty = true;
}
@@ -899,10 +900,16 @@ pub(crate) fn collect_meta_overlays(
root: &FsNode,
point_size: f32,
sim: &mut crate::geometry::EvalSim,
-) -> (Vec<crate::geometry::Vertex3D>, Vec<([f32; 3], u32)>, Vec<crate::geometry::Vertex3D>) {
+) -> (
+ Vec<crate::geometry::Vertex3D>,
+ Vec<([f32; 3], u32)>,
+ Vec<crate::geometry::Vertex3D>,
+ Vec<crate::geometry::Vertex3D>,
+) {
let mut markers = Vec::new();
let mut labels = Vec::new();
let mut wires = Vec::new();
+ let mut normals = Vec::new();
fn visit(
root: &FsNode,
node: &FsNode,
@@ -911,13 +918,15 @@ pub(crate) fn collect_meta_overlays(
markers: &mut Vec<crate::geometry::Vertex3D>,
labels: &mut Vec<([f32; 3], u32)>,
wires: &mut Vec<crate::geometry::Vertex3D>,
+ normals: &mut Vec<crate::geometry::Vertex3D>,
sim: &mut crate::geometry::EvalSim,
) {
let is_visible = parent_visible && node.geometry_visible;
let want_markers = is_visible && crate::app::meta_pref(node, "Point Markers");
let want_numbers = is_visible && crate::app::meta_pref(node, "Point Numbers");
let want_wires = is_visible && crate::app::meta_pref(node, "Wireframe");
- if want_markers || want_numbers || want_wires {
+ let want_normals = is_visible && crate::app::meta_pref(node, "Point Normals");
+ if want_markers || want_numbers || want_wires || want_normals {
let mut visited = Vec::new();
let mut err = None;
if let Some(geom) = crate::geometry::generate_single_node_geometry_with_errors(
@@ -950,6 +959,47 @@ pub(crate) fn collect_meta_overlays(
cce_ui::colors::to_linear_rgb([0.85, 0.85, 1.0]),
));
}
+ if want_normals {
+ // Smooth vertex normals from topology: per distinct
+ // position, the normalized sum of touching triangles'
+ // face normals. On this repo's meshes cross(B-A, C-A)
+ // points INWARD (see the node-template kernel notes), so
+ // it is negated for outward whiskers. The kernel outputs'
+ // Norm attribute is a default up-vector — useless here.
+ use glam::Vec3;
+ let quant = |p: &[f32; 3]| {
+ (
+ (p[0] * 1000.0).round() as i32,
+ (p[1] * 1000.0).round() as i32,
+ (p[2] * 1000.0).round() as i32,
+ )
+ };
+ let mut acc: std::collections::HashMap<(i32, i32, i32), ([f32; 3], Vec3)> =
+ std::collections::HashMap::new();
+ for tri in geom.vertices.chunks_exact(3) {
+ let a = Vec3::from_array(tri[0].pos);
+ let b = Vec3::from_array(tri[1].pos);
+ let c = Vec3::from_array(tri[2].pos);
+ let n = -(b - a).cross(c - a);
+ if n.length_squared() <= 1e-12 {
+ continue;
+ }
+ for v in tri {
+ acc.entry(quant(&v.pos)).or_insert((v.pos, Vec3::ZERO)).1 += n;
+ }
+ }
+ let len = point_size * 4.0;
+ let color = cce_ui::colors::to_linear_rgb([0.45, 0.8, 1.0]);
+ for (pos, sum) in acc.values() {
+ let n = sum.normalize_or_zero();
+ if n == Vec3::ZERO {
+ continue;
+ }
+ let tip = Vec3::from_array(*pos) + n * len;
+ normals.push(crate::geometry::Vertex3D { position: *pos, color });
+ normals.push(crate::geometry::Vertex3D { position: tip.to_array(), color });
+ }
+ }
if want_numbers {
let mut seen = std::collections::HashSet::new();
for (i, v) in geom.vertices.iter().enumerate() {
@@ -966,11 +1016,11 @@ pub(crate) fn collect_meta_overlays(
}
}
for c in &node.children {
- visit(root, c, is_visible, point_size, markers, labels, wires, sim);
+ visit(root, c, is_visible, point_size, markers, labels, wires, normals, sim);
}
}
for c in &root.children {
- visit(root, c, true, point_size, &mut markers, &mut labels, &mut wires, sim);
+ visit(root, c, true, point_size, &mut markers, &mut labels, &mut wires, &mut normals, sim);
}
// A dense mesh can label tens of thousands of points; the text pass is
// per-frame, so cap it rather than melt the frame rate.
@@ -978,5 +1028,5 @@ pub(crate) fn collect_meta_overlays(
if labels.len() > MAX_LABELS {
labels.truncate(MAX_LABELS);
}
- (markers, labels, wires)
+ (markers, labels, wires, normals)
}