Skip to main content

brep_kernel/geometry/
sphere_chart.rs

1//! Six pole-free coordinate charts for a spherical carrier — the cube atlas.
2//!
3//! A sphere in this kernel is ONE exact rational-NURBS surface of revolution
4//! over a polar `(azimuth, latitude)` domain.  That domain is not a chart in the
5//! differential-geometry sense: it is singular at the two poles (a whole
6//! parameter line collapses to one point, and `Su × Sv` vanishes there) and it
7//! is periodic in `u`, so a region straddling the seam is not a connected set of
8//! parameters at all.  Every trimming, splitting and tessellation decision taken
9//! in that domain inherits both defects, and the kernel carries a long tail of
10//! special cases — pole caps, seam bands, seam walls, unwrapped covers — that
11//! exist only to work around them.
12//!
13//! This module supplies the alternative: an ATLAS of six regular charts, one per
14//! cube face, that between them cover the sphere with no singular point and no
15//! periodic identification.  Chart `k` maps the square `(s, t) ∈ [-1, 1]²` to
16//!
17//! ```text
18//!     p(s, t) = centre + radius · normalize(n_k + s·e_k + t·f_k)
19//! ```
20//!
21//! with `(e_k, f_k, n_k)` a right-handed orthonormal frame — the central
22//! projection of a cube face onto its circumscribed sphere.  The map is a
23//! diffeomorphism on the closed square, its Jacobian never degenerates, and it
24//! is exact: every image point lies on the sphere to the accuracy of one
25//! `normalize`, so NOTHING about the carrier's geometry is approximated by using
26//! it.  The map does contain a square root, so it is deliberately NOT offered as
27//! a NURBS patch — the exact rational sphere surface remains the geometry of
28//! record and the charts are a computational device layered over it.
29//!
30//! # What the atlas is aligned to
31//!
32//! The cube basis is the sphere's own recognition frame, so `basis[2]` is the
33//! polar axis and the two degenerate poles land at the CENTRES of the `±z`
34//! charts.  A former pole is then an ordinary interior point of a regular chart:
35//! a cut through it is no more special than a cut anywhere else.  Because the
36//! basis is read off the surface, two call sites looking at the same surface
37//! always build the same atlas — a prerequisite for the shared-sample rules
38//! below.
39//!
40//! # Sharing, and why it is the whole problem
41//!
42//! Charts are internal.  They must mint no face, no edge, no name, and above all
43//! no crack.  The watertight tessellator's guarantee is that every edge is
44//! sampled ONCE and both adjacent faces consume the same positions; an
45//! artificial chart boundary has to inherit exactly that guarantee.  Three rules
46//! do it, and they are the reason this module owns the sample generation rather
47//! than leaving it to each consumer:
48//!
49//! 1. **A cube edge is subdivided once.**  [`cube_edge_parameters`] returns the
50//!    subdivision of a cube edge in the edge's OWN parameter `q`, and both
51//!    charts that meet along that edge read the same list through
52//!    [`ChartSide::edge`].  Neither chart may add a point of its own.
53//! 2. **A corner belongs to no chart.**  The eight cube corners are named by
54//!    [`corner_index`], and the three charts and three cube edges meeting at one
55//!    all address it by that name, so ownership is never ambiguous.
56//! 3. **A crossing is computed once.**  Where a trim polyline leaves one chart,
57//!    [`arc_chart_crossings`] returns the crossing as a function of the segment
58//!    alone — never of which chart is asking — so the two charts that share it
59//!    get the identical point, and the same value can be pushed back into the
60//!    SHARED edge-sample table so the neighbouring face gets it too.
61//!
62//! # Classification
63//!
64//! [`SphericalRegion`] decides inside/outside for a trimmed spherical face
65//! without reference to any chart, by the signed solid angle each trim loop
66//! subtends at the query point (Van Oosterom & Strackee's formula).  It is
67//! pole-free and seam-free by construction: a slit — the seam traversed once
68//! each way, or a collapsed pole loop — cancels exactly, and needs no special
69//! case.  Trimming and triangulation share this one classifier so they cannot
70//! disagree about where the material is.
71
72use crate::{AnalyticSurface, NurbsSurface, Vec3};
73
74/// Charts in the atlas: one per cube face.
75pub const CHART_COUNT: usize = 6;
76/// Cube edges, each shared by exactly two charts.
77pub const CUBE_EDGE_COUNT: usize = 12;
78/// Cube corners, each shared by exactly three charts and three cube edges.
79pub const CUBE_CORNER_COUNT: usize = 8;
80
81/// How far along a segment a chart crossing must be to count as one, as a
82/// fraction of the segment.  Anything nearer an end is that end.
83const ENDPOINT_LAMBDA: f64 = 1e-9;
84
85/// Relative slack used when asking whether a direction lies in a chart or on a
86/// cube edge.  Chart membership is a comparison of coordinates that are exactly
87/// equal on a boundary, so the slack only has to absorb rounding.
88const CHART_EPSILON: f64 = 1e-12;
89
90/// One cube-face chart: `direction(s, t) = normal + s·tangent_s + t·tangent_t`,
91/// with `tangent_s × tangent_t = normal`, so `∂p/∂s × ∂p/∂t` points OUT of the
92/// sphere everywhere on the chart.
93#[derive(Clone, Copy, Debug)]
94pub struct Chart {
95    pub normal: Vec3,
96    pub tangent_s: Vec3,
97    pub tangent_t: Vec3,
98    /// Basis axis the chart faces along, `0..3`.
99    pub axis: usize,
100    /// `+1` or `-1`: which end of that axis.
101    pub sign: f64,
102}
103
104/// Which side of a chart square a cube edge is: the chart's other coordinate
105/// runs along the edge.
106#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107pub enum ChartSide {
108    /// `s = +1`
109    SPlus,
110    /// `s = -1`
111    SMinus,
112    /// `t = +1`
113    TPlus,
114    /// `t = -1`
115    TMinus,
116}
117
118impl ChartSide {
119    pub const ALL: [ChartSide; 4] = [
120        ChartSide::SPlus,
121        ChartSide::SMinus,
122        ChartSide::TPlus,
123        ChartSide::TMinus,
124    ];
125
126    /// The cube edge this side of chart `chart` lies on, and the sign relating
127    /// the chart's free coordinate `f` to the edge parameter: `q = sign · f`.
128    ///
129    /// This is the single place the two charts meeting along a cube edge agree
130    /// about its identity and its direction, so a subdivision computed once in
131    /// `q` reaches both of them unchanged.
132    pub fn edge(self, chart: usize) -> (usize, f64) {
133        let axis = chart / 2;
134        let sign = if chart % 2 == 0 { 1.0 } else { -1.0 };
135        let b = (axis + 1) % 3;
136        let c = (axis + 2) % 3;
137        match self {
138            // Varying axis c; fixed axes in canonical order (c+1)%3 = axis,
139            // (c+2)%3 = b.
140            ChartSide::SPlus => (cube_edge_index(c, sign, sign), 1.0),
141            ChartSide::SMinus => (cube_edge_index(c, sign, -sign), 1.0),
142            // Varying axis b; fixed axes in canonical order (b+1)%3 = c,
143            // (b+2)%3 = axis.  The chart's s maps to the edge parameter with
144            // the chart's own sign.
145            ChartSide::TPlus => (cube_edge_index(b, 1.0, sign), sign),
146            ChartSide::TMinus => (cube_edge_index(b, -1.0, sign), sign),
147        }
148    }
149
150    /// `(s, t)` of the point at free coordinate `f` on this side.
151    pub fn coords(self, f: f64) -> (f64, f64) {
152        match self {
153            ChartSide::SPlus => (1.0, f),
154            ChartSide::SMinus => (-1.0, f),
155            ChartSide::TPlus => (f, 1.0),
156            ChartSide::TMinus => (f, -1.0),
157        }
158    }
159}
160
161/// Canonical index of the cube edge whose VARYING axis is `varying` and whose
162/// two other axes `(varying+1) % 3` and `(varying+2) % 3` are pinned to the
163/// given signs.  Twelve edges: three varying axes times four sign pairs.
164pub fn cube_edge_index(varying: usize, sign_first: f64, sign_second: f64) -> usize {
165    varying * 4 + usize::from(sign_first > 0.0) * 2 + usize::from(sign_second > 0.0)
166}
167
168/// Inverse of [`cube_edge_index`]: `(varying axis, sign of (v+1)%3, sign of (v+2)%3)`.
169pub fn cube_edge_parts(index: usize) -> (usize, f64, f64) {
170    let varying = index / 4;
171    let rest = index % 4;
172    (
173        varying,
174        if rest & 2 != 0 { 1.0 } else { -1.0 },
175        if rest & 1 != 0 { 1.0 } else { -1.0 },
176    )
177}
178
179/// Canonical index of the cube corner with the given per-axis signs.
180pub fn corner_index(signs: [f64; 3]) -> usize {
181    usize::from(signs[0] > 0.0) | (usize::from(signs[1] > 0.0) << 1) | (usize::from(signs[2] > 0.0) << 2)
182}
183
184/// The corner at the `q = +1` (`positive_end`) or `q = -1` end of a cube edge.
185pub fn cube_edge_corner(edge: usize, positive_end: bool) -> usize {
186    let (varying, first, second) = cube_edge_parts(edge);
187    let mut signs = [0.0; 3];
188    signs[varying] = if positive_end { 1.0 } else { -1.0 };
189    signs[(varying + 1) % 3] = first;
190    signs[(varying + 2) % 3] = second;
191    corner_index(signs)
192}
193
194/// The shared subdivision of EVERY cube edge, in the edge parameter `q ∈ [-1, 1]`.
195///
196/// Uniform in ARC ANGLE rather than in `q`: a cube edge's direction at `q` makes
197/// an angle `atan(q/√2)` with the edge's midpoint, so equal angle steps give
198/// equal chord sag, which is what a chord tolerance actually asks for.  The list
199/// is symmetric, strictly increasing, and pinned to `±1` at the ends (the two
200/// corners), and it is a pure function of `divisions` — which is why both charts
201/// meeting along the edge can compute it independently and still agree
202/// bit-for-bit.
203pub fn cube_edge_parameters(divisions: usize) -> Vec<f64> {
204    let divisions = divisions.max(1);
205    let half = std::f64::consts::SQRT_2.recip().atan();
206    (0..=divisions)
207        .map(|index| {
208            if index == 0 {
209                -1.0
210            } else if index == divisions {
211                1.0
212            } else {
213                let angle = -half + 2.0 * half * index as f64 / divisions as f64;
214                std::f64::consts::SQRT_2 * angle.tan()
215            }
216        })
217        .collect()
218}
219
220/// The interior grid of a chart square, in the chart coordinate `s` (or `t`).
221///
222/// A chart spans 90° each way and its coordinate is the TANGENT of the angle, so
223/// equal angle steps — the thing a chord tolerance actually constrains — are
224/// `tan` of a uniform division, not a uniform division.  Interior points are free
225/// (no other chart reads them), so this list is independent of
226/// [`cube_edge_parameters`]; sizing both from the same angular step is what keeps
227/// the triangles the same size on either side of a chart boundary.
228pub fn chart_grid_parameters(divisions: usize) -> Vec<f64> {
229    let divisions = divisions.max(2);
230    let half = std::f64::consts::FRAC_PI_4;
231    (1..divisions)
232        .map(|index| (-half + 2.0 * half * index as f64 / divisions as f64).tan())
233        .collect()
234}
235
236/// The angular step a chord tolerance allows on a sphere of `radius`: a chord
237/// subtending `θ` sags `radius·(1 − cos(θ/2))`.
238fn angular_step(radius: f64, chord_tolerance: f64) -> Option<f64> {
239    if !(chord_tolerance > 0.0) || !(radius > 0.0) {
240        return None;
241    }
242    let ratio = 1.0 - (chord_tolerance / radius).min(1.0);
243    let step = 2.0 * ratio.clamp(-1.0, 1.0).acos();
244    (step > 0.0 && step.is_finite()).then_some(step)
245}
246
247/// Divisions across a chart square (90°) at `chord_tolerance`.
248///
249/// Sized against the CELL DIAGONAL, not the cell side: a Delaunay triangle over a
250/// square grid has the diagonal as its longest edge, and it is that chord whose
251/// sag the tolerance is about.  Sizing by the side leaves every triangle a factor
252/// of two over tolerance, so the refinement pass fires on all of them and rebuilds
253/// the grid it was meant to correct — three times the triangles and a crop of
254/// slivers for the same accuracy.
255pub fn chart_grid_divisions(radius: f64, chord_tolerance: f64) -> usize {
256    let divisions = match angular_step(radius, chord_tolerance) {
257        Some(step) => (((std::f64::consts::FRAC_PI_2 * std::f64::consts::SQRT_2) / step).ceil()
258            as usize)
259            .clamp(2, 96),
260        None => 16,
261    };
262    // EVEN, so `0` is a grid value and the chart's CENTRE is a mesh vertex.
263    //
264    // The six chart centres are the sphere's extreme points along its own basis —
265    // two of them are the former poles. The polar domain always had a vertex at a
266    // pole, and a mesh that stops short of one is visibly flat there and fails any
267    // check on the solid's extent. At a fine tolerance the shortfall is invisible;
268    // at a coarse one (a 40 mm ball at 1 mm chord: five divisions) the nearest
269    // grid point sits 12.6 degrees off the pole, a whole millimetre short.
270    divisions + divisions % 2
271}
272
273/// Divisions per cube edge that hold the chord sag of a `radius` sphere under
274/// `chord_tolerance`.  A chord subtending `θ` sags `radius·(1 − cos(θ/2))`, so
275/// `θ = 2·acos(1 − tolerance/radius)`; the edge arc is `2·atan(1/√2)` long.
276pub fn cube_edge_divisions(radius: f64, chord_tolerance: f64) -> usize {
277    let arc = 2.0 * std::f64::consts::SQRT_2.recip().atan();
278    match angular_step(radius, chord_tolerance) {
279        // The same diagonal-aware step [`chart_grid_divisions`] uses. A cube edge
280        // is a 1-D chain and would meet the tolerance at the plain step, but a
281        // boundary sampled coarser than the interior it abuts leaves a row of
282        // thin triangles along every chart edge — matching the two steps is what
283        // makes a chart boundary invisible in the mesh as well as in the topology.
284        Some(step) => (((arc * std::f64::consts::SQRT_2) / step).ceil() as usize).clamp(2, 80),
285        None => 14,
286    }
287}
288
289/// A sphere's pole-free cube atlas: centre, radius, and the right-handed basis
290/// the six charts are built on.
291#[derive(Clone, Copy, Debug)]
292pub struct SphereAtlas {
293    pub centre: Vec3,
294    pub radius: f64,
295    /// `basis[2]` is the polar axis, so the two degenerate poles of the stored
296    /// polar domain sit at the centres of charts 4 and 5.
297    pub basis: [Vec3; 3],
298}
299
300impl SphereAtlas {
301    /// The atlas of a surface that IS a sphere, however it is parameterized.
302    ///
303    /// Routed through [`AnalyticSurface::sphere_frame`], so a REFLECTED sphere —
304    /// which recognizes as a general `Revolution`, not as `Sphere` — gets an
305    /// atlas exactly as a direct one does.  Matching on the `Sphere` variant
306    /// here would silently drop every mirrored ball.
307    pub fn of_surface(surface: &NurbsSurface) -> Option<Self> {
308        Self::of_analytic(surface.analytic()?)
309    }
310
311    pub fn of_analytic(analytic: &AnalyticSurface) -> Option<Self> {
312        let (centre, radius, basis) = analytic.sphere_frame()?;
313        (radius > 0.0).then_some(Self {
314            centre,
315            radius,
316            basis,
317        })
318    }
319
320    /// Chart `index`: `axis = index / 2`, facing `+` for even and `-` for odd.
321    ///
322    /// The tangents are picked so `tangent_s × tangent_t = normal` for all six,
323    /// which makes every chart's `∂p/∂s × ∂p/∂t` point out of the sphere — the
324    /// atlas has ONE orientation, so a triangle wound counter-clockwise in any
325    /// chart faces outward in every other.
326    pub fn chart(&self, index: usize) -> Chart {
327        let axis = index / 2;
328        let sign = if index % 2 == 0 { 1.0 } else { -1.0 };
329        let b = (axis + 1) % 3;
330        let c = (axis + 2) % 3;
331        Chart {
332            normal: self.basis[axis].scale(sign),
333            tangent_s: self.basis[b].scale(sign),
334            tangent_t: self.basis[c],
335            axis,
336            sign,
337        }
338    }
339
340    /// Unnormalized chart direction — the point of the cube face itself.
341    pub fn direction(&self, chart: usize, s: f64, t: f64) -> Vec3 {
342        let chart = self.chart(chart);
343        chart
344            .normal
345            .add(chart.tangent_s.scale(s))
346            .add(chart.tangent_t.scale(t))
347    }
348
349    /// The sphere point at chart coordinates `(s, t)`.  Exact: the result lies on
350    /// the sphere to the accuracy of one `normalize`.
351    pub fn point(&self, chart: usize, s: f64, t: f64) -> Result<Vec3, String> {
352        let direction = self.direction(chart, s, t).normalized()?;
353        Ok(self.centre.add(direction.scale(self.radius)))
354    }
355
356    /// Outward unit normal at chart coordinates `(s, t)` — for a sphere the
357    /// normal IS the radial direction, so no derivative is ever needed and there
358    /// is no pole at which one degenerates.
359    pub fn normal_at(&self, chart: usize, s: f64, t: f64) -> Result<Vec3, String> {
360        self.direction(chart, s, t).normalized()
361    }
362
363    /// Whether the surface's OWN `Su x Sv` points out of the sphere, sampled at
364    /// the middle of its parameter domain — for a polar sphere the equator, as
365    /// far from either degenerate pole as the domain allows.
366    ///
367    /// The trim convention (material to the LEFT of the directed boundary) is
368    /// stated against this normal, not against the face's sense, and a reflected
369    /// sphere has it pointing inward — so no consumer may assume it.
370    pub fn parameterization_is_outward(&self, surface: &NurbsSurface) -> Result<bool, String> {
371        let [u0, u1] = surface.domain_u()?;
372        let [v0, v1] = surface.domain_v()?;
373        let (point, du, dv) = surface.deriv1(0.5 * (u0 + u1), 0.5 * (v0 + v1))?;
374        Ok(du.cross(dv).dot(point.sub(self.centre)) > 0.0)
375    }
376
377    /// Basis coordinates of `point` relative to the centre.
378    pub fn axis_coordinates(&self, point: Vec3) -> [f64; 3] {
379        let d = point.sub(self.centre);
380        [
381            d.dot(self.basis[0]),
382            d.dot(self.basis[1]),
383            d.dot(self.basis[2]),
384        ]
385    }
386
387    /// The chart a point belongs to when only one answer is wanted: the chart
388    /// whose face the point projects furthest onto, lowest index breaking a tie.
389    /// Deterministic, so it can be used as a key.
390    pub fn locate(&self, point: Vec3) -> usize {
391        let x = self.axis_coordinates(point);
392        let mut best = 0usize;
393        let mut best_value = f64::NEG_INFINITY;
394        for chart in 0..CHART_COUNT {
395            let axis = chart / 2;
396            let sign = if chart % 2 == 0 { 1.0 } else { -1.0 };
397            let value = sign * x[axis];
398            if value > best_value {
399                best_value = value;
400                best = chart;
401            }
402        }
403        best
404    }
405
406    /// Chart coordinates of `point` in `chart`, or `None` when the point is on
407    /// the far side of the sphere from it.  A point outside the chart's square
408    /// still returns coordinates (with `|s| > 1` or `|t| > 1`); use
409    /// [`Self::contains`] to ask about membership.
410    pub fn coordinates(&self, chart: usize, point: Vec3) -> Option<(f64, f64)> {
411        let x = self.axis_coordinates(point);
412        let chart = self.chart(chart);
413        let b = (chart.axis + 1) % 3;
414        let c = (chart.axis + 2) % 3;
415        let w = chart.sign * x[chart.axis];
416        if !(w > 0.0) {
417            return None;
418        }
419        Some((chart.sign * x[b] / w, x[c] / w))
420    }
421
422    /// Whether `point` lies in `chart`'s closed square, within a relative slack.
423    pub fn contains(&self, chart: usize, point: Vec3) -> bool {
424        match self.coordinates(chart, point) {
425            Some((s, t)) => {
426                let slack = 1.0 + CHART_EPSILON;
427                s.abs() <= slack && t.abs() <= slack
428            }
429            None => false,
430        }
431    }
432
433    /// Every chart whose closed square contains `point`: one in a chart
434    /// interior, two on a cube edge, three at a corner.  Ascending index, so the
435    /// answer is order-independent.
436    pub fn charts_containing(&self, point: Vec3) -> Vec<usize> {
437        (0..CHART_COUNT)
438            .filter(|chart| self.contains(*chart, point))
439            .collect()
440    }
441
442    /// The cube edge `point` lies on, with its edge parameter `q`, or `None`
443    /// when the point is in a chart interior.
444    ///
445    /// A point sits on a cube edge exactly when the two LARGEST of its three
446    /// basis coordinates are equal in magnitude; `q` is the third coordinate
447    /// scaled so those two are `±1`.  Returning `q` — rather than either chart's
448    /// own coordinate — is what lets a crossing be filed against the edge once
449    /// and read back by both charts.
450    pub fn edge_of(&self, point: Vec3, relative_tolerance: f64) -> Option<(usize, f64)> {
451        self.edges_containing(point, relative_tolerance)
452            .into_iter()
453            .next()
454    }
455
456    /// EVERY cube edge `point` lies on: one along an edge, three at a corner,
457    /// none in a chart interior.
458    ///
459    /// Callers that have to decide whether two points share an edge must use
460    /// this rather than [`Self::edge_of`]: a corner lies on three edges at once,
461    /// and picking one of them canonically would make a segment running INTO a
462    /// corner look as if it left the edge it is on.
463    pub fn edges_containing(&self, point: Vec3, relative_tolerance: f64) -> Vec<(usize, f64)> {
464        let x = self.axis_coordinates(point);
465        let scale = x[0].abs().max(x[1].abs()).max(x[2].abs());
466        if !(scale > 0.0) {
467            return Vec::new();
468        }
469        let tolerance = relative_tolerance.max(CHART_EPSILON) * scale;
470        let mut found = Vec::new();
471        // The varying axis is the one whose magnitude is NOT tied for largest.
472        for varying in 0..3 {
473            let b = (varying + 1) % 3;
474            let c = (varying + 2) % 3;
475            let pinned = x[b].abs().min(x[c].abs());
476            if (x[b].abs() - x[c].abs()).abs() <= tolerance
477                && pinned >= x[varying].abs() - tolerance
478                && pinned > 0.0
479            {
480                let magnitude = 0.5 * (x[b].abs() + x[c].abs());
481                found.push((
482                    cube_edge_index(varying, x[b], x[c]),
483                    (x[varying] / magnitude).clamp(-1.0, 1.0),
484                ));
485            }
486        }
487        found
488    }
489
490    /// The sphere point on cube edge `edge` at edge parameter `q`.
491    pub fn edge_point(&self, edge: usize, q: f64) -> Result<Vec3, String> {
492        let (varying, first, second) = cube_edge_parts(edge);
493        let direction = self.basis[varying]
494            .scale(q)
495            .add(self.basis[(varying + 1) % 3].scale(first))
496            .add(self.basis[(varying + 2) % 3].scale(second))
497            .normalized()?;
498        Ok(self.centre.add(direction.scale(self.radius)))
499    }
500
501    /// The parameters `λ ∈ (0, 1)` at which the great-circle arc from `start` to
502    /// `end` crosses a chart boundary, ascending.
503    ///
504    /// A chart boundary is one of the six planes `|x_i| = |x_j|` through the
505    /// centre, so the crossing is the root of a function that is LINEAR along
506    /// the chord — no Newton iteration, no seeding, and nothing that could
507    /// converge onto an extrapolated surface.  `λ` interpolates the chord
508    /// `(1−λ)·d_start + λ·d_end`; the crossing point is that direction
509    /// normalized back onto the sphere, which is exactly where the arc meets the
510    /// plane.
511    ///
512    /// The result depends only on the two endpoints, never on which chart is
513    /// asking, so both charts sharing the crossing derive the identical point.
514    /// A plane crossed AWAY from the cube edge it carries (the other coordinate
515    /// is larger there) is not a chart boundary at that point and is dropped.
516    pub fn arc_chart_crossings(&self, start: Vec3, end: Vec3) -> Vec<f64> {
517        let a = self.axis_coordinates(start);
518        let b = self.axis_coordinates(end);
519        let scale = (0..3)
520            .map(|k| a[k].abs().max(b[k].abs()))
521            .fold(0.0, f64::max);
522        if !(scale > 0.0) {
523            return Vec::new();
524        }
525        // A segment that LIES IN one of the six planes evaluates that plane's
526        // function as rounding noise, and the sign of noise flips at random. Every
527        // flip brackets a "root", and each one splits the segment at a point no
528        // neighbouring face holds — a T-junction, on the very trim that runs along
529        // a chart boundary. A crossing has to be transversal, so a bracket counts
530        // only when the function is meaningfully non-zero at an end.
531        let significant = 1e-9 * scale;
532        let mut crossings = Vec::new();
533        for (i, j) in [(0usize, 1usize), (1, 2), (2, 0)] {
534            for combination in [1.0f64, -1.0] {
535                let g0 = a[i] - combination * a[j];
536                let g1 = b[i] - combination * b[j];
537                if (g0 > 0.0) == (g1 > 0.0) || g0 == g1 {
538                    continue;
539                }
540                if g0.abs().max(g1.abs()) <= significant {
541                    continue;
542                }
543                let lambda = g0 / (g0 - g1);
544                // A crossing AT an endpoint is not a crossing: the endpoint is
545                // already a vertex, and splitting there mints a second one a few
546                // ulps away that no neighbouring face holds. This is the common
547                // case, not a corner one — `sample_all_edges` has already put a
548                // sample on the boundary, so the segments either side of it each
549                // report a root at their shared end.
550                if !(lambda > ENDPOINT_LAMBDA && lambda < 1.0 - ENDPOINT_LAMBDA) {
551                    continue;
552                }
553                let at = |k: usize| a[k] + (b[k] - a[k]) * lambda;
554                let tied = at(i).abs().max(at(j).abs());
555                let third = at(3 - i - j).abs();
556                // Only a crossing where the tied pair is the LARGEST pair is on
557                // a cube edge; elsewhere the plane runs through a chart's
558                // interior and means nothing.
559                if tied >= third - CHART_EPSILON * scale && tied > 0.0 {
560                    crossings.push(lambda);
561                }
562            }
563        }
564        crossings.sort_by(f64::total_cmp);
565        crossings.dedup_by(|x, y| (*x - *y).abs() <= 1e-12);
566        crossings
567    }
568
569    /// Split a closed polyline on the sphere so that no segment crosses a chart
570    /// boundary, returning `(point, chart)` for every segment: the vertices of
571    /// the segment and the single chart it lies in.
572    ///
573    /// Crossing points are inserted at the exact positions
574    /// [`Self::arc_chart_crossings`] reports, so a vertex on a cube edge belongs
575    /// to both adjacent charts with identical coordinates.
576    pub fn split_polyline(&self, points: &[Vec3]) -> Result<Vec<ChartSegment>, String> {
577        let mut segments = Vec::new();
578        for index in 0..points.len() {
579            segments.extend(self.split_segment(points[index], points[(index + 1) % points.len()])?);
580        }
581        Ok(segments)
582    }
583
584    /// [`Self::split_polyline`] for ONE segment.  A caller holding individual
585    /// boundary segments must use this: handing a two-point slice to the polyline
586    /// form would close it and emit the segment twice, once each way.
587    pub fn split_segment(&self, start: Vec3, end: Vec3) -> Result<Vec<ChartSegment>, String> {
588        let mut segments = Vec::new();
589        {
590            if start.sub(end).length() <= 0.0 {
591                return Ok(segments);
592            }
593            let crossings = self.arc_chart_crossings(start, end);
594            let da = self.axis_coordinates(start);
595            let db = self.axis_coordinates(end);
596            let mut cursor = start;
597            let mut previous = 0.0f64;
598            for lambda in crossings.iter().copied().chain(std::iter::once(1.0)) {
599                let next = if lambda >= 1.0 {
600                    end
601                } else {
602                    let direction = self.basis[0]
603                        .scale(da[0] + (db[0] - da[0]) * lambda)
604                        .add(self.basis[1].scale(da[1] + (db[1] - da[1]) * lambda))
605                        .add(self.basis[2].scale(da[2] + (db[2] - da[2]) * lambda))
606                        .normalized()?;
607                    self.centre.add(direction.scale(self.radius))
608                };
609                let final_piece = lambda >= 1.0;
610                // A crossing that landed on the far endpoint adds nothing but a
611                // duplicate vertex; the final piece reaches that endpoint anyway.
612                let distinct = next.sub(cursor).length() > 0.0
613                    && (final_piece || next.sub(end).length() > 0.0);
614                if distinct {
615                    let middle = 0.5 * (previous + lambda);
616                    let probe = self.basis[0]
617                        .scale(da[0] + (db[0] - da[0]) * middle)
618                        .add(self.basis[1].scale(da[1] + (db[1] - da[1]) * middle))
619                        .add(self.basis[2].scale(da[2] + (db[2] - da[2]) * middle));
620                    segments.push(ChartSegment {
621                        start: cursor,
622                        end: next,
623                        chart: self.locate(self.centre.add(probe)),
624                    });
625                }
626                cursor = next;
627                previous = lambda;
628            }
629        }
630        Ok(segments)
631    }
632}
633
634/// One piece of a trim polyline, already confined to a single chart.
635#[derive(Clone, Copy, Debug)]
636pub struct ChartSegment {
637    pub start: Vec3,
638    pub end: Vec3,
639    pub chart: usize,
640}
641
642/// Whether the minor great-circle arcs `a0→a1` and `b0→b1` cross, for unit
643/// directions.
644///
645/// The two arcs' plane normals are NORMALIZED before they are crossed, so the
646/// magnitude of the result is the sine of the angle between the planes — a
647/// meaningful quantity with a meaningful threshold. Crossing the raw normals
648/// instead makes that magnitude scale with both arcs' lengths, so a short probe
649/// against a short boundary sample yields a direction that is mostly rounding
650/// noise, and the crossing decision becomes a coin toss. Parity cannot survive
651/// that: one miscounted crossing inverts the answer for a whole region.
652///
653/// The arcs are half-open at their end, so a crossing exactly at a shared vertex
654/// of a polyline is counted once rather than twice.
655///
656/// Both normals arrive precomputed: `na` once per LEG (it depends only on the
657/// path, not on the arc it is tested against) and `nb` once per REGION (it is a
658/// property of the boundary arc). A trim query counts crossings over every arc of
659/// the region six times over, so computing either here put two square roots per
660/// arc per query in the innermost loop of the boolean.
661fn arcs_cross(a0: Vec3, a1: Vec3, na: Vec3, arc: &Arc) -> bool {
662    let (b0, b1, nb) = (arc.start, arc.end, arc.normal);
663    if nb.dot(nb) <= 0.0 {
664        // Ends that name no plane — antipodal or coincident. Not a crossing.
665        return false;
666    }
667    // Straddle test first, and it settles most arcs: any crossing point lies on
668    // BOTH great circles, so it satisfies `na · p == 0`. An arc whose two ends are
669    // strictly on one side of the leg's plane contains no such point, and the same
670    // holds for the leg against the arc's plane. Equality is left to the full test
671    // below — an end exactly ON the other plane is a crossing the half-open
672    // convention still has to adjudicate.
673    let straddles = |n: Vec3, x0: Vec3, x1: Vec3| {
674        let (d0, d1) = (n.dot(x0), n.dot(x1));
675        !((d0 > 0.0 && d1 > 0.0) || (d0 < 0.0 && d1 < 0.0))
676    };
677    if !straddles(na, b0, b1) || !straddles(nb, a0, a1) {
678        return false;
679    }
680    let line = na.cross(nb);
681    if line.length() <= 1e-9 {
682        // The two great circles coincide, or meet at too shallow an angle to
683        // locate: an overlap is not a transversal crossing and must not flip
684        // parity.
685        return false;
686    }
687    let Ok(unit) = line.normalized() else {
688        return false;
689    };
690    let within = |p: Vec3, x0: Vec3, x1: Vec3, n: Vec3| -> bool {
691        x0.cross(p).dot(n) >= 0.0 && p.cross(x1).dot(n) > 0.0
692    };
693    [unit, unit.scale(-1.0)]
694        .into_iter()
695        .any(|p| within(p, a0, a1, na) && within(p, b0, b1, nb))
696}
697
698/// One directed boundary arc, with the great-circle plane it lies in.
699#[derive(Clone, Copy, Debug)]
700struct Arc {
701    start: Vec3,
702    end: Vec3,
703    /// The unit normal of `start × end`, or zero when the ends name no plane.
704    /// Precomputed: every crossing count in every query walks every arc.
705    normal: Vec3,
706}
707
708/// The trim of a spherical face, as directed boundary arcs on the unit sphere,
709/// ready to classify points without touching any chart or parameter domain.
710///
711/// Containment is decided by CROSSING PARITY against a seed the region carries:
712/// one point placed just to the material side of one boundary arc.  The parity of
713/// the crossings between the seed and a query decides the query, and both the
714/// seed and the test are ordinary great-circle geometry — no parameter domain, no
715/// pole, no seam, and no reliance on a signed-area formula whose branch collapses
716/// when the query sits opposite a small loop (which is exactly the near-tangent
717/// pocket, and exactly where it was wrong).
718///
719/// The seed encodes the BREP convention in the one place it belongs: material
720/// lies to the LEFT of the directed boundary as seen from the FACE normal — the
721/// face's own normal, which for a cavity wall points into the sphere, not out of
722/// it.
723#[derive(Clone, Debug, Default)]
724pub struct SphericalRegion {
725    /// Directed boundary arcs as unit directions from the sphere centre, with
726    /// slits already cancelled.
727    arcs: Vec<Arc>,
728    /// A unit direction known to be material, or `None` when there is no
729    /// boundary at all (the whole sphere) or none usable.
730    seed: Option<Vec3>,
731}
732
733impl SphericalRegion {
734    /// Build the region from the face's trim loops given as closed 3D polylines.
735    ///
736    /// `outward_face_normal` says whether the FACE's normal points out of the
737    /// sphere — `same_sense == parameterization_is_outward`, never `same_sense`
738    /// alone and never the surface normal alone.
739    pub fn new(centre: Vec3, polylines: &[Vec<Vec3>], outward_face_normal: bool) -> Self {
740        let mut segments = Vec::new();
741        for polyline in polylines {
742            for index in 0..polyline.len() {
743                segments.push((polyline[index], polyline[(index + 1) % polyline.len()]));
744            }
745        }
746        Self::from_segments(centre, &segments, outward_face_normal)
747    }
748
749    /// As [`Self::new`], from the directed boundary segments themselves.
750    ///
751    /// A consumer that has one loop joining TWO rims through the seam — a ball
752    /// drilled through — MUST use this: concatenating that loop's surviving
753    /// coedges and closing the ring bridges rim to rim with two chords that are
754    /// not reverses of each other, and the region that comes out is not the one
755    /// the face means.
756    pub fn from_segments(centre: Vec3, segments: &[(Vec3, Vec3)], outward_face_normal: bool) -> Self {
757        use std::collections::HashMap;
758        type Key = [u64; 3];
759        let key = |p: Vec3| -> Key { [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()] };
760        let mut counts: HashMap<(Key, Key), usize> = HashMap::new();
761        let mut directed: Vec<(Vec3, Vec3)> = Vec::new();
762        for &(a, b) in segments {
763            if key(a) == key(b) {
764                // A collapsed pole loop: every sample is the same point.
765                continue;
766            }
767            *counts.entry((key(a), key(b))).or_insert(0) += 1;
768            directed.push((a, b));
769        }
770        // Cancel each directed segment against a matching reverse. The seam of a
771        // trimmed ball is used once each way by the SAME face, so both uses go;
772        // what is left is the material boundary and nothing else.
773        let mut budget: HashMap<(Key, Key), usize> = HashMap::new();
774        for (&(from, to), &forward) in &counts {
775            let backward = counts.get(&(to, from)).copied().unwrap_or(0);
776            budget.insert((from, to), forward.saturating_sub(backward.min(forward)));
777        }
778        let unit = |p: Vec3| p.sub(centre).normalized().ok();
779        let mut arcs: Vec<Arc> = Vec::new();
780        for (a, b) in directed {
781            let entry = budget.entry((key(a), key(b))).or_insert(0);
782            if *entry == 0 {
783                continue;
784            }
785            *entry -= 1;
786            if let (Some(a), Some(b)) = (unit(a), unit(b)) {
787                if a.sub(b).length() > 0.0 {
788                    arcs.push(Arc {
789                        start: a,
790                        end: b,
791                        normal: a.cross(b).normalized().unwrap_or_default(),
792                    });
793                }
794            }
795        }
796        let seed = Self::seed_from(&arcs, outward_face_normal);
797        Self { arcs, seed }
798    }
799
800    /// A unit direction just to the MATERIAL side of the longest boundary arc.
801    ///
802    /// The offset is bisected until stepping the same distance to the other side
803    /// crosses the boundary exactly once: that is the proof the seed cleared the
804    /// arc it came from and reached no further boundary, so it is inside the
805    /// material and not merely near it. A region thinner than the first offset is
806    /// found by the bisection rather than mis-seeded.
807    fn seed_from(arcs: &[Arc], outward_face_normal: bool) -> Option<Vec3> {
808        let longest = arcs.iter().copied().max_by(|x, y| {
809            x.start
810                .sub(x.end)
811                .length()
812                .total_cmp(&y.start.sub(y.end).length())
813        })?;
814        let (a, b) = (longest.start, longest.end);
815        let middle = a.add(b).normalized().ok()?;
816        let along = b.sub(a);
817        let tangent = along.sub(middle.scale(along.dot(middle)));
818        let normal = if outward_face_normal {
819            middle
820        } else {
821            middle.scale(-1.0)
822        };
823        let left = normal.cross(tangent).normalized().ok()?;
824        // Start well inside the arc's own sampling step, so the common case
825        // settles on the first try: this runs once per region build and a region
826        // is built once per trim query, in the innermost loop of the boolean.
827        let mut step = 0.1 * a.sub(b).length().max(1e-9);
828        for _ in 0..24 {
829            let inside = middle.add(left.scale(step)).normalized().ok()?;
830            let outside = middle.sub(left.scale(step)).normalized().ok()?;
831            if Self::crossings(arcs, inside, outside) == 1 {
832                return Some(inside);
833            }
834            step *= 0.5;
835        }
836        None
837    }
838
839    fn crossings(arcs: &[Arc], from: Vec3, to: Vec3) -> usize {
840        // The leg's own plane, once for the whole walk: a leg that names no plane
841        // crosses nothing, which is what testing each arc against it used to
842        // conclude one arc at a time.
843        let Ok(leg) = from.cross(to).normalized() else {
844            return 0;
845        };
846        arcs.iter()
847            .filter(|arc| arcs_cross(from, to, leg, arc))
848            .count()
849    }
850
851    /// Crossing parity along a TWO-LEG path `from → via → to`.
852    ///
853    /// One straight leg is not enough. Two nearly antipodal endpoints do not name
854    /// a great circle at all — their cross product is noise — and a single leg
855    /// that grazes a boundary vertex can count a crossing twice or not at all.
856    /// Routing through a via point that is far from both endpoints makes each leg
857    /// well conditioned, and taking three different via points and a majority
858    /// makes a single grazed vertex harmless.
859    fn parity(arcs: &[Arc], from: Vec3, to: Vec3) -> bool {
860        let mut even = 0usize;
861        let mut odd = 0usize;
862        for index in 0..3 {
863            let Some(via) = Self::via_point(from, to, index) else {
864                continue;
865            };
866            let count = Self::crossings(arcs, from, via) + Self::crossings(arcs, via, to);
867            if count % 2 == 0 {
868                even += 1;
869            } else {
870                odd += 1;
871            }
872        }
873        even >= odd
874    }
875
876    /// A unit direction well away from both endpoints, deterministic in `index`.
877    fn via_point(from: Vec3, to: Vec3, index: usize) -> Option<Vec3> {
878        let axis = from.cross(to);
879        let base = match axis.normalized() {
880            Ok(unit) => unit,
881            // Antipodal or coincident: any direction off the pair will do.
882            Err(_) => from.perpendicular().ok()?,
883        };
884        let other = base.cross(from).normalized().ok()?;
885        let angle = std::f64::consts::TAU * index as f64 / 3.0;
886        base.scale(angle.cos())
887            .add(other.scale(angle.sin()))
888            .normalized()
889            .ok()
890    }
891
892    /// Whether the face has no material boundary at all — an untrimmed ball,
893    /// whose only "loops" were the seam slit and the two collapsed poles.
894    pub fn is_whole_sphere(&self) -> bool {
895        self.arcs.is_empty()
896    }
897
898    /// Whether this region can answer at all: either it has no boundary, or the
899    /// seed search cleared one.
900    ///
901    /// A region with arcs but no seed must make its CALLER decline — answering
902    /// anyway would report every point material, which in the mesh is silent
903    /// double coverage and in the trim query is a face that swallowed the ball.
904    /// A classifier that cannot decide has to say so.
905    pub fn is_decidable(&self) -> bool {
906        self.arcs.is_empty() || self.seed.is_some()
907    }
908
909    /// Whether the sphere point `point` is material.  Pole-free and seam-free:
910    /// the query never enters a parameter domain.
911    pub fn contains(&self, centre: Vec3, point: Vec3) -> bool {
912        let Ok(probe) = point.sub(centre).normalized() else {
913            return false;
914        };
915        let Some(seed) = self.seed else {
916            // No boundary at all: the face is the whole ball. A region that HAS a
917            // boundary but no seed is not decidable and the caller must have
918            // declined already ([`Self::is_decidable`]).
919            return true;
920        };
921        Self::parity(&self.arcs, seed, probe)
922    }
923
924    /// How many boundary arcs separate `point` from the region's seed.  A caller
925    /// letting one probe speak for a whole flood region prefers the probe whose
926    /// count is smallest — it is the one furthest from a grazing arc.
927    pub fn separation(&self, centre: Vec3, point: Vec3) -> usize {
928        let Ok(probe) = point.sub(centre).normalized() else {
929            return usize::MAX;
930        };
931        match self.seed {
932            Some(seed) => Self::via_point(seed, probe, 0)
933                .map(|via| {
934                    Self::crossings(&self.arcs, seed, via) + Self::crossings(&self.arcs, via, probe)
935                })
936                .unwrap_or(usize::MAX),
937            None => 0,
938        }
939    }
940}
941
942/// Collapse points that are the same point to ONE value.
943///
944/// Trim polylines assembled from per-coedge evaluations name a shared vertex
945/// twice — once from each side — and the two evaluations agree only to rounding.
946/// [`SphericalRegion`] cancels a slit by matching a segment against its exact
947/// reverse, which those two nearly-equal values would defeat, so a consumer that
948/// did not read its points from one shared table runs them through here first.
949/// The tolerance is relative to the point cloud's own extent.
950pub fn canonicalize_points(points: &mut [Vec3], relative_tolerance: f64) {
951    let scale = points
952        .iter()
953        .map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs()))
954        .fold(0.0, f64::max)
955        .max(1.0);
956    let cell = (relative_tolerance * scale).max(f64::MIN_POSITIVE);
957    let mut table: std::collections::HashMap<[i64; 3], Vec3> = std::collections::HashMap::new();
958    for point in points.iter_mut() {
959        let base = [
960            (point.x / cell).round() as i64,
961            (point.y / cell).round() as i64,
962            (point.z / cell).round() as i64,
963        ];
964        let mut found = None;
965        'search: for dx in -1..=1 {
966            for dy in -1..=1 {
967                for dz in -1..=1 {
968                    let probe = [base[0] + dx, base[1] + dy, base[2] + dz];
969                    if let Some(&existing) = table.get(&probe) {
970                        if existing.sub(*point).length() <= cell {
971                            found = Some(existing);
972                            break 'search;
973                        }
974                    }
975                }
976            }
977        }
978        match found {
979            Some(existing) => *point = existing,
980            None => {
981                table.insert(base, *point);
982            }
983        }
984    }
985}
986
987// BREP private tests: 6b6f1c0e2ab4c7d1