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

commit620b6adf2b0eceb52dc4df8e65c8510673c3e768
parente825355160
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 16:24
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.