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

commita44a49ea39fb141d912ce5d3792966aef4d1d50a
parentd0b7a63998
authorLucas Galante <[email protected]>
date2026-09-24 14:54
fix: point markers wind outward, so the fill's back-face cull no longer hides them

points_vertices kept the retired soup's inward winding: with back-face
culling on, only the INSIDE of each marker's far half drew, so a group
marker (and a Render-node point, and a Point Markers overlay marker) on a
surface was visible only where that far half poked out of the mesh, and
vanished from the views where it did not. Reverse each facet to the
sphere_detail convention; point_markers_wind_outward holds the sign by the
signed volume.

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

 src/geometry.rs | 10 +++++++++-
 src/main.rs     | 40 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 49 insertions(+), 1 deletion(-)

diff --git a/src/geometry.rs b/src/geometry.rs
index fbe1b3d..09e13ee 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -5463,7 +5463,15 @@ pub fn points_vertices(src: &[Vertex3D], size: f32, color: [f32; 3]) -> Vec<Vert
                 let p10 = sp(theta1, phi0);
                 let p11 = sp(theta1, phi1);
                 let p01 = sp(theta0, phi1);
-                for p in [p00, p10, p11, p00, p11, p01] {
+                // Counter-clockwise seen from OUTSIDE, as `sphere_detail`
+                // winds and as the raster fill's back-face cull expects.
+                // This kept the retired soup's inward order until
+                // 2026-09-24, so the cull drew the INSIDE of each marker's
+                // far half and nothing of its near half — and a marker on a
+                // surface showed only where that far half poked out of the
+                // mesh, vanishing from the views where it did not.
+                // `point_markers_wind_outward` holds the sign.
+                for p in [p00, p11, p10, p00, p01, p11] {
                     out.push(Vertex3D { position: p, color });
                 }
             }
diff --git a/src/main.rs b/src/main.rs
index e74f372..b89e3b7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2343,6 +2343,46 @@ mod tests {
         assert_eq!(markers.len() % 240, 0);
     }
 
+    /// A marker sphere winds counter-clockwise seen from OUTSIDE — the
+    /// raster fill's culling convention, and what `sphere_detail` does.
+    /// Until 2026-09-24 `points_vertices` kept the retired soup's inward
+    /// winding, so the cull threw away the near half of every marker and
+    /// drew the inside of the far half instead: a marker on a surface was
+    /// visible only where that far half poked out of the mesh, which is
+    /// what "the group marker disappears when I look up at it from below"
+    /// was. The signed volume by the divergence theorem is the test — it
+    /// is positive exactly when every facet faces outward.
+    #[test]
+    fn point_markers_wind_outward() {
+        let centre = [0.3f32, -0.2, 0.7];
+        let src = [crate::geometry::Vertex3D { position: centre, color: [0.0; 3] }];
+        let r = 0.1f32;
+        let markers = crate::geometry::points_vertices(&src, r, [1.0; 3]);
+        assert_eq!(markers.len() % 3, 0);
+        let c = glam::Vec3::from_array(centre);
+        let mut volume = 0.0f32;
+        let mut inward_facets = 0;
+        for tri in markers.chunks(3) {
+            let a = glam::Vec3::from_array(tri[0].position) - c;
+            let b = glam::Vec3::from_array(tri[1].position) - c;
+            let d = glam::Vec3::from_array(tri[2].position) - c;
+            volume += a.dot(b.cross(d)) / 6.0;
+            // Every facet's plain cross product must point away from the centre.
+            let n = (b - a).cross(d - a);
+            if n.dot(a + b + d) < 0.0 {
+                inward_facets += 1;
+            }
+        }
+        let expected = 4.0 / 3.0 * std::f32::consts::PI * r.powi(3);
+        assert!(volume > 0.0, "marker winds inward: signed volume {volume}");
+        // An inscribed 4x10 polyhedron holds about 80% of the true sphere.
+        assert!(
+            volume > expected * 0.6 && volume < expected,
+            "signed volume {volume} is not a sphere of radius {r} ({expected})"
+        );
+        assert_eq!(inward_facets, 0, "{inward_facets} facets face into the marker");
+    }
+
     /// The Attribute node's three operations over a sphere: Create tags every
     /// vertex, Modify combines into existing tags (and reaches the Pos/Col
     /// built-ins), Delete removes them, a Group name restricts the edit to