graphic design tool
git clone https://git.lucas.co/cce-designer.git
fix: point numbers and line annotations are dimmed by the fill in front of them
The normal whiskers and pull arrows drew after the fill: hidden outright
behind a translucent fill that writes depth, full strength over the near
faces of a see-through one. They go before the geometry on the
depth-writing line pipeline, as the markers do. Point numbers are 2D text,
so their alpha is worked out from the fill layers the sight line crosses;
behind an opaque face a number is not drawn.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 27 ++++++++++++
src/app.rs | 62 ++++++++++++++++++++++------
src/geometry.rs | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 56 +++++++++++++++++++++++++
src/render.rs | 11 ++++-
5 files changed, 267 insertions(+), 14 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index c24265b..2de200e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2168,6 +2168,33 @@ is taken in MESH space, the inverse of view × model. Leaving see-through
clears the key and re-uploads nothing: sorted order is still a valid
opaque mesh.
+**Every annotation is dimmed by what is in front of it** (since
+2026-09-29). Three mechanisms, because there are three kinds of annotation.
+The MARKERS (Render points, Selected-Group markers, Show Point Markers)
+always were: they draw before the fill and the wires and write depth, so a
+nearer translucent face or wire blends over them. The LINE annotations —
+the normal whiskers, Visualize's vectors, the pull arrows — drew AFTER the
+fill until then, which was wrong both ways: behind a translucent fill that
+writes depth they were hidden outright, and behind a see-through one, which
+writes none, the far side's painted over the near faces at full strength
+(the wires' own bug of 2026-09-25). They go before the geometry now, on the
+depth-writing line pipeline (`see_through: true` on a wire draw selects
+it). The point NUMBERS are 2D text and never meet the depth buffer, so
+their dimming is worked out on the CPU: `geometry::point_transmittance`
+counts the fill layers the sight line to each point crosses — every
+triangle when seen through; otherwise the front-facing ones nearer than all
+drawn before them, which is what culling and the depth write leave — and
+the label's alpha is `(1 - Opacity)` to that power. Triangles are binned by
+screen bounds, so the cost is a projection of the mesh per camera move and
+a few tests per label. `State::sync_point_number_alpha` runs it from the
+stage pass, for the eye being staged; a label under 2% is not drawn, so
+behind an OPAQUE face a number is hidden, where until then every number
+showed through everything. The wires are not counted against a number: a
+line a pixel wide is not in front of a label in any way one alpha could
+show. `a_point_number_is_dimmed_by_the_fill_in_front_of_it` is the test;
+the whiskers' order has none, being a draw list only a renderer reads, and
+was checked in a shadow session before and after.
+
### Pull arrows
While the params pane shows an Attribute node that writes `Pos`
diff --git a/src/app.rs b/src/app.rs
index db7dce8..300c9a8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2383,6 +2383,12 @@ pub struct State {
pub overlay_dirty: bool,
pub overlay_point_count: u32,
pub overlay_number_labels: Vec<([f32; 3], u32)>,
+ /// How much of each label above shows through the fill in front of its
+ /// point (`geometry::point_transmittance`), worked out by the stage
+ /// pass for the eye it staged. The labels are 2D text, so this is the
+ /// only occlusion they get. Emptied with every rebuild of the labels; a
+ /// label with no entry draws whole.
+ pub overlay_number_alpha: Vec<f32>,
/// Show 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).
@@ -6031,6 +6037,25 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.point_size * self.group_marker_scale
}
+ /// Works out how much of each point number shows through the fill, for
+ /// the eye the stage pass is staging (`eye` in mesh space, the space of
+ /// the labels and the triangles).
+ pub(crate) fn sync_point_number_alpha(&mut self, mvp: Mat4, eye: Vec3) {
+ if self.overlay_number_labels.is_empty() {
+ self.overlay_number_alpha.clear();
+ return;
+ }
+ let points: Vec<[f32; 3]> = self.overlay_number_labels.iter().map(|(p, _)| *p).collect();
+ self.overlay_number_alpha = crate::geometry::point_transmittance(
+ &self.rt_sphere_verts,
+ mvp,
+ eye,
+ &points,
+ self.geo_opacity,
+ self.see_through_active(),
+ );
+ }
+
/// Build the Selected-Group marker spheres from the kept members at the
/// current size — the cheap half of the markers, with no evaluation, so
/// a size change can run it on every motion of a drag. The Highlight
@@ -6493,6 +6518,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
overlay_dirty: false,
overlay_point_count: 0,
overlay_number_labels: Vec::new(),
+ overlay_number_alpha: Vec::new(),
overlay_normal_verts: Vec::new(),
overlay_normal_count: 0,
show_point_markers: settings.viewport.show_point_markers,
@@ -10685,6 +10711,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.last_scene_mvp = Some(mvp_mat);
self.last_scene_view_rect =
(sx as f32 / s, sy as f32 / s, cw as f32 / s, ch as f32 / s);
+ self.sync_point_number_alpha(mvp_mat, (view_mat * model).inverse().transform_point3(Vec3::ZERO));
// The camera-pivot marker is WORLD-FIXED at the pivot point, like
// the origin gizmo. Its old yaw rotation existed to keep it glued
@@ -10744,6 +10771,29 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
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 });
}
if self.vertex_count_spheres > 0 {
+ // The line annotations go UNDER the geometry, as
+ // the markers above do, and write depth (the wire
+ // draw's `see_through` selects the depth-writing
+ // line pipeline): what is nearer then blends over
+ // them, so a whisker behind a translucent face or
+ // wire is dimmed by it, and what is farther fails
+ // the test and leaves them whole. Drawn after the
+ // fill they were hidden outright behind one that
+ // writes depth, and painted at full strength over
+ // the near faces of one that does not.
+ //
+ // Show Point Normals: thin cyan whiskers,
+ // width deliberately fixed (a chunky Wire Width is a
+ // wireframe styling choice, not a normals one).
+ if self.overlay_normal_count > 0 {
+ draws.push(SceneDraw { mesh: meshes.overlay_normals, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: true });
+ }
+ // Pull arrows: selection feedback, like the group
+ // markers — full opacity, a little heavier than the
+ // whiskers.
+ if self.pull_arrow_count > 0 {
+ draws.push(SceneDraw { mesh: meshes.pull_arrows, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 2.0, wire_base_width: 0.0, prelit: false, see_through: true });
+ }
// With wires coming, the fill is pushed back by its
// slope-scaled offset so the lattice reads solid.
let base = if self.wireframe { self.wire_width } else { 0.0 };
@@ -10780,18 +10830,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
draws.push(SceneDraw { mesh: meshes.sphere_edges, mvp, wireframe: true, wire_tint: tint, opacity: wire_alpha, line_width: self.wire_width, wire_base_width: 0.0, prelit: false, see_through });
}
draws.extend(fill);
- // Show Point Normals: thin cyan whiskers,
- // width deliberately fixed (a chunky Wire Width is a
- // wireframe styling choice, not a normals one).
- if self.overlay_normal_count > 0 {
- draws.push(SceneDraw { mesh: meshes.overlay_normals, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
- }
- // Pull arrows: selection feedback, like the group
- // markers — full opacity, a little heavier than the
- // whiskers so they read over a wireframe.
- if self.pull_arrow_count > 0 {
- draws.push(SceneDraw { mesh: meshes.pull_arrows, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 2.0, wire_base_width: 0.0, prelit: false, see_through: false });
- }
}
renderer.stage_scene((sx, sy, cw, ch), draws);
}
diff --git a/src/geometry.rs b/src/geometry.rs
index 2823cc6..4748d19 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -95,6 +95,131 @@ pub fn sort_triangles_back_to_front(verts: &[Vertex3D], eye: Vec3) -> Vec<Vertex
out
}
+/// How much of what stands at each of `points` reaches `eye` through the
+/// translucent fill: 1 with nothing in front, `(1 - opacity)` per layer of
+/// fill the sight line crosses, 0 behind an opaque one. It is what the depth
+/// test and the blend do to a marker drawn under the fill, worked out on the
+/// CPU for the point NUMBERS, which are 2D text and never meet the depth
+/// buffer.
+///
+/// A layer is what the raster pass would blend there. Seen through, that
+/// is every triangle the line crosses, either side. Otherwise the fill
+/// culls back faces and writes depth, so a triangle counts when it faces
+/// the eye and is nearer than every one drawn before it — `verts` is in
+/// draw order. Triangles are binned by their screen bounds (`mvp`) so a
+/// point is tested against the ones over its own pixel, not the mesh; the
+/// primitives that meet AT the point cross the line at its end and are not
+/// in front of it. The wires are not counted: a number is not behind a line
+/// a pixel wide in any way a single alpha could show.
+pub fn point_transmittance(
+ verts: &[Vertex3D],
+ mvp: glam::Mat4,
+ eye: Vec3,
+ points: &[[f32; 3]],
+ opacity: f32,
+ see_through: bool,
+) -> Vec<f32> {
+ const CELLS: usize = 48;
+ let tris = verts.len() / 3;
+ let opacity = opacity.clamp(0.0, 1.0);
+ if tris == 0 || opacity <= 0.0 || points.is_empty() {
+ return vec![1.0; points.len()];
+ }
+ let corner = |t: usize, k: usize| Vec3::from_array(verts[3 * t + k].position);
+ let cell_of = |ndc: f32| (((ndc * 0.5 + 0.5) * CELLS as f32).floor().max(0.0) as usize).min(CELLS - 1);
+ let mut cells: Vec<Vec<u32>> = vec![Vec::new(); CELLS * CELLS];
+ // Triangles reaching behind the eye have no screen bounds to bin by.
+ let mut everywhere: Vec<u32> = Vec::new();
+ for t in 0..tris {
+ let (mut lo, mut hi, mut behind) = ([f32::MAX; 2], [f32::MIN; 2], false);
+ for k in 0..3 {
+ let c = mvp * corner(t, k).extend(1.0);
+ if c.w <= 1e-6 {
+ behind = true;
+ break;
+ }
+ for (axis, v) in [c.x / c.w, c.y / c.w].into_iter().enumerate() {
+ lo[axis] = lo[axis].min(v);
+ hi[axis] = hi[axis].max(v);
+ }
+ }
+ if behind {
+ everywhere.push(t as u32);
+ continue;
+ }
+ if hi[0] < -1.0 || lo[0] > 1.0 || hi[1] < -1.0 || lo[1] > 1.0 {
+ continue;
+ }
+ for y in cell_of(lo[1])..=cell_of(hi[1]) {
+ for x in cell_of(lo[0])..=cell_of(hi[0]) {
+ cells[y * CELLS + x].push(t as u32);
+ }
+ }
+ }
+ let through = 1.0 - opacity;
+ let mut candidates: Vec<u32> = Vec::new();
+ points
+ .iter()
+ .map(|&p| {
+ let p = Vec3::from_array(p);
+ let c = mvp * p.extend(1.0);
+ if c.w <= 1e-6 {
+ return 1.0;
+ }
+ let (x, y) = (c.x / c.w, c.y / c.w);
+ if x.abs() > 1.0 || y.abs() > 1.0 {
+ return 1.0;
+ }
+ candidates.clear();
+ candidates.extend_from_slice(&cells[cell_of(y) * CELLS + cell_of(x)]);
+ if !everywhere.is_empty() {
+ candidates.extend_from_slice(&everywhere);
+ candidates.sort_unstable();
+ }
+ let d = p - eye;
+ let (mut layers, mut nearest) = (0i32, f32::MAX);
+ for &t in &candidates {
+ let t = t as usize;
+ let (v0, v1, v2) = (corner(t, 0), corner(t, 1), corner(t, 2));
+ let (e1, e2) = (v1 - v0, v2 - v0);
+ if !see_through && e1.cross(e2).dot(eye - v0) <= 0.0 {
+ continue;
+ }
+ // Möller–Trumbore with the sight line as the ray, the
+ // tolerance relative to the lengths as in
+ // `spatial::segment_crosses_triangle`.
+ let h = d.cross(e2);
+ let det = e1.dot(h);
+ if det.abs() <= 1e-7 * e1.length() * e2.length() * d.length() {
+ continue;
+ }
+ let f = 1.0 / det;
+ let s = eye - v0;
+ let u = f * s.dot(h);
+ if !(0.0..=1.0).contains(&u) {
+ continue;
+ }
+ let q = s.cross(e1);
+ let v = f * d.dot(q);
+ if v < 0.0 || u + v > 1.0 {
+ continue;
+ }
+ let at = f * e2.dot(q);
+ if at <= 0.0 || at >= 1.0 - 1e-3 {
+ continue;
+ }
+ if see_through {
+ layers += 1;
+ } else if at < nearest {
+ nearest = at;
+ layers += 1;
+ }
+ }
+ through.powi(layers)
+ })
+ .collect()
+}
+
/// The raster pass's light, in WORLD space — `scene3d.wgsl`'s `l`, which the
/// smooth bake below has to match or switching shading modes would move
/// the lit side of the model.
diff --git a/src/main.rs b/src/main.rs
index e750192..74e705c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1653,6 +1653,62 @@ mod tests {
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
+ /// A point number is dimmed by the fill in front of its point, as a
+ /// marker drawn under that fill is: whole on the near side, one layer
+ /// down on the far side of a closed mesh (the faces that meet AT the
+ /// point are not in front of it), gone behind an opaque face. Behind
+ /// the whole mesh the two fills differ — seen through, both walls
+ /// blend; otherwise the far wall is culled and only the near one does.
+ #[test]
+ fn a_point_number_is_dimmed_by_the_fill_in_front_of_it() {
+ use glam::{Mat4, Vec3};
+ let sphere = crate::geometry::sphere_detail(Vec3::ZERO, 1.0, 8, 12);
+ let verts = crate::geometry::detail_vertices(&sphere);
+ let eye = Vec3::new(0.3, 0.2, 5.0);
+ let mvp = Mat4::perspective_rh(0.9, 1.0, 0.1, 100.0) * Mat4::look_at_rh(eye, Vec3::ZERO, Vec3::Y);
+ let positions = sphere.positions();
+ let nearest = |to: Vec3| {
+ *positions
+ .iter()
+ .min_by(|a, b| (Vec3::from_array(**a) - to).length().total_cmp(&(Vec3::from_array(**b) - to).length()))
+ .unwrap()
+ };
+ let near = nearest(Vec3::new(0.0, 0.0, 1.0));
+ let far = nearest(Vec3::new(0.2, 0.3, -1.0));
+ let behind = [0.05, 0.05, -3.0];
+ let points = [near, far, behind];
+ let t = |opacity: f32, see_through: bool| {
+ crate::geometry::point_transmittance(&verts, mvp, eye, &points, opacity, see_through)
+ };
+ let close = |a: &[f32], b: [f32; 3]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5);
+ assert!(close(&t(0.5, true), [1.0, 0.5, 0.25]), "seen through: {:?}", t(0.5, true));
+ assert!(close(&t(0.5, false), [1.0, 0.5, 0.5]), "culled: {:?}", t(0.5, false));
+ assert!(close(&t(1.0, false), [1.0, 0.0, 0.0]), "opaque: {:?}", t(1.0, false));
+ assert!(close(&t(0.0, true), [1.0, 1.0, 1.0]), "invisible fill: {:?}", t(0.0, true));
+
+ // And the paint reads it: a label behind an opaque face is not drawn.
+ let mut state = State::new(false);
+ state.show_point_numbers = true;
+ state.rebuild_scene_geometry();
+ assert!(state.overlay_number_alpha.is_empty(), "a rebuild drops the old eye's answers");
+ let labels = |state: &mut State| {
+ let numbers: std::collections::HashSet<String> =
+ state.overlay_number_labels.iter().map(|(_, i)| i.to_string()).collect();
+ state
+ .collect_display_list()
+ .items
+ .iter()
+ .filter(|item| matches!(&item.prim, cce_ui::scene::paint::Prim::Text { text, .. } if numbers.contains(text)))
+ .count()
+ };
+ state.show_viewport = true;
+ state.last_scene_mvp = Some(Mat4::IDENTITY);
+ state.last_scene_view_rect = (0.0, 0.0, 800.0, 600.0);
+ let whole = labels(&mut state);
+ state.overlay_number_alpha = vec![0.0; state.overlay_number_labels.len()];
+ assert!(labels(&mut state) < whole, "hidden labels are not drawn");
+ }
+
/// Smooth shading bakes the raster pass's own light, so on a PLANE —
/// where every point normal is the face normal — it gives exactly the
/// flat shader's factor at every corner: switching modes changes how
diff --git a/src/render.rs b/src/render.rs
index 968945f..8a9f30c 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -990,7 +990,13 @@ impl State {
return;
}
pc.clip(rect(vx, vy, vw, vh), |pc| {
- for (pos, idx) in &self.overlay_number_labels {
+ for (i, (pos, idx)) in self.overlay_number_labels.iter().enumerate() {
+ // What the fill in front of the point lets through; a
+ // number behind an opaque face is not drawn.
+ let alpha = self.overlay_number_alpha.get(i).copied().unwrap_or(1.0);
+ if alpha < 0.02 {
+ continue;
+ }
let clip_pos = mvp * glam::Vec4::new(pos[0], pos[1], pos[2], 1.0);
if clip_pos.w <= 0.0 {
continue;
@@ -1001,7 +1007,7 @@ impl State {
}
let sx = vx + (ndc.x * 0.5 + 0.5) * vw;
let sy = vy + (0.5 - ndc.y * 0.5) * vh;
- pc.text(idx.to_string(), sx + 4.0, sy - 6.0, 10.0, [0xee, 0xee, 0xff]);
+ pc.text_faded(idx.to_string(), sx + 4.0, sy - 6.0, 10.0, [0xee, 0xee, 0xff], alpha, None, None);
}
});
}
@@ -1203,6 +1209,7 @@ impl State {
Vec::new()
};
self.overlay_number_labels = labels;
+ self.overlay_number_alpha.clear();
self.overlay_normal_verts = normals;
// The wire pass's edges, likewise — topological, and only while the
// wireframe is actually on.