Skip to main content

brep_kernel/brep/classification/
face_containment.rs

1use crate::curve::interior_knot_count;
2use super::*;
3
4/// How many chords the containment lanes sample ONE pcurve at:
5/// `max(2, (k + 1)(p + 1)·4)`, `k` the pcurve's distinct interior knots (two
6/// knots are one when they differ by no more than
7/// [`KNOT_DEDUP_EPS`](crate::KNOT_DEDUP_EPS), and a knot that close to an end
8/// of the active domain is not interior) and `p` its degree. Knot- and
9/// degree-aware, so a spline trim is resolved wherever its shape can bend and a
10/// line is two chords.
11///
12/// Nothing compares the count; it IS the polygon [`parameter_point_in_face`]
13/// classifies against. The generic scan, the wrapped-horizon, sphere-cap and
14/// covering-rim-strip lanes sample every pcurve at
15/// [`trim_station`]`(start, end, i, count)` for `i` in `0..count` (the rim
16/// strip `0..=count`), the pcurve's domain being `[start, end]`.
17///
18/// Published rather than copied: [`crate::loop_self_crossings`] and the
19/// soundness detectors test EXACTLY these chords, or the kernel could hold that
20/// a loop does not cross itself while classifying points against a polygon
21/// that does; and a caller that answers containment questions ahead of the
22/// kernel (the sheets' hidden-line pass) must build its polygon from the same
23/// stations, or it stops answering as the kernel does.
24pub fn trim_sample_count(curve: &NurbsCurve) -> usize {
25    2usize.max((interior_knot_count(&curve.knots, curve.degree) + 1) * (curve.degree + 1) * 4)
26}
27
28/// The pcurve parameter of trim station `index` of `count` over `[start, end]`:
29/// `start + (end − start)·index / count`, evaluated in exactly that order.
30///
31/// The comparand is bit identity with the stations the lanes sampled: the
32/// prepared-face cache, the self-crossing detector and any caller mirroring a
33/// lane rely on the same floats. At `index == count` this is NOT `end` to the
34/// bit; the generic scan closes a pcurve's last chord at `end` itself.
35pub fn trim_station(start: f64, end: f64, index: usize, count: usize) -> f64 {
36    start + (end - start) * index as f64 / count as f64
37}
38
39/// `BREP_SEAM_BAND_MERGED`: set to `"0"` (the only value compared), the
40/// doubly-periodic seam-band lane stops unwrapping a single merged seam-carrying
41/// loop, and that face goes on to the two-loop analyses or the generic scan.
42/// Read when a face is prepared; part of the prepared-face cache key.
43pub const SEAM_BAND_MERGED_SWITCH: &str = "BREP_SEAM_BAND_MERGED";
44
45/// `BREP_HORIZON_CONTAINMENT`: set to `"0"`, the wrapped-horizon lane declines
46/// every face. Read when a face is prepared; part of the cache key.
47pub const HORIZON_CONTAINMENT_SWITCH: &str = "BREP_HORIZON_CONTAINMENT";
48
49/// `BREP_HORIZON_SINGLE_LOOP`: set to `"0"`, the wrapped-horizon lane declines a
50/// face of one or two loops (compared: `face.loops.len() <= 2`). Read when a
51/// face is prepared; part of the cache key.
52pub const HORIZON_SINGLE_LOOP_SWITCH: &str = "BREP_HORIZON_SINGLE_LOOP";
53
54/// `BREP_HORIZON_CROSS_FRAME`: set to `"0"`, the wrapped-horizon lane combines
55/// its period images per image (Inside when any image counts odd) instead of
56/// summing each loop's parity over the images. Read per QUERY, not when the
57/// face is prepared; see [`horizon_cross_frame`].
58pub const HORIZON_CROSS_FRAME_SWITCH: &str = "BREP_HORIZON_CROSS_FRAME";
59
60/// `BREP_COVERING_RIM_STRIP`: set to `"0"`, the covering-rim-strip lane declines
61/// every face. Read when a face is prepared; part of the cache key.
62pub const COVERING_RIM_STRIP_SWITCH: &str = "BREP_COVERING_RIM_STRIP";
63
64/// `BREP_NO_SPHERE_CHART_TRIM`: SET to any value (compared: present in the
65/// environment), the sphere-chart lane stops substituting for the generic scan's
66/// parity count. `BREP_NO_SPHERE_CHARTS`, which turns every sphere-chart path
67/// off, does the same here. Read per query.
68pub const NO_SPHERE_CHART_TRIM_SWITCH: &str = "BREP_NO_SPHERE_CHART_TRIM";
69
70// The lane switches below are `"0"`-style: each is off when its environment
71// value is exactly `"0"` (`switch!(.., true)`), read through its call
72// site's once-cell while `BREP_ENV_READ_ONCE` is on. They were read once
73// under `BREP_CONTAINMENT_SWITCHES_ONCE` (lane O row 18) until S1 folded that
74// table into `switch!`; only variables set before a process starts go
75// through it, so `NO_SPHERE_CHART_TRIM_SWITCH` and the debug flags stay live.
76
77
78/// Whether the wrapped-horizon lane sums each loop's parity over the query's
79/// period images (`true`, the default) or keeps the per-image combining
80/// (`false`, [`HORIZON_CROSS_FRAME_SWITCH`] = `"0"`). Read from the environment
81/// on every call, as the lane reads it on every query.
82pub fn horizon_cross_frame() -> bool {
83    switch!(HORIZON_CROSS_FRAME_SWITCH, true)
84}
85
86/// Whether the sphere-chart lane may substitute for the generic scan's parity
87/// count: neither `BREP_NO_SPHERE_CHARTS` nor [`NO_SPHERE_CHART_TRIM_SWITCH`]
88/// is set. Read on every call.
89fn sphere_chart_trim_enabled() -> bool {
90    env_read!("BREP_NO_SPHERE_CHARTS").is_none()
91        && std::env::var(NO_SPHERE_CHART_TRIM_SWITCH).is_err()
92}
93
94fn point_segment_distance(point: Vec2, start: Vec2, end: Vec2) -> f64 {
95    point.distance_to_segment(start, end, 1e-30)
96}
97
98pub use crate::polygon::PolygonClass;
99
100fn point_in_polygon(point: Vec2, polygon: &[Vec2], tolerance: f64) -> PolygonClass {
101    crate::polygon::classify(point, polygon, tolerance, 1e-30)
102}
103
104/// The even-odd half of [`point_in_polygon`]: whether a ray from `point`
105/// towards +x crosses `polygon` an odd number of times.
106fn polygon_parity_inside(point: Vec2, polygon: &[Vec2]) -> bool {
107    crate::polygon::parity_inside([point.x, point.y], polygon.len(), |i| [polygon[i].x, polygon[i].y])
108}
109
110
111/// One chord of a trim loop as the generic scan samples it: the loop and
112/// coedge it belongs to (the pcurve is looked up on the face at query time —
113/// the prepared data is cached across faces and must own nothing borrowed)
114/// and the pcurve parameters at its ends.
115#[derive(Clone, Copy)]
116struct SegmentPrep {
117    coedge_index: usize,
118    parameter_start: f64,
119    parameter_end: f64,
120}
121
122/// Which of the environment switches the lanes read were set when a face was
123/// prepared; part of the cache signature so a test that flips one between two
124/// queries on one thread never answers from data prepared under the other.
125pub(crate) fn lane_switches() -> f64 {
126    let mut bits = 0u32;
127    if !switch!(SEAM_BAND_MERGED_SWITCH, true) {
128        bits |= 1;
129    }
130    if !switch!(HORIZON_CONTAINMENT_SWITCH, true) {
131        bits |= 2;
132    }
133    if !switch!(HORIZON_SINGLE_LOOP_SWITCH, true) {
134        bits |= 4;
135    }
136    if !switch!(COVERING_RIM_STRIP_SWITCH, true) {
137        bits |= 8;
138    }
139    bits as f64
140}
141
142/// Doubly-periodic seam-band lane, prepared.
143enum SeamBandPrep {
144    /// The surface is not closed in both directions: the lane declines.
145    NotApplicable,
146    /// Every loop is a collapsed puncture: the whole torus is material.
147    AlwaysInside,
148    /// One seam-crossing loop unwrapped onto the covering plane.
149    Merged { polygon: Vec<Vec2>, u_span: f64, v_span: f64 },
150    /// Two loops the seam-band analysis reassembled into one polygon.
151    Analyzed { polygon: Vec<Vec2> },
152    /// Two clean full-wrap rims at constant cross levels.
153    Rings { p_is_u: bool, q_lo: f64, q_hi: f64, complement: bool },
154    /// None of the shapes above: keep the general path.
155    Declined,
156}
157
158/// Winding sphere-cap lane, prepared: `None` when the face is not a cap.
159struct SphereCapPrep {
160    rim: Vec<Vec2>,
161    pole_v: f64,
162    period: f64,
163}
164
165/// Wrapped-horizon lane, prepared: `None` when no loop straddles the seam.
166struct HorizonPrep {
167    loops: Vec<Vec<Vec2>>,
168    u_period: f64,
169    /// The loops' chords, indexed the first time a query asks.
170    index: std::cell::OnceCell<Option<ChordIndex>>,
171}
172
173/// Covering rim-strip lane, prepared: `None` when the face is not a strip.
174struct RimStripPrep {
175    lower: Vec<Vec2>,
176    upper: Vec<Vec2>,
177    period: f64,
178}
179
180/// The generic scan's samples: per loop the parity polygon, the chord
181/// parameters behind it and the per-coedge runs the spherical lane reads.
182struct GenericPrep {
183    loops: Vec<LoopSamples>,
184    segments: Vec<Vec<SegmentPrep>>,
185    /// Every loop's chords, counted.
186    chord_count: usize,
187    /// The chords indexed ([`ChordIndex`]), built the first time a query asks
188    /// for the index; `None` inside when a sample is too large or not finite.
189    index: std::cell::OnceCell<Option<ChordIndex>>,
190}
191
192/// The fewest chords a lane must carry before a query goes through its
193/// [`ChordIndex`] rather than scanning every chord: below it the scan costs
194/// about what a descent does. Either answers the same.
195const CHORD_INDEX_MIN_CHORDS: usize = 64;
196
197/// Chords per leaf of a [`ChordIndex`].
198const CHORD_INDEX_LEAF: usize = 6;
199
200/// The largest coordinate, of a chord end or of a query point, the index is
201/// used for. Squares of anything smaller stay finite, so every distance the
202/// scan computes is a finite number the index can bound; beyond it the scan
203/// answers.
204const CHORD_INDEX_MAX_COORDINATE: f64 = 1e100;
205
206/// Whether [`parameter_point_in_face`] may answer through a lane's
207/// [`ChordIndex`]: `BREP_CONTAINMENT_INDEX=0` keeps the linear scans
208/// everywhere, for an A/B of the two in one build. Read once per process.
209fn chord_index_enabled() -> bool {
210    switch!("BREP_CONTAINMENT_INDEX", true)
211}
212
213/// A node of a [`ChordIndex`]: the exact box (the min and max of the samples,
214/// not a computed value) of a run of consecutive chords of one loop, chords
215/// `first..last` of loop `loop_index`, chord `i` running from `polygon[i]` to
216/// `polygon[(i + 1) % len]`. A leaf reads its chords; an inner node has two
217/// children, the runs before and after its middle.
218#[derive(Clone, Copy)]
219struct ChordNode {
220    low: Vec2,
221    high: Vec2,
222    loop_index: u32,
223    first: u32,
224    last: u32,
225    children: Option<(u32, u32)>,
226}
227
228/// A hierarchy of boxes over every chord of a face's generic scan, or of its
229/// wrapped-horizon lane's unwrapped loops, so a query reads the chords near it
230/// rather than all of them — and gives the scan's answer to the bit.
231///
232/// Consecutive chords of a trim lie together, so each loop's chords are cut
233/// into runs of [`CHORD_INDEX_LEAF`] and the runs paired, and paired again, up
234/// to one box per loop: built in one pass over the samples, holding no copy of
235/// them. (A median-split tree over the chords of `gear-hex-bore-push`'s face
236/// 8307, 557 504 of them, took 200 ms to build — more than the questions it
237/// answered saved.)
238///
239/// The scan asks three things of its chords, and the index asks each of the
240/// same chords with the same arithmetic (the wrapped horizon asks the second
241/// and third, at each of its three period images):
242///
243/// - the NEAREST chord, `point_segment_distance` to every chord with a strict
244///   `<` in loop-then-chord order, so the first minimum is kept. The index
245///   keeps the least `(distance, loop, chord)`, which is that chord, and skips a
246///   box only when its distance exceeds the best by more than `slack`;
247/// - whether any chord is within the tolerance (`Boundary`): exactly when the
248///   nearest chord's distance is, both being that same float;
249/// - each loop's even-odd count along +x, the test `(a.y > p.y) != (b.y > p.y)`
250///   and `crossing > p.x`. A chord that passes the first has one sample above
251///   `p.y` and one at or below it, which its box holds exactly, and its
252///   crossing is within rounding of its samples' largest `x`; a box is skipped
253///   only when its `y` range cannot straddle `p.y` or its largest `x` is short
254///   of `p.x` by more than `slack`.
255///
256/// `slack` is `1e-12` of the largest coordinate in play, some four thousand
257/// times the few units in the last place a computed foot or crossing can stray
258/// outside its chord's box. Distances and crossings are only COMPARED against
259/// boxes with that slack; every value that decides an answer is the scan's own.
260struct ChordIndex {
261    nodes: Vec<ChordNode>,
262    /// One per loop with chords.
263    roots: Vec<u32>,
264    /// `1e-12 × (1 + the largest |coordinate| of a sample)`.
265    slack: f64,
266}
267
268/// The closed polygons a [`ChordIndex`] is built over and read against: the
269/// generic scan's loops, or the wrapped-horizon lane's unwrapped ones.
270trait ChordLoops {
271    fn loop_count(&self) -> usize;
272    fn polygon(&self, index: usize) -> &[Vec2];
273}
274
275impl ChordLoops for [LoopSamples] {
276    fn loop_count(&self) -> usize {
277        self.len()
278    }
279
280    fn polygon(&self, index: usize) -> &[Vec2] {
281        &self[index].polygon
282    }
283}
284
285impl ChordLoops for [Vec<Vec2>] {
286    fn loop_count(&self) -> usize {
287        self.len()
288    }
289
290    fn polygon(&self, index: usize) -> &[Vec2] {
291        &self[index]
292    }
293}
294
295impl ChordIndex {
296    /// The index over `loops`, or `None` when a sample is not finite, a
297    /// coordinate exceeds [`CHORD_INDEX_MAX_COORDINATE`], a count does not fit
298    /// in `u32`, or there is no chord.
299    fn build<L: ChordLoops + ?Sized>(loops: &L) -> Option<ChordIndex> {
300        let mut nodes: Vec<ChordNode> = Vec::new();
301        let mut roots = Vec::new();
302        let mut largest: f64 = 0.0;
303        for loop_index in 0..loops.loop_count() {
304            let polygon = loops.polygon(loop_index);
305            let len = polygon.len();
306            if len == 0 {
307                continue;
308            }
309            u32::try_from(len).ok()?;
310            for sample in polygon {
311                if !(sample.x.abs() <= CHORD_INDEX_MAX_COORDINATE && sample.y.abs() <= CHORD_INDEX_MAX_COORDINATE) {
312                    return None;
313                }
314                largest = largest.max(sample.x.abs()).max(sample.y.abs());
315            }
316            let loop_index = u32::try_from(loop_index).ok()?;
317            let mut level: Vec<u32> = Vec::with_capacity(len / CHORD_INDEX_LEAF + 1);
318            for first in (0..len).step_by(CHORD_INDEX_LEAF) {
319                let last = (first + CHORD_INDEX_LEAF).min(len);
320                let (mut low, mut high) = (polygon[first], polygon[first]);
321                for index in first..last {
322                    let end = polygon[(index + 1) % len];
323                    low = Vec2 { x: low.x.min(end.x), y: low.y.min(end.y) };
324                    high = Vec2 { x: high.x.max(end.x), y: high.y.max(end.y) };
325                    let start = polygon[index];
326                    low = Vec2 { x: low.x.min(start.x), y: low.y.min(start.y) };
327                    high = Vec2 { x: high.x.max(start.x), y: high.y.max(start.y) };
328                }
329                level.push(u32::try_from(nodes.len()).ok()?);
330                nodes.push(ChordNode {
331                    low,
332                    high,
333                    loop_index,
334                    first: first as u32,
335                    last: last as u32,
336                    children: None,
337                });
338            }
339            while level.len() > 1 {
340                let mut next = Vec::with_capacity(level.len() / 2 + 1);
341                for pair in level.chunks(2) {
342                    let &[left, right] = pair else {
343                        next.push(pair[0]);
344                        continue;
345                    };
346                    let (a, b) = (nodes[left as usize], nodes[right as usize]);
347                    next.push(u32::try_from(nodes.len()).ok()?);
348                    nodes.push(ChordNode {
349                        low: Vec2 { x: a.low.x.min(b.low.x), y: a.low.y.min(b.low.y) },
350                        high: Vec2 { x: a.high.x.max(b.high.x), y: a.high.y.max(b.high.y) },
351                        loop_index,
352                        first: a.first,
353                        last: b.last,
354                        children: Some((left, right)),
355                    });
356                }
357                level = next;
358            }
359            roots.push(level[0]);
360        }
361        if roots.is_empty() {
362            return None;
363        }
364        Some(ChordIndex {
365            nodes,
366            roots,
367            slack: 1e-12 * (1.0 + largest),
368        })
369    }
370
371    /// The slack for a query at `point`: the index's own plus `1e-12` of the
372    /// point's largest coordinate.
373    fn slack_at(&self, point: Vec2) -> f64 {
374        self.slack + 1e-12 * point.x.abs().max(point.y.abs())
375    }
376
377    fn box_distance(node: &ChordNode, point: Vec2) -> f64 {
378        let dx = (node.low.x - point.x).max(point.x - node.high.x).max(0.0);
379        let dy = (node.low.y - point.y).max(point.y - node.high.y).max(0.0);
380        (dx * dx + dy * dy).sqrt()
381    }
382
383    /// The scan's nearest chord of `loops` to `point` as `(loop, chord,
384    /// distance)`: the least distance, and of equal distances the first in
385    /// loop-then-chord order.
386    fn nearest<L: ChordLoops + ?Sized>(&self, loops: &L, point: Vec2) -> Option<(usize, usize, f64)> {
387        let slack = self.slack_at(point);
388        let mut best: Option<(f64, u32, u32)> = None;
389        let mut stack: Vec<(u32, f64)> = Vec::with_capacity(64);
390        for &root in &self.roots {
391            stack.push((root, Self::box_distance(&self.nodes[root as usize], point)));
392        }
393        while let Some((node_index, bound)) = stack.pop() {
394            if best.is_some_and(|(distance, _, _)| bound > distance + slack + 1e-12 * distance) {
395                continue;
396            }
397            let node = self.nodes[node_index as usize];
398            match node.children {
399                None => {
400                    let polygon = loops.polygon(node.loop_index as usize);
401                    for index in node.first..node.last {
402                        let start = polygon[index as usize];
403                        let end = polygon[(index as usize + 1) % polygon.len()];
404                        let distance = point_segment_distance(point, start, end);
405                        let better = best.is_none_or(|(least, loop_index, chord_index)| {
406                            distance < least
407                                || (distance == least && (node.loop_index, index) < (loop_index, chord_index))
408                        });
409                        if better {
410                            best = Some((distance, node.loop_index, index));
411                        }
412                    }
413                }
414                Some((left, right)) => {
415                    let left_bound = Self::box_distance(&self.nodes[left as usize], point);
416                    let right_bound = Self::box_distance(&self.nodes[right as usize], point);
417                    // The nearer box is read first, so the best tightens early.
418                    if left_bound <= right_bound {
419                        stack.push((right, right_bound));
420                        stack.push((left, left_bound));
421                    } else {
422                        stack.push((left, left_bound));
423                        stack.push((right, right_bound));
424                    }
425                }
426            }
427        }
428        best.map(|(distance, loop_index, chord_index)| {
429            (loop_index as usize, chord_index as usize, distance)
430        })
431    }
432
433    /// Whether a chord of `loops` lies within `tolerance` of `point`, by
434    /// `point_segment_distance`, the scan's own test.
435    fn any_within<L: ChordLoops + ?Sized>(&self, loops: &L, point: Vec2, tolerance: f64) -> bool {
436        let reach = tolerance + self.slack_at(point) + 1e-12 * tolerance.abs();
437        let mut stack: Vec<u32> = self.roots.clone();
438        while let Some(node_index) = stack.pop() {
439            let node = self.nodes[node_index as usize];
440            if Self::box_distance(&node, point) > reach {
441                continue;
442            }
443            match node.children {
444                None => {
445                    let polygon = loops.polygon(node.loop_index as usize);
446                    for index in node.first as usize..node.last as usize {
447                        let (start, end) = (polygon[index], polygon[(index + 1) % polygon.len()]);
448                        if point_segment_distance(point, start, end) <= tolerance {
449                            return true;
450                        }
451                    }
452                }
453                Some((left, right)) => {
454                    stack.push(right);
455                    stack.push(left);
456                }
457            }
458        }
459        false
460    }
461
462    /// Flips `odd[loop]` once for every chord of that loop the +x ray from
463    /// `point` crosses, with `point_in_polygon`'s crossing test.
464    fn flip_crossings<L: ChordLoops + ?Sized>(&self, loops: &L, point: Vec2, odd: &mut [bool]) {
465        let slack = self.slack_at(point);
466        let mut stack: Vec<u32> = self.roots.clone();
467        while let Some(node_index) = stack.pop() {
468            let node = self.nodes[node_index as usize];
469            if !(node.low.y <= point.y && node.high.y > point.y) || node.high.x + slack < point.x {
470                continue;
471            }
472            match node.children {
473                None => {
474                    let polygon = loops.polygon(node.loop_index as usize);
475                    for index in node.first as usize..node.last as usize {
476                        let a = polygon[index];
477                        let b = polygon[(index + 1) % polygon.len()];
478                        if crate::polygon::crosses_ray([point.x, point.y], [a.x, a.y], [b.x, b.y]) {
479                            let parity = &mut odd[node.loop_index as usize];
480                            *parity = !*parity;
481                        }
482                    }
483                }
484                Some((left, right)) => {
485                    stack.push(right);
486                    stack.push(left);
487                }
488            }
489        }
490    }
491}
492
493/// How a query reads a lane's chords: the generic scan's, or the wrapped
494/// horizon's.
495#[derive(Clone, Copy, PartialEq, Eq)]
496enum ChordScan {
497    /// Through the index on a face of [`CHORD_INDEX_MIN_CHORDS`] or more,
498    /// unless `BREP_CONTAINMENT_INDEX=0`.
499    Auto,
500    /// Through the index whenever it can be built.
501    Indexed,
502    /// Every chord.
503    Linear,
504}
505
506impl ChordScan {
507    /// Whether a lane of `chords` chords is read through its index.
508    fn indexes(self, chords: usize) -> bool {
509        match self {
510            ChordScan::Linear => false,
511            ChordScan::Indexed => true,
512            ChordScan::Auto => chord_index_enabled() && chords >= CHORD_INDEX_MIN_CHORDS,
513        }
514    }
515}
516
517/// Whether a query point is one the index is used for
518/// ([`CHORD_INDEX_MAX_COORDINATE`]).
519fn in_index_range(point: Vec2) -> bool {
520    point.x.abs() <= CHORD_INDEX_MAX_COORDINATE && point.y.abs() <= CHORD_INDEX_MAX_COORDINATE
521}
522
523/// The generic scan over every chord: `(odd loops, boundary, nearest chord as
524/// (loop, chord, distance))`. The reference the index answers as.
525fn linear_scan(
526    generic: &GenericPrep,
527    point: Vec2,
528    tolerance: f64,
529) -> (usize, bool, Option<(usize, usize, f64)>) {
530    let mut crossings = 0;
531    let mut boundary = false;
532    // `(loop index, chord index, distance)` of the nearest chord, scanned in
533    // loop order then chord order so a tie resolves as it always has.
534    let mut nearest: Option<(usize, usize, f64)> = None;
535    let fused = fused_scan_enabled();
536    for (loop_index, (samples, chords)) in generic.loops.iter().zip(&generic.segments).enumerate() {
537        let polygon = &samples.polygon;
538        if fused {
539            // One distance per chord serves the boundary test and the nearest
540            // chord; `chords` holds one entry per polygon sample.
541            let mut on_boundary = false;
542            for index in 0..polygon.len() {
543                let distance = point_segment_distance(point, polygon[index], polygon[(index + 1) % polygon.len()]);
544                if distance <= tolerance {
545                    on_boundary = true;
546                }
547                if index < chords.len()
548                    && nearest
549                        .as_ref()
550                        .is_none_or(|(_, _, nearest_distance)| distance < *nearest_distance)
551                {
552                    nearest = Some((loop_index, index, distance));
553                }
554            }
555            if on_boundary {
556                boundary = true;
557            } else if polygon_parity_inside(point, polygon) {
558                crossings += 1;
559            }
560            continue;
561        }
562        match point_in_polygon(point, polygon, tolerance) {
563            PolygonClass::Boundary => boundary = true,
564            PolygonClass::Inside => crossings += 1,
565            PolygonClass::Outside => {}
566        }
567        for index in 0..chords.len() {
568            let start = polygon[index];
569            let end = polygon[(index + 1) % polygon.len()];
570            let distance = point_segment_distance(point, start, end);
571            if nearest
572                .as_ref()
573                .is_none_or(|(_, _, nearest_distance)| distance < *nearest_distance)
574            {
575                nearest = Some((loop_index, index, distance));
576            }
577        }
578    }
579    (crossings, boundary, nearest)
580}
581
582/// FUSED SCAN (lane O row 24, 2026-10-29; on by default since the batch 6 promotion, `BREP_CONTAINMENT_FUSED_SCAN=0` restores the two passes).
583/// [`linear_scan`] measured every chord's distance twice, once in
584/// [`point_in_polygon`]'s boundary pass and once for the nearest chord; on the
585/// D2 outward shells that pair was most of the point-in-face query's own time.
586/// The fused pass computes each distance once and feeds both, then counts
587/// parity only off the boundary, as `point_in_polygon` does. Read once per process.
588fn fused_scan_enabled() -> bool {
589    switch!("BREP_CONTAINMENT_FUSED_SCAN", true)
590}
591
592
593/// The generic scan's `(odd loops, boundary, nearest chord)` for `point`, read
594/// as `scan` says. When the index answers, `odd loops` is not counted on a
595/// boundary point, where nothing reads it.
596fn generic_scan(
597    generic: &GenericPrep,
598    point: Vec2,
599    tolerance: f64,
600    scan: ChordScan,
601) -> (usize, bool, Option<(usize, usize, f64)>) {
602    if scan.indexes(generic.chord_count) && in_index_range(point) {
603        if let Some(index) = generic.index.get_or_init(|| {
604            let started = containment_profile_started();
605            let built = ChordIndex::build(generic.loops.as_slice());
606            let ms = containment_elapsed_ms(started);
607            containment_profile(|p| {
608                p.index_builds += 1;
609                p.index_ms += ms;
610            });
611            built
612        }) {
613            let loops = generic.loops.as_slice();
614            let nearest = index.nearest(loops, point);
615            let boundary = nearest.is_some_and(|(_, _, distance)| distance <= tolerance);
616            let crossings = if boundary {
617                0
618            } else {
619                let mut odd = vec![false; loops.len()];
620                index.flip_crossings(loops, point, &mut odd);
621                odd.into_iter().filter(|&parity| parity).count()
622            };
623            return (crossings, boundary, nearest);
624        }
625    }
626    linear_scan(generic, point, tolerance)
627}
628
629/// Everything [`parameter_point_in_face`] derives from the FACE alone, so a
630/// face queried many times — the imprint clip asks once per marched sample,
631/// the fragment builder and the classifier's rays once per crossing — samples
632/// its trim once. Each lane is prepared the first time a query REACHES it, so
633/// an error in a lane's sampling surfaces on exactly the faces and queries it
634/// did when the lanes sampled inline.
635struct PreparedFace {
636    seam_band: std::cell::OnceCell<Result<SeamBandPrep, String>>,
637    sphere_cap: std::cell::OnceCell<Result<Option<SphereCapPrep>, String>>,
638    horizon: std::cell::OnceCell<Result<Option<HorizonPrep>, String>>,
639    rim_strip: std::cell::OnceCell<Result<Option<RimStripPrep>, String>>,
640    generic: std::cell::OnceCell<Result<GenericPrep, String>>,
641    /// The spherical trim lane's region, built from `generic`'s samples.
642    sphere_region: std::cell::OnceCell<Result<crate::sphere_chart::SphericalRegion, String>>,
643    /// The same region from a reduced boundary, with the margin within which it
644    /// may differ from `sphere_region` ([`build_sphere_region_reduced`]); `None`
645    /// for a trim too small to reduce.
646    sphere_region_reduced: std::cell::OnceCell<Result<Option<(crate::sphere_chart::SphericalRegion, f64)>, String>>,
647    /// Each loop's trim samples at [`trim_station`] `0..count` per pcurve, in
648    /// coedge order, taken once and shared by the sphere-cap, wrapped-horizon
649    /// and generic lanes ([`loop_samples`]); one entry per loop, filled the
650    /// first time a lane reaches that loop.
651    loop_samples: std::cell::RefCell<Vec<Option<Result<std::rc::Rc<Vec<Vec2>>, String>>>>,
652}
653
654impl PreparedFace {
655    fn new() -> Self {
656        Self {
657            seam_band: std::cell::OnceCell::new(),
658            sphere_cap: std::cell::OnceCell::new(),
659            horizon: std::cell::OnceCell::new(),
660            rim_strip: std::cell::OnceCell::new(),
661            generic: std::cell::OnceCell::new(),
662            sphere_region: std::cell::OnceCell::new(),
663            sphere_region_reduced: std::cell::OnceCell::new(),
664            loop_samples: std::cell::RefCell::new(Vec::new()),
665        }
666    }
667}
668
669/// Loop `loop_index` of `face` sampled as the lanes sample it: every pcurve
670/// at [`trim_sample_count`] chords, [`trim_station`] `0..count`, in coedge
671/// order. The three lanes that read exactly these samples (the sphere cap,
672/// the wrapped horizon, the generic scan) used to each evaluate them again,
673/// 2 500 pcurve evaluations per pass on a box-fillet corner patch; the
674/// samples are taken the first time any lane reaches the loop and shared
675/// from then on, loop by loop, so a loop a lane never reaches (it declined
676/// on an earlier loop) is never sampled, and a sampling error surfaces at
677/// the loop and the query it did when each lane sampled for itself.
678fn loop_samples(prepared: &PreparedFace, face: &FaceRecord, loop_index: usize) -> Result<std::rc::Rc<Vec<Vec2>>, String> {
679    if let Some(Some(sampled)) = prepared.loop_samples.borrow().get(loop_index) {
680        return sampled.clone();
681    }
682    let sampled = (|| {
683        let mut points: Vec<Vec2> = Vec::new();
684        for coedge in &face.loops[loop_index].coedges {
685            let curve = &coedge.pcurve;
686            let [start, end] = curve.domain()?;
687            let sample_count = trim_sample_count(curve);
688            for index in 0..sample_count {
689                let parameter = trim_station(start, end, index, sample_count);
690                let evaluated = curve.evaluate(parameter)?;
691                points.push(Vec2 {
692                    x: evaluated.x,
693                    y: evaluated.y,
694                });
695            }
696        }
697        Ok(std::rc::Rc::new(points))
698    })();
699    let mut cells = prepared.loop_samples.borrow_mut();
700    if cells.len() < face.loops.len() {
701        cells.resize(face.loops.len(), None);
702    }
703    cells[loop_index] = Some(sampled.clone());
704    sampled
705}
706
707/// Per-thread split of what a face's FIRST query pays to prepare it, printed
708/// by [`containment_profile_report`] when `BREP_PROFILE` is set (the
709/// restriction lane and the interference scan report it beside the
710/// classifier's `classify.profile`): how many faces missed the prepared-face
711/// cache, how many of each lane's preparations ran and their wall time, the
712/// generic scan's chord index builds, and the spherical lane's region builds
713/// split into the surface evaluations, the canonicalization and the region's
714/// own construction (`from_segments`, its seed included). When the switch is
715/// off every counter is one thread-local flag test.
716#[derive(Default, Clone, Copy)]
717struct ContainmentProfile {
718    enabled: bool,
719    cache_misses: u64,
720    seam_band_preps: u64,
721    seam_band_ms: f64,
722    sphere_cap_preps: u64,
723    sphere_cap_ms: f64,
724    horizon_preps: u64,
725    horizon_ms: f64,
726    rim_strip_preps: u64,
727    rim_strip_ms: f64,
728    generic_preps: u64,
729    generic_ms: f64,
730    generic_chords: u64,
731    index_builds: u64,
732    index_ms: f64,
733    region_builds: u64,
734    region_evaluate_ms: f64,
735    region_canonical_ms: f64,
736    region_segments_ms: f64,
737}
738
739thread_local! {
740    static CONTAINMENT_PROFILE: std::cell::Cell<ContainmentProfile> =
741        const { std::cell::Cell::new(ContainmentProfile {
742            enabled: false, cache_misses: 0, seam_band_preps: 0, seam_band_ms: 0.0,
743            sphere_cap_preps: 0, sphere_cap_ms: 0.0, horizon_preps: 0, horizon_ms: 0.0,
744            rim_strip_preps: 0, rim_strip_ms: 0.0, generic_preps: 0, generic_ms: 0.0,
745            generic_chords: 0, index_builds: 0, index_ms: 0.0, region_builds: 0,
746            region_evaluate_ms: 0.0, region_canonical_ms: 0.0, region_segments_ms: 0.0,
747        }) };
748}
749
750impl crate::profile::Counters for ContainmentProfile {
751    fn armed(&self) -> bool {
752        self.enabled
753    }
754}
755
756fn containment_profile(update: impl FnOnce(&mut ContainmentProfile)) {
757    crate::profile::update(&CONTAINMENT_PROFILE, update)
758}
759
760fn containment_profile_started() -> Option<web_time::Instant> {
761    crate::profile::started(&CONTAINMENT_PROFILE)
762}
763
764use crate::profile::elapsed_ms as containment_elapsed_ms;
765
766/// Arm the preparation counters (reset to zero) when `BREP_PROFILE` is set.
767pub(crate) fn containment_profile_begin() {
768    let enabled = crate::profile::profile_enabled();
769    CONTAINMENT_PROFILE.with(|cell| {
770        cell.set(ContainmentProfile {
771            enabled,
772            ..ContainmentProfile::default()
773        })
774    });
775}
776
777/// Print the counters gathered since [`containment_profile_begin`] under
778/// `label` and disarm them. Silent when profiling is off.
779pub(crate) fn containment_profile_report(label: &str) {
780    let p = CONTAINMENT_PROFILE.with(|cell| cell.get());
781    if !p.enabled {
782        return;
783    }
784    profile_line!(
785        "containment.profile {label} cache_misses={} seam_band: preps={} ms={:.2} sphere_cap: preps={} ms={:.2} horizon: preps={} ms={:.2} rim_strip: preps={} ms={:.2} generic: preps={} ms={:.2} chords={} index: builds={} ms={:.2} region: builds={} evaluate_ms={:.2} canonical_ms={:.2} segments_ms={:.2}",
786        p.cache_misses, p.seam_band_preps, p.seam_band_ms, p.sphere_cap_preps, p.sphere_cap_ms,
787        p.horizon_preps, p.horizon_ms, p.rim_strip_preps, p.rim_strip_ms, p.generic_preps,
788        p.generic_ms, p.generic_chords, p.index_builds, p.index_ms, p.region_builds,
789        p.region_evaluate_ms, p.region_canonical_ms, p.region_segments_ms,
790    );
791    CONTAINMENT_PROFILE.with(|cell| cell.set(ContainmentProfile::default()));
792}
793
794/// A lane's preparation, timed into the profile's `(count, ms)` pair when it
795/// is on.
796fn timed_lane<'a, T>(
797    cell: &'a std::cell::OnceCell<Result<T, String>>,
798    build: impl FnOnce() -> Result<T, String>,
799    record: fn(&mut ContainmentProfile, f64),
800) -> Result<&'a T, String> {
801    lane(cell, || {
802        let started = containment_profile_started();
803        let built = build();
804        let ms = containment_elapsed_ms(started);
805        containment_profile(|p| record(p, ms));
806        built
807    })
808}
809
810fn lane<'a, T>(
811    cell: &'a std::cell::OnceCell<Result<T, String>>,
812    build: impl FnOnce() -> Result<T, String>,
813) -> Result<&'a T, String> {
814    match cell.get_or_init(build) {
815        Ok(value) => Ok(value),
816        Err(error) => Err(error.clone()),
817    }
818}
819
820/// The prepared faces this thread queried most recently, keyed by the EXACT
821/// data the lanes read: the environment switches, the face sense, the surface's
822/// degrees, knots and control points, and per loop each coedge's edge id,
823/// direction, pcurve degree, knots and control points. A hit compares the whole
824/// signature, value by value to the bit (and never a NaN); the hash only
825/// chooses which entries to compare. It also holds the spherical lane's region
826/// (`PreparedFace::sphere_region`, [`build_sphere_region`]). Sampling a
827/// 16-coedge sphere trim at 2 600 chords per coedge four times per query was 1.5 s of
828/// `anotherBooleanFail`'s imprint clip and most of its fragment selection, for
829/// samples that never change between one query and the next.
830///
831/// The lookup is paid on every query, so it walks the face once against the
832/// stored signature and copies nothing: the hash reads the signature's shape
833/// and a few values, not all of it. Building, hashing and comparing the whole
834/// signature per query was 0.68 ms on `gear-hex-bore-push`'s face 8307 (562
835/// pcurves, 36 530 control points), which is all a query there costs once its
836/// chords are indexed.
837struct PreparedFaceCache {
838    entries: Vec<(u64, Vec<f64>, std::rc::Rc<PreparedFace>)>,
839}
840
841const PREPARED_FACE_CACHE_ENTRIES: usize = 64;
842
843thread_local! {
844    static PREPARED_FACES: std::cell::RefCell<PreparedFaceCache> = const {
845        std::cell::RefCell::new(PreparedFaceCache {
846            entries: Vec::new(),
847        })
848    };
849}
850
851/// Walks a face's signature — `switches` ([`lane_switches`], read once by the
852/// caller), then the face's data in a fixed order, every count before the
853/// values it counts — handing each value to `visit` until it returns false.
854/// Returns whether every value was visited.
855/// Each coedge's SLOT: the flat index (loops in order, coedges in order) of
856/// the first coedge of this face that names the same edge. The prepared face
857/// reads an edge id only to see whether one face uses an edge twice (a slit,
858/// `build_sphere_region`), which is this pattern and not the id's value; the
859/// assembler renumbers every result's edges (`next_edge_id: 1`), so a key on
860/// the ids missed for every geometrically unchanged face of a running body
861/// (the elephant's fold: 167 misses of 167 uncut probes, 1.47 s of region
862/// builds, 2026-11-02). Keying and storing the slot instead
863/// serves the same prepared face, whose every lane reads the same pattern.
864fn coedge_slots(face: &FaceRecord) -> Vec<usize> {
865    let ids: Vec<u64> = face.loops.iter().flat_map(|record| record.coedges.iter().map(|coedge| coedge.edge_id)).collect();
866    ids.iter().enumerate().map(|(at, id)| ids[..at].iter().position(|earlier| earlier == id).unwrap_or(at)).collect()
867}
868
869fn walk_face_signature<F: FnMut(f64) -> bool>(face: &FaceRecord, switches: f64, visit: &mut F) -> bool {
870    let slots = coedge_slots(face);
871    let mut flat = 0usize;
872    let surface = &face.surface;
873    let point = |visit: &mut F, p: &crate::Vec4| visit(p.x) && visit(p.y) && visit(p.z) && visit(p.w);
874    if !(visit(switches)
875        && visit(if face.same_sense { 1.0 } else { 0.0 })
876        && visit(surface.degree_u as f64)
877        && visit(surface.degree_v as f64)
878        && visit(surface.knots_u.len() as f64))
879    {
880        return false;
881    }
882    if !(surface.knots_u.iter().all(|&knot| visit(knot)) && visit(surface.knots_v.len() as f64)) {
883        return false;
884    }
885    if !(surface.knots_v.iter().all(|&knot| visit(knot)) && visit(surface.control_points.len() as f64)) {
886        return false;
887    }
888    for row in &surface.control_points {
889        if !(visit(row.len() as f64) && row.iter().all(|p| point(visit, p))) {
890            return false;
891        }
892    }
893    if !visit(face.loops.len() as f64) {
894        return false;
895    }
896    for loop_record in &face.loops {
897        if !visit(loop_record.coedges.len() as f64) {
898            return false;
899        }
900        for coedge in &loop_record.coedges {
901            let curve = &coedge.pcurve;
902            let slot = slots[flat];
903            flat += 1;
904            if !(visit(slot as f64)
905                && visit(if coedge.forward { 1.0 } else { 0.0 })
906                && visit(curve.degree as f64)
907                && visit(curve.knots.len() as f64))
908            {
909                return false;
910            }
911            if !(curve.knots.iter().all(|&knot| visit(knot)) && visit(curve.control_points.len() as f64)) {
912                return false;
913            }
914            if !curve.control_points.iter().all(|p| point(visit, p)) {
915                return false;
916            }
917        }
918    }
919    true
920}
921
922/// The cache's hash of a face: its signature's every count, the switches and
923/// sense, the surface's first and last control point, and each pcurve's
924/// first and last knot and control point, each value by its bits. Two faces
925/// whose signatures agree to the bit hash the same.
926fn face_key_hash(face: &FaceRecord, switches: f64) -> u64 {
927    use std::hash::Hasher;
928    let slots = coedge_slots(face);
929    let mut flat = 0usize;
930    let mut hasher = rustc_hash::FxHasher::default();
931    let mut write = |value: f64| hasher.write_u64(value.to_bits());
932    let surface = &face.surface;
933    write(switches);
934    write(if face.same_sense { 1.0 } else { 0.0 });
935    write(surface.degree_u as f64);
936    write(surface.degree_v as f64);
937    write(surface.knots_u.len() as f64);
938    write(surface.knots_v.len() as f64);
939    write(surface.control_points.len() as f64);
940    let corners = [surface.control_points.first().and_then(|row| row.first()), surface.control_points.last().and_then(|row| row.last())];
941    for p in corners.into_iter().flatten() {
942        [p.x, p.y, p.z, p.w].into_iter().for_each(&mut write);
943    }
944    write(face.loops.len() as f64);
945    for loop_record in &face.loops {
946        write(loop_record.coedges.len() as f64);
947        for coedge in &loop_record.coedges {
948            let curve = &coedge.pcurve;
949            write(slots[flat] as f64);
950            flat += 1;
951            write(if coedge.forward { 1.0 } else { 0.0 });
952            write(curve.degree as f64);
953            write(curve.knots.len() as f64);
954            write(curve.control_points.len() as f64);
955            for knot in [curve.knots.first(), curve.knots.last()].into_iter().flatten() {
956                write(*knot);
957            }
958            for p in [curve.control_points.first(), curve.control_points.last()].into_iter().flatten() {
959                [p.x, p.y, p.z, p.w].into_iter().for_each(&mut write);
960            }
961        }
962    }
963    hasher.finish()
964}
965
966/// Where `face` is in the cache's `entries`, compared exactly.
967fn cached_entry(
968    entries: &[(u64, Vec<f64>, std::rc::Rc<PreparedFace>)],
969    face: &FaceRecord,
970    switches: f64,
971    hash: u64,
972) -> Option<usize> {
973    entries.iter().position(|(entry_hash, signature, _)| {
974        if *entry_hash != hash {
975            return false;
976        }
977        let mut at = 0;
978        let walked = walk_face_signature(face, switches, &mut |value: f64| {
979            let same = signature
980                .get(at)
981                .is_some_and(|stored| stored.to_bits() == value.to_bits() && !value.is_nan());
982            at += 1;
983            same
984        });
985        walked && at == signature.len()
986    })
987}
988
989/// Empties this thread's prepared-face cache, spherical regions included. For
990/// the boolean's operand-state instrument (`BREP_DEBUG_OPERAND_STATE=tls`) only.
991pub(crate) fn forget_prepared_faces() {
992    PREPARED_FACES.with(|cache| cache.borrow_mut().entries.clear());
993}
994
995
996/// `face`'s prepared data if a query on this thread prepared it and it is
997/// still cached, without touching the cache: a caller that only wants the
998/// lane ([`containment_lane`]) must not push out the faces queries are using.
999fn cached_prepared_face(face: &FaceRecord) -> Option<std::rc::Rc<PreparedFace>> {
1000    PREPARED_FACES.with(|cache| {
1001        let PreparedFaceCache { entries } = &*cache.borrow();
1002        let switches = lane_switches();
1003        let hash = face_key_hash(face, switches);
1004        cached_entry(entries, face, switches, hash).map(|index| entries[index].2.clone())
1005    })
1006}
1007
1008fn prepared_face(face: &FaceRecord) -> std::rc::Rc<PreparedFace> {
1009    PREPARED_FACES.with(|cache| {
1010        let PreparedFaceCache { entries } = &mut *cache.borrow_mut();
1011        let switches = lane_switches();
1012        let hash = face_key_hash(face, switches);
1013        if let Some(index) = cached_entry(entries, face, switches, hash) {
1014            if index != 0 {
1015                let entry = entries.remove(index);
1016                entries.insert(0, entry);
1017            }
1018            return entries[0].2.clone();
1019        }
1020        let mut signature = Vec::new();
1021        walk_face_signature(face, switches, &mut |value: f64| {
1022            signature.push(value);
1023            true
1024        });
1025        let prepared = std::rc::Rc::new(PreparedFace::new());
1026        entries.insert(0, (hash, signature, prepared.clone()));
1027        entries.truncate(PREPARED_FACE_CACHE_ENTRIES);
1028        containment_profile(|p| p.cache_misses += 1);
1029        prepared
1030    })
1031}
1032
1033/// Point-in-face for a DOUBLY-periodic (torus) seam-band face, whose material
1034/// region the raw even-odd/tangent test below gets wrong: the constant-level
1035/// seam rim is a zero-area iso line and the wavy cut's own polygon encloses only
1036/// the thin strip it bounds, so a point in the band reads Outside (or flips on
1037/// the fragile nearest-tangent refinement). Reconstruct the band as one simple
1038/// (u,v) polygon — identical topology to the mass integrator and tessellator —
1039/// and test the point against it, so all three subsystems agree on the material
1040/// side. Returns None (keep the general path) for every other face.
1041fn prepare_seam_band(face: &FaceRecord) -> Result<SeamBandPrep, String> {
1042    if face.surface.closed_directions()? != (true, true) {
1043        return Ok(SeamBandPrep::NotApplicable);
1044    }
1045    let [u0, u1] = face.surface.domain_u()?;
1046    let [v0, v1] = face.surface.domain_v()?;
1047    if crate::topology::doubly_periodic_has_only_collapsed_loops(face)? {
1048        return Ok(SeamBandPrep::AlwaysInside);
1049    }
1050    let u_span = (u1 - u0).abs().max(1e-30);
1051    let v_span = (v1 - v0).abs().max(1e-30);
1052    let mut loops_uv: Vec<Vec<[f64; 2]>> = Vec::with_capacity(face.loops.len());
1053    // Which face loop each retained entry came from: the collapsed punctures
1054    // dropped below make the two lists disagree, and the merged-band lane needs
1055    // the ORIGINAL coedges of the loop it is about to unwrap.
1056    let mut retained: Vec<usize> = Vec::with_capacity(face.loops.len());
1057    for (loop_index, loop_record) in face.loops.iter().enumerate() {
1058        let mut points: Vec<[f64; 2]> = Vec::new();
1059        for coedge in &loop_record.coedges {
1060            let [d0, d1] = coedge.pcurve.domain()?;
1061            let samples = 24;
1062            for k in 0..=samples {
1063                let t = trim_station(d0, d1, k, samples);
1064                let p = coedge.pcurve.evaluate(t)?;
1065                points.push([p.x, p.y]);
1066            }
1067        }
1068        let (mut umin, mut umax, mut vmin, mut vmax) = (
1069            f64::INFINITY,
1070            f64::NEG_INFINITY,
1071            f64::INFINITY,
1072            f64::NEG_INFINITY,
1073        );
1074        for p in &points {
1075            umin = umin.min(p[0]);
1076            umax = umax.max(p[0]);
1077            vmin = vmin.min(p[1]);
1078            vmax = vmax.max(p[1]);
1079        }
1080        // Mirror `mass_properties::biperiodic_band_range`: a torus has no
1081        // geometric pole, so a VERTEX_LOOP whose whole pcurve trace collapses
1082        // to one parameter point is a zero-area puncture, not a band boundary.
1083        //
1084        // This used to drop the puncture only on faces with MORE than two loops,
1085        // to leave "ordinary two-loop torus classification" alone. That gate was
1086        // wrong, and mass integration never had it: one merged seam-carrying band
1087        // plus one puncture IS a two-loop face, and keeping the puncture put it at
1088        // `loops_uv.len() == 2`, where the two-loop analyses both decline (a
1089        // collapsed loop never full-wraps) and the face fell through to the plain
1090        // even-odd — whose verdict across a seam-wrapped band is the COMPLEMENT.
1091        // Measured on `anotherBooleanFail`'s imported body: face 280 read its
1092        // material band as v ∈ [0.125, 0.875] where its puncture-free twin face
1093        // 166 reads v ∈ [0.875, 1] ∪ [0, 0.125], and a ray crossing there was
1094        // silently lost, so points OpenCASCADE calls interior classified `Out`.
1095        // A zero-area point removes no material from any face, so drop it
1096        // wherever it appears; the all-collapsed case (an untrimmed torus written
1097        // as a sole VERTEX_LOOP) is already answered above, so at least one real
1098        // loop always survives.
1099        if (umax - umin) <= 1e-3 * u_span && (vmax - vmin) <= 1e-3 * v_span {
1100            continue;
1101        }
1102        retained.push(loop_index);
1103        loops_uv.push(points);
1104    }
1105    // MERGED SEAM-CARRYING BAND (single loop): `insert_periodic_band_seam_edges`
1106    // fuses a wrapped band's two rims into ONE loop joined by seam columns, so
1107    // the two-loop paths below never see it, and the plain even-odd polygon
1108    // carries a period-magnitude hop at each rim→column junction (a phantom
1109    // diagonal across the domain) that misclassifies the whole middle zone.
1110    // Unwrap the loop onto the covering plane (`loop_seam_offsets`, the same
1111    // fold mass integration and tessellation use), where it IS a simple
1112    // polygon, and even-odd the query's period images against it. Gated on a
1113    // genuinely seam-crossing single loop on a doubly-periodic surface; every
1114    // other face falls through unchanged. Hatch: BREP_SEAM_BAND_MERGED=0.
1115    if loops_uv.len() == 1 && switch!(SEAM_BAND_MERGED_SWITCH, true) {
1116        let coedges = &face.loops[retained[0]].coedges;
1117        let offsets = crate::topology::loop_seam_offsets(coedges, true, true, u_span, v_span)?;
1118        if offsets.iter().any(|o| o[0] != 0.0 || o[1] != 0.0) {
1119            let mut polygon: Vec<Vec2> = Vec::new();
1120            for (coedge_index, coedge) in coedges.iter().enumerate() {
1121                let [d0, d1] = coedge.pcurve.domain()?;
1122                let samples = 24;
1123                for k in 0..samples {
1124                    let t = trim_station(d0, d1, k, samples);
1125                    let p = coedge.pcurve.evaluate(t)?;
1126                    polygon.push(Vec2 {
1127                        x: p.x + offsets[coedge_index][0],
1128                        y: p.y + offsets[coedge_index][1],
1129                    });
1130                }
1131            }
1132            return Ok(SeamBandPrep::Merged { polygon, u_span, v_span });
1133        }
1134    }
1135    if loops_uv.len() != 2 {
1136        return Ok(SeamBandPrep::Declined);
1137    }
1138    if let Some(band) = crate::topology::analyze_doubly_periodic_seam_band(
1139        &loops_uv,
1140        [u0, u1, v0, v1],
1141        face.same_sense,
1142    ) {
1143        let polygon: Vec<Vec2> =
1144            crate::topology::seam_band_uv_polygon(&loops_uv, [u0, u1, v0, v1], &band)
1145                .into_iter()
1146                .map(|p| Vec2 { x: p[0], y: p[1] })
1147                .collect();
1148        return Ok(SeamBandPrep::Analyzed { polygon });
1149    }
1150    // TWO clean full-wrap rims are the companion `biperiodic_band_range` case,
1151    // with or without an extra collapsed puncture. Classify only the cross
1152    // parameter: the material is either the strip between the rims or its
1153    // periodic complement. The exact two-loop/full-wrap/constant-cross-level
1154    // gates below mirror mass integration and tessellation; every other torus
1155    // face retains the general classifier.
1156    for p_is_u in [true, false] {
1157        let (period, q_extent) = if p_is_u {
1158            (u1 - u0, v_span)
1159        } else {
1160            (v1 - v0, u_span)
1161        };
1162        if !(period > 0.0) {
1163            continue;
1164        }
1165        let coord = |p: &[f64; 2]| if p_is_u { (p[0], p[1]) } else { (p[1], p[0]) };
1166        let mut rings = Vec::with_capacity(2);
1167        for points in &loops_uv {
1168            let (mut pmin, mut pmax, mut qmin, mut qmax) = (
1169                f64::INFINITY,
1170                f64::NEG_INFINITY,
1171                f64::INFINITY,
1172                f64::NEG_INFINITY,
1173            );
1174            for p in points {
1175                let (periodic, cross) = coord(p);
1176                pmin = pmin.min(periodic);
1177                pmax = pmax.max(periodic);
1178                qmin = qmin.min(cross);
1179                qmax = qmax.max(cross);
1180            }
1181            if (pmax - pmin) < 0.6 * period || (qmax - qmin) > 0.05 * q_extent {
1182                rings.clear();
1183                break;
1184            }
1185            let mut net = 0.0;
1186            for pair in points.windows(2) {
1187                let mut delta = coord(&pair[1]).0 - coord(&pair[0]).0;
1188                if delta > 0.5 * period {
1189                    delta -= period;
1190                } else if delta < -0.5 * period {
1191                    delta += period;
1192                }
1193                net += delta;
1194            }
1195            let direction = if net > 0.25 * period {
1196                1
1197            } else if net < -0.25 * period {
1198                -1
1199            } else {
1200                0
1201            };
1202            rings.push((0.5 * (qmin + qmax), direction));
1203        }
1204        if rings.len() != 2 {
1205            continue;
1206        }
1207        rings.sort_by(|a, b| a.0.total_cmp(&b.0));
1208        let [(q_lo, lower_direction), (q_hi, upper_direction)] = rings.as_slice() else {
1209            unreachable!()
1210        };
1211        let inconclusive =
1212            *lower_direction == 0 || *upper_direction == 0 || lower_direction == upper_direction;
1213        let between_is_ccw_uv = if p_is_u {
1214            *lower_direction > 0
1215        } else {
1216            *lower_direction < 0
1217        };
1218        let complement = !inconclusive && between_is_ccw_uv != face.same_sense;
1219        return Ok(SeamBandPrep::Rings {
1220            p_is_u,
1221            q_lo: *q_lo,
1222            q_hi: *q_hi,
1223            complement,
1224        });
1225    }
1226    Ok(SeamBandPrep::Declined)
1227}
1228
1229fn seam_band_point_in_face(
1230    prepared: &SeamBandPrep,
1231    point: Vec2,
1232    tolerance: f64,
1233) -> Option<PolygonClass> {
1234    match prepared {
1235        SeamBandPrep::NotApplicable | SeamBandPrep::Declined => None,
1236        SeamBandPrep::AlwaysInside => Some(PolygonClass::Inside),
1237        SeamBandPrep::Merged { polygon, u_span, v_span } => {
1238            let mut best = PolygonClass::Outside;
1239            'images: for du in [-1.0, 0.0, 1.0] {
1240                for dv in [-1.0, 0.0, 1.0] {
1241                    let image = Vec2 {
1242                        x: point.x + du * u_span,
1243                        y: point.y + dv * v_span,
1244                    };
1245                    match point_in_polygon(image, polygon, tolerance) {
1246                        PolygonClass::Boundary => {
1247                            best = PolygonClass::Boundary;
1248                            break 'images;
1249                        }
1250                        PolygonClass::Inside => best = PolygonClass::Inside,
1251                        PolygonClass::Outside => {}
1252                    }
1253                }
1254            }
1255            Some(best)
1256        }
1257        SeamBandPrep::Analyzed { polygon } => Some(point_in_polygon(point, polygon, tolerance)),
1258        SeamBandPrep::Rings { p_is_u, q_lo, q_hi, complement } => {
1259            let q = if *p_is_u { point.y } else { point.x };
1260            if (q - q_lo).abs() <= tolerance || (q - q_hi).abs() <= tolerance {
1261                return Some(PolygonClass::Boundary);
1262            }
1263            let between = q > *q_lo && q < *q_hi;
1264            Some(if between != *complement {
1265                PolygonClass::Inside
1266            } else {
1267                PolygonClass::Outside
1268            })
1269        }
1270    }
1271}
1272
1273/// Point-in-face for a SINGLY-PERIODIC face with a SEAM-STRADDLING loop: a
1274/// trim loop crossing the u seam is stored with its samples folded into the
1275/// domain, so its flat polygon is TORN at the seam and plain even-odd
1276/// misclassifies near the tear (STEP 00000585-family: face 519's big inner
1277/// loop straddles the seam and a REAL section sub-segment between two accepted
1278/// pieces read Outside in `process_curve`'s midpoint gate — trial 174's
1279/// missing middle segment). Mirror fragment.rs's proven seam-straddling
1280/// fallback: UNWRAP every loop by accumulating period-folded deltas (a
1281/// zero-winding loop closes in the covering plane), then even-odd the query at
1282/// its −P/0/+P period images across all unwrapped loops. Fires only when the
1283/// face is closed in exactly u, has >2 loops, and at least one loop straddles
1284/// the seam (≥1 fold jump); every other face returns None and keeps the plain
1285/// classifier bit-identically. Loops that WIND the period (net winding ≠ 0)
1286/// do not close under unwrapping — bail to the plain path rather than guess.
1287/// Escape hatch `BREP_HORIZON_CONTAINMENT=0`.
1288fn prepare_wrapped_horizon(face: &FaceRecord, prepared: &PreparedFace) -> Result<Option<HorizonPrep>, String> {
1289    if face.surface.closed_directions()? != (true, false) || face.loops.is_empty() {
1290        return Ok(None);
1291    }
1292    if !switch!(HORIZON_CONTAINMENT_SWITCH, true) {
1293        return Ok(None);
1294    }
1295    // SINGLE/DOUBLE-LOOP straddlers (t91's face 675: ONE loop drawn in-domain
1296    // that hops the u-seam twice, trimming a sliver strip AT the seam) used
1297    // to be excluded by a `loops.len() <= 2` gate and fell to the plain
1298    // even-odd, whose verdict on the hopped polygon is INVERTED (the strip
1299    // interior read Outside, far azimuths read Inside) — minting bogus
1300    // far-azimuth section pieces and discarding the real ones. The unwrap +
1301    // period-image even-odd below is loop-count-agnostic, so the gate now
1302    // only requires a non-empty loop set; non-straddling loops still bail to
1303    // the plain path unchanged. Escape hatch: BREP_HORIZON_SINGLE_LOOP=0
1304    // restores the old minimum.
1305    if face.loops.len() <= 2 && !switch!(HORIZON_SINGLE_LOOP_SWITCH, true) {
1306        return Ok(None);
1307    }
1308    let [u0, u1] = face.surface.domain_u()?;
1309    let u_period = u1 - u0;
1310    if u_period <= 0.0 {
1311        return Ok(None);
1312    }
1313    let debug = std::env::var("BREP_DEBUG_HORIZON").is_ok();
1314    let mut loops: Vec<Vec<Vec2>> = Vec::new();
1315    let mut straddling = false;
1316    for loop_index in 0..face.loops.len() {
1317        let points = loop_samples(prepared, face, loop_index)?;
1318        if points.len() < 3 {
1319            continue;
1320        }
1321        // Unwrap into the covering plane: place each sample at the previous
1322        // one plus the period-folded delta. A seam-straddling loop becomes a
1323        // continuous closed polygon; a WINDING loop does not close — bail.
1324        let mut unwrapped = Vec::with_capacity(points.len());
1325        let mut jumps = 0usize;
1326        let mut cursor = points[0];
1327        unwrapped.push(cursor);
1328        for pair in points.windows(2) {
1329            let mut du = pair[1].x - pair[0].x;
1330            let folded = du - u_period * (du / u_period).round();
1331            if (du - folded).abs() > 0.25 * u_period {
1332                jumps += 1;
1333            }
1334            du = folded;
1335            cursor = Vec2 {
1336                x: cursor.x + du,
1337                y: pair[1].y,
1338            };
1339            unwrapped.push(cursor);
1340        }
1341        let closure = (unwrapped[0].x - unwrapped[unwrapped.len() - 1].x).abs();
1342        if closure > 0.25 * u_period {
1343            if debug {
1344                eprintln!(
1345                    "horizon: face {} loop winds the period (closure {closure:.3}) — bail",
1346                    face.id
1347                );
1348            }
1349            return Ok(None); // winding loop: unwrapping cannot close it
1350        }
1351        if jumps > 0 {
1352            straddling = true;
1353        }
1354        loops.push(unwrapped);
1355    }
1356    if !straddling || loops.is_empty() {
1357        return Ok(None);
1358    }
1359    Ok(Some(HorizonPrep {
1360        loops,
1361        u_period,
1362        index: std::cell::OnceCell::new(),
1363    }))
1364}
1365
1366fn wrapped_horizon_point_in_face(
1367    face: &FaceRecord,
1368    prepared: &HorizonPrep,
1369    point: Vec2,
1370    tolerance: f64,
1371    scan: ChordScan,
1372) -> Option<PolygonClass> {
1373    let HorizonPrep { loops, u_period, index } = prepared;
1374    let u_period = *u_period;
1375    let debug = std::env::var("BREP_DEBUG_HORIZON").is_ok();
1376    // Even-odd on the QUOTIENT cylinder: each unwrapped loop is tested at
1377    // every period image of the query and contributes its own parity. The old
1378    // combining ("any single image with an odd crossing count ⇒ Inside") is
1379    // wrong whenever two loops live in DIFFERENT period frames after
1380    // unwrapping — t181/00000312#p7: face 519's seam-straddling HOLE unwraps
1381    // to u∈[−0.039, 0.039] while the outer rectangle spans [0, 1], so a query
1382    // inside the hole at u≈0.986 is contained by the outer loop at shift 0
1383    // (odd ⇒ Inside) and by the hole at shift −P (odd ⇒ Inside again); the
1384    // true even count (outer + hole = 2 ⇒ Outside) is never assembled at any
1385    // single image. Fragment selection then kept a PHANTOM half-hole region
1386    // whose boundary (half the hole loop + a derived chord) minted same-sense
1387    // coedges at assembly. Summing per-loop parities across images restores
1388    // the covering-space even-odd. Escape hatch: BREP_HORIZON_CROSS_FRAME=0
1389    // restores the old per-image combining.
1390    if !horizon_cross_frame() {
1391        let mut best: Option<PolygonClass> = None;
1392        for shift in [-u_period, 0.0, u_period] {
1393            let image = Vec2 {
1394                x: point.x + shift,
1395                y: point.y,
1396            };
1397            let mut crossings = 0usize;
1398            for polygon in loops {
1399                match point_in_polygon(image, polygon, tolerance) {
1400                    PolygonClass::Boundary => return Some(PolygonClass::Boundary),
1401                    PolygonClass::Inside => crossings += 1,
1402                    PolygonClass::Outside => {}
1403                }
1404            }
1405            if crossings % 2 == 1 {
1406                best = Some(PolygonClass::Inside);
1407            } else if best.is_none() {
1408                best = Some(PolygonClass::Outside);
1409            }
1410        }
1411        if debug {
1412            eprintln!(
1413                "horizon: face {} loops={} straddling (legacy per-image) -> {:?}",
1414                face.id,
1415                loops.len(),
1416                best
1417            );
1418        }
1419        return best;
1420    }
1421    let images = [-u_period, 0.0, u_period].map(|shift| Vec2 {
1422        x: point.x + shift,
1423        y: point.y,
1424    });
1425    let chords: usize = loops.iter().map(Vec::len).sum();
1426    let indexed = (scan.indexes(chords) && images.iter().all(|image| in_index_range(*image)))
1427        .then(|| index.get_or_init(|| ChordIndex::build(loops.as_slice())).as_ref())
1428        .flatten();
1429    let mut crossings = 0usize;
1430    if let Some(index) = indexed {
1431        // The per-loop parities summed over the images, as the scan below
1432        // sums them: `Boundary` if any image is within the band of any chord,
1433        // otherwise each loop's crossings flipped across all three images.
1434        let loops = loops.as_slice();
1435        if images.iter().any(|image| index.any_within(loops, *image, tolerance)) {
1436            return Some(PolygonClass::Boundary);
1437        }
1438        let mut odd = vec![false; loops.len()];
1439        for image in images {
1440            index.flip_crossings(loops, image, &mut odd);
1441        }
1442        crossings = odd.into_iter().filter(|&parity| parity).count();
1443    } else {
1444        for polygon in loops {
1445            let mut image_hits = 0usize;
1446            for image in images {
1447                match point_in_polygon(image, polygon, tolerance) {
1448                    PolygonClass::Boundary => return Some(PolygonClass::Boundary),
1449                    PolygonClass::Inside => image_hits += 1,
1450                    PolygonClass::Outside => {}
1451                }
1452            }
1453            crossings += image_hits % 2;
1454        }
1455    }
1456    let class = if crossings % 2 == 1 {
1457        PolygonClass::Inside
1458    } else {
1459        PolygonClass::Outside
1460    };
1461    if debug {
1462        eprintln!(
1463            "horizon: face {} loops={} straddling -> {:?}",
1464            face.id,
1465            loops.len(),
1466            class
1467        );
1468    }
1469    Some(class)
1470}
1471
1472/// Classify a spherical cap represented by a varying full-wrap contact rim and
1473/// the oppositely wound collapsed rim at one sphere pole.  This is the natural
1474/// topology when a circle about an oblique axis encloses a pole of the sphere's
1475/// stored parameter frame.
1476fn prepare_winding_sphere_cap(face: &FaceRecord, prepared: &PreparedFace) -> Result<Option<SphereCapPrep>, String> {
1477    if !matches!(
1478        face.surface.analytic(),
1479        Some(crate::AnalyticSurface::Sphere { .. })
1480    ) || face.surface.closed_directions()? != (true, false)
1481        || face.loops.len() != 2
1482    {
1483        return Ok(None);
1484    }
1485    let [u0, u1] = face.surface.domain_u()?;
1486    let [v0, v1] = face.surface.domain_v()?;
1487    let period = u1 - u0;
1488    let v_span = v1 - v0;
1489    if !(period > 0.0 && v_span > 0.0) {
1490        return Ok(None);
1491    }
1492    struct WindingLoop {
1493        points: Vec<Vec2>,
1494        winding: f64,
1495        vmin: f64,
1496        vmax: f64,
1497    }
1498    let mut loops = Vec::with_capacity(2);
1499    for loop_index in 0..face.loops.len() {
1500        let points = loop_samples(prepared, face, loop_index)?;
1501        if points.len() < 2 {
1502            return Ok(None);
1503        }
1504        let first = points[0];
1505        let mut cursor = first;
1506        let mut unwrapped = vec![cursor];
1507        for next in points.iter().skip(1) {
1508            let du = next.x - cursor.x;
1509            let folded = du - period * (du / period).round();
1510            cursor = Vec2 {
1511                x: cursor.x + folded,
1512                y: next.y,
1513            };
1514            unwrapped.push(cursor);
1515        }
1516        let last_raw = *points.last().unwrap();
1517        let closing_du = first.x - last_raw.x;
1518        let closing_folded = closing_du - period * (closing_du / period).round();
1519        let winding = cursor.x + closing_folded - first.x;
1520        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
1521        for p in &unwrapped {
1522            vmin = vmin.min(p.y);
1523            vmax = vmax.max(p.y);
1524        }
1525        loops.push(WindingLoop {
1526            points: unwrapped,
1527            winding,
1528            vmin,
1529            vmax,
1530        });
1531    }
1532    if loops
1533        .iter()
1534        .any(|loop_data| (loop_data.winding.abs() - period).abs() > 0.05 * period)
1535        || loops[0].winding * loops[1].winding >= 0.0
1536    {
1537        return Ok(None);
1538    }
1539    let flat = |loop_data: &WindingLoop| loop_data.vmax - loop_data.vmin <= 1e-6 * v_span;
1540    let (pole, rim) = match (flat(&loops[0]), flat(&loops[1])) {
1541        (true, false) => (&loops[0], &loops[1]),
1542        (false, true) => (&loops[1], &loops[0]),
1543        _ => return Ok(None),
1544    };
1545    let pole_v = 0.5 * (pole.vmin + pole.vmax);
1546    if (pole_v - v0).abs() > 1e-6 * v_span && (pole_v - v1).abs() > 1e-6 * v_span {
1547        return Ok(None);
1548    }
1549    Ok(Some(SphereCapPrep {
1550        rim: rim.points.clone(),
1551        pole_v,
1552        period,
1553    }))
1554}
1555
1556fn winding_sphere_cap_point_in_face(
1557    prepared: &SphereCapPrep,
1558    point: Vec2,
1559    tolerance: f64,
1560) -> Option<PolygonClass> {
1561    let SphereCapPrep { rim, pole_v, period } = prepared;
1562    let (pole_v, period) = (*pole_v, *period);
1563    let window_start = rim[0].x.min(rim[rim.len() - 1].x);
1564    let query_u = window_start + (point.x - window_start).rem_euclid(period);
1565    let mut crossings = Vec::new();
1566    for pair in rim.windows(2) {
1567        let (a, b) = (pair[0], pair[1]);
1568        if (a.x > query_u) != (b.x > query_u) {
1569            crossings.push(a.y + (query_u - a.x) / (b.x - a.x) * (b.y - a.y));
1570        }
1571        for image in [-period, 0.0, period] {
1572            if point_segment_distance(
1573                Vec2 {
1574                    x: point.x + image,
1575                    y: point.y,
1576                },
1577                a,
1578                b,
1579            ) <= tolerance
1580            {
1581                return Some(PolygonClass::Boundary);
1582            }
1583        }
1584    }
1585    let rim_v = crossings
1586        .into_iter()
1587        .min_by(|a, b| (a - point.y).abs().total_cmp(&(b - point.y).abs()))?;
1588    let inside = if pole_v < rim_v {
1589        point.y <= rim_v + tolerance
1590    } else {
1591        point.y >= rim_v - tolerance
1592    };
1593    Some(if inside {
1594        PolygonClass::Inside
1595    } else {
1596        PolygonClass::Outside
1597    })
1598}
1599
1600/// Point-in-face for a SINGLY-PERIODIC (closed-u) FULL-BAND STRIP whose two
1601/// full-wrap rim loops are drawn as COVERING-PLANE pcurves running OUT OF the
1602/// u-domain by up to a whole period (case 08:10-genus face 1513, the 4th seam
1603/// representation after helmet-unwrapped / band-complement / 675-hops): the
1604/// raw even-odd sees u-winding loop polygons whose parity is meaningless, so
1605/// nearly the whole material strip reads Outside, the marched clip crumbles
1606/// the section curves to sub-mm bits, and the neighbours strand one-use.
1607///
1608/// The material region is the strip BETWEEN the two rims: on an open-v
1609/// surface a 2-loop face whose loops both wrap the full period has no other
1610/// representable region. The verdict is a sampled per-u hull — each rim is a
1611/// single periodic polyline v = rim(u) in the covering plane, and the query
1612/// point is between the rims iff an upward (+v) vertical ray at the query's
1613/// u (folded modulo the period into each rim's own one-period window) crosses
1614/// the two rim polylines an odd number of times in total. NO flat-level
1615/// approximation is used (the rims are scalloped: 1513's upper rim is flat at
1616/// v=1 on parts of u but dips to v=0.745 elsewhere — a v-between-extremes
1617/// rule is provably wrong on it).
1618///
1619/// STRUCTURAL gates (all must hold; anything else returns None and keeps the
1620/// previous classification bit-identically):
1621///   - surface closed in exactly u; exactly 2 loops;
1622///   - at least one loop's samples run OUT of the u-domain by >1e-3 period
1623///     (the covering-plane discriminator — in-domain 2-rim bands keep their
1624///     current path);
1625///   - each loop's unwrapped net u-travel is exactly ±one period and its v
1626///     closes (a true full-wrap rim, not a partial arc);
1627///   - opposite travel directions, disjoint v-hulls, and the material-left
1628///     winding rule confirming the BETWEEN strip (a complement verdict cannot
1629///     be represented on an open-v surface — decline, never guess).
1630/// Escape hatch: BREP_COVERING_RIM_STRIP=0.
1631fn prepare_covering_rim_strip(face: &FaceRecord) -> Result<Option<RimStripPrep>, String> {
1632    if face.surface.closed_directions()? != (true, false) || face.loops.len() != 2 {
1633        return Ok(None);
1634    }
1635    if !switch!(COVERING_RIM_STRIP_SWITCH, true) {
1636        return Ok(None);
1637    }
1638    let [u0, u1] = face.surface.domain_u()?;
1639    let [v0, v1] = face.surface.domain_v()?;
1640    let period = u1 - u0;
1641    if !(period > 0.0) {
1642        return Ok(None);
1643    }
1644    let v_span = (v1 - v0).abs().max(1e-30);
1645    struct Rim {
1646        points: Vec<Vec2>,
1647        vmin: f64,
1648        vmax: f64,
1649        ascending: bool,
1650    }
1651    let mut rims: Vec<Rim> = Vec::with_capacity(2);
1652    let mut out_of_domain = false;
1653    for loop_record in &face.loops {
1654        let mut points: Vec<Vec2> = Vec::new();
1655        for coedge in &loop_record.coedges {
1656            let curve = &coedge.pcurve;
1657            let [start, end] = curve.domain()?;
1658            let sample_count = trim_sample_count(curve);
1659            for index in 0..=sample_count {
1660                let parameter = trim_station(start, end, index, sample_count);
1661                let evaluated = curve.evaluate(parameter)?;
1662                points.push(Vec2 {
1663                    x: evaluated.x,
1664                    y: evaluated.y,
1665                });
1666            }
1667        }
1668        if points.len() < 3 {
1669            return Ok(None);
1670        }
1671        // Covering-plane discriminator: this lane exists for loops drawn PAST
1672        // the domain; everything in-domain keeps the existing classifiers.
1673        for p in &points {
1674            if p.x < u0 - 1e-3 * period || p.x > u1 + 1e-3 * period {
1675                out_of_domain = true;
1676            }
1677        }
1678        // Unwrap into the covering plane (period-folded deltas — a no-op for
1679        // an already-continuous covering-plane representation, and the same
1680        // fold the horizon lane uses for in-domain seam hops).
1681        let mut unwrapped: Vec<Vec2> = Vec::with_capacity(points.len());
1682        let mut cursor = points[0];
1683        unwrapped.push(cursor);
1684        for pair in points.windows(2) {
1685            let du = pair[1].x - pair[0].x;
1686            let folded = du - period * (du / period).round();
1687            cursor = Vec2 {
1688                x: cursor.x + folded,
1689                y: pair[1].y,
1690            };
1691            unwrapped.push(cursor);
1692        }
1693        let net = unwrapped[unwrapped.len() - 1].x - unwrapped[0].x;
1694        if (net.abs() - period).abs() > 2e-2 * period {
1695            return Ok(None); // not a clean single full wrap
1696        }
1697        if (unwrapped[unwrapped.len() - 1].y - unwrapped[0].y).abs() > 1e-3 * v_span {
1698            return Ok(None); // rim does not close in v
1699        }
1700        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
1701        for p in &unwrapped {
1702            vmin = vmin.min(p.y);
1703            vmax = vmax.max(p.y);
1704        }
1705        rims.push(Rim {
1706            points: unwrapped,
1707            vmin,
1708            vmax,
1709            ascending: net > 0.0,
1710        });
1711    }
1712    if !out_of_domain {
1713        return Ok(None);
1714    }
1715    if rims[0].ascending == rims[1].ascending {
1716        return Ok(None); // rims must traverse opposite u directions
1717    }
1718    let (lower, upper) = if rims[0].vmax <= rims[1].vmin {
1719        (&rims[0], &rims[1])
1720    } else if rims[1].vmax <= rims[0].vmin {
1721        (&rims[1], &rims[0])
1722    } else {
1723        return Ok(None); // interleaved v-hulls: not a clean strip
1724    };
1725    if upper.vmin - lower.vmax <= 1e-6 * v_span {
1726        return Ok(None);
1727    }
1728    // Material-left rule (same convention as the doubly-periodic band lanes):
1729    // the between strip is CCW in uv iff the LOWER rim travels +u; that must
1730    // match the face sense, else the material would be the complement — which
1731    // an open-v surface cannot represent. Decline rather than guess.
1732    if (lower.ascending) != face.same_sense {
1733        return Ok(None);
1734    }
1735    Ok(Some(RimStripPrep {
1736        lower: lower.points.clone(),
1737        upper: upper.points.clone(),
1738        period,
1739    }))
1740}
1741
1742fn covering_rim_strip_point_in_face(
1743    prepared: &RimStripPrep,
1744    point: Vec2,
1745    tolerance: f64,
1746) -> Option<PolygonClass> {
1747    let RimStripPrep { lower, upper, period } = prepared;
1748    let period = *period;
1749    // Boundary: proximity to either rim polyline at the query's period images.
1750    for rim in [lower, upper] {
1751        for shift in [-period, 0.0, period] {
1752            let image = Vec2 {
1753                x: point.x + shift,
1754                y: point.y,
1755            };
1756            for pair in rim.windows(2) {
1757                if point_segment_distance(image, pair[0], pair[1]) <= tolerance {
1758                    return Some(PolygonClass::Boundary);
1759                }
1760            }
1761        }
1762    }
1763    // Sampled per-u hull: fold the query into each rim's own one-period
1764    // window and count upward-ray crossings; odd total = between the rims.
1765    let mut crossings = 0usize;
1766    for rim in [lower, upper] {
1767        let window_base = rim[0].x.min(rim[rim.len() - 1].x);
1768        let x = window_base + (point.x - window_base).rem_euclid(period);
1769        for pair in rim.windows(2) {
1770            let (a, b) = (pair[0], pair[1]);
1771            if (a.x > x) != (b.x > x) {
1772                let v_cross = a.y + (x - a.x) / (b.x - a.x) * (b.y - a.y);
1773                if v_cross > point.y {
1774                    crossings += 1;
1775                }
1776            }
1777        }
1778    }
1779    Some(if crossings % 2 == 1 {
1780        PolygonClass::Inside
1781    } else {
1782        PolygonClass::Outside
1783    })
1784}
1785
1786/// The generic scan's trim samples: every pcurve at `(interior knots + 1) ×
1787/// (degree + 1) × 4` chords, the closed polygon per loop, and the chord
1788/// parameters behind every polygon edge.
1789fn prepare_generic(face: &FaceRecord, prepared: &PreparedFace) -> Result<GenericPrep, String> {
1790    let slots = coedge_slots(face);
1791    let mut flat = 0usize;
1792    let mut loops: Vec<LoopSamples> = Vec::with_capacity(face.loops.len());
1793    let mut segments: Vec<Vec<SegmentPrep>> = Vec::with_capacity(face.loops.len());
1794    let mut chord_count = 0;
1795    for (loop_index, loop_record) in face.loops.iter().enumerate() {
1796        let polygon = loop_samples(prepared, face, loop_index)?;
1797        let mut chords = Vec::with_capacity(polygon.len());
1798        let mut coedges = Vec::with_capacity(loop_record.coedges.len());
1799        let mut first = 0;
1800        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
1801            let curve = &coedge.pcurve;
1802            let [start, end] = curve.domain()?;
1803            let sample_count = trim_sample_count(curve);
1804            for index in 0..sample_count {
1805                let parameter = trim_station(start, end, index, sample_count);
1806                let parameter_end = if index + 1 < sample_count {
1807                    trim_station(start, end, index + 1, sample_count)
1808                } else {
1809                    end
1810                };
1811                chords.push(SegmentPrep {
1812                    coedge_index,
1813                    parameter_start: parameter,
1814                    parameter_end,
1815                });
1816            }
1817            coedges.push((slots[flat] as u64, first, sample_count));
1818            flat += 1;
1819            first += sample_count;
1820        }
1821        debug_assert_eq!(first, polygon.len());
1822        chord_count += chords.len();
1823        loops.push(LoopSamples { polygon, coedges });
1824        segments.push(chords);
1825    }
1826    containment_profile(|p| p.generic_chords += chord_count as u64);
1827    Ok(GenericPrep {
1828        loops,
1829        segments,
1830        chord_count,
1831        index: std::cell::OnceCell::new(),
1832    })
1833}
1834
1835/// One trim loop as [`parameter_point_in_face`] already sampled it: the closed
1836/// polygon it counts parity against, plus `(edge slot, first sample, sample count)`
1837/// per coedge in loop order (`coedge_slots`), which is all the spherical lane needs to drop a slit
1838/// and to rebuild the boundary as SEGMENTS rather than as one ring.
1839struct LoopSamples {
1840    polygon: std::rc::Rc<Vec<Vec2>>,
1841    coedges: Vec<(u64, usize, usize)>,
1842}
1843
1844/// Point-in-face for a SPHERICAL carrier, decided on the ball itself instead of
1845/// in its polar parameter domain — but ONLY in the far field, where the generic
1846/// path has nothing better than a parity count.
1847///
1848/// The generic classifier below does not really answer by parity.  Whenever the
1849/// query has a nearest trim segment whose foot is interior to that pcurve, it
1850/// answers by which SIDE of that curve the point is on, which is locally exact
1851/// and is what the imprint and the fragment builder are tuned against.  Parity is
1852/// its fallback for points further from every trim segment than that segment is
1853/// long — and parity is exactly what the polar domain breaks: a loop enclosing a
1854/// pole does not enclose it in `uv` (it wraps the domain instead), and a region
1855/// straddling the seam is not one polygon there at all.  Three separate special
1856/// cases in this file exist because of that.
1857///
1858/// So this lane engages on the complement of the refinement's condition: it
1859/// replaces the parity count and nothing else.  Material is the intersection of
1860/// the regions to the LEFT of each trim loop, and the side a point falls on is
1861/// the sign of the signed solid angle that loop subtends there — a question with
1862/// no parameter domain in it, so pole and seam need no mention.  A collapsed pole
1863/// loop has no area and a seam is traversed once each way; both cancel exactly.
1864///
1865/// [`crate::sphere_chart::SphericalRegion`] is the same classifier the chart
1866/// tessellation uses, so the trim query and the mesh cannot disagree about where
1867/// the material is.
1868fn sphere_chart_point_in_face(
1869    face: &FaceRecord,
1870    point: Vec2,
1871    prepared: &PreparedFace,
1872    sampled: &[LoopSamples],
1873) -> Result<Option<PolygonClass>, String> {
1874    let Some(atlas) = sphere_chart_atlas(face) else {
1875        return Ok(None);
1876    };
1877    if sphere_region_reduced_enabled() {
1878        let reduced = lane(&prepared.sphere_region_reduced, || build_sphere_region_reduced(face, &atlas, sampled))?;
1879        if let Some((region, margin)) = reduced {
1880            if !region.is_whole_sphere() && region.is_decidable() {
1881                let probe = face.surface.evaluate(point.x, point.y)?;
1882                if region.clear_of_boundary(atlas.centre, probe, *margin) {
1883                    return Ok(Some(if region.contains(atlas.centre, probe) {
1884                        PolygonClass::Inside
1885                    } else {
1886                        PolygonClass::Outside
1887                    }));
1888                }
1889            }
1890        }
1891    }
1892    let region = lane(&prepared.sphere_region, || build_sphere_region(face, &atlas, sampled))?;
1893    if region.is_whole_sphere() {
1894        return Ok(Some(PolygonClass::Inside));
1895    }
1896    if !region.is_decidable() {
1897        // No seed: decline to the generic path rather than answer Inside for
1898        // the whole ball.
1899        return Ok(None);
1900    }
1901    let probe = face.surface.evaluate(point.x, point.y)?;
1902    Ok(Some(if region.contains(atlas.centre, probe) {
1903        PolygonClass::Inside
1904    } else {
1905        PolygonClass::Outside
1906    }))
1907}
1908
1909/// `BREP_SPHERE_REGION_REDUCED` (default on, `=0` restores): see
1910/// [`build_sphere_region_reduced`].
1911fn sphere_region_reduced_enabled() -> bool {
1912    crate::switch!("BREP_SPHERE_REGION_REDUCED", true)
1913}
1914
1915/// Trims with fewer samples than this build only the full region.
1916const REDUCED_REGION_SAMPLES: usize = 4096;
1917
1918/// [`build_sphere_region`] from a reduced boundary, and the margin (radians on the
1919/// unit sphere) within which its answer may differ from the full region's.
1920///
1921/// The trim samples come from each pcurve's knots, not from the curve: a dimple's
1922/// rim on a ball, an exact circle, carries a fitted pcurve of 270 to 880 control
1923/// points and so 4 000 to 14 000 samples, and brep-bench boolean/golf-ball's ball
1924/// face after 54 cuts had 540 000, which every cut's imprint rebuilt the region
1925/// from (0.55 s a build, 50 of the case's 85 s). Each coedge's samples are kept
1926/// at a stride `k` counted from both of its ends (sample `i` stays when
1927/// `min(i, n - 1 - i)` is a multiple of `k`), so a coedge and its reverse keep the
1928/// same samples whatever their rounding; `k` is the largest of 32, 16, 8, 4, 2
1929/// whose dropped samples all lie within a tenth of the coedge's median chord
1930/// angle of the kept arcs, MEASURED. The regions differ only within the largest
1931/// such deviation of the kept arcs (plus the 1e-9 canonicalization of both), so a
1932/// point farther than twice it from every kept arc is material in one exactly
1933/// when it is in the other ([`SphericalRegion::clear_of_boundary`]); any other
1934/// point is read on the full region, built then.
1935fn build_sphere_region_reduced(
1936    face: &FaceRecord,
1937    atlas: &crate::sphere_chart::SphereAtlas,
1938    sampled: &[LoopSamples],
1939) -> Result<Option<(crate::sphere_chart::SphericalRegion, f64)>, String> {
1940    let total: usize = sampled.iter().map(|samples| samples.polygon.len()).sum();
1941    if total < REDUCED_REGION_SAMPLES {
1942        return Ok(None);
1943    }
1944    let mut uses: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
1945    for slot in coedge_slots(face) {
1946        *uses.entry(slot as u64).or_insert(0) += 1;
1947    }
1948    let outward_face_normal = face.same_sense == atlas.parameterization_is_outward(&face.surface)?;
1949    let memo = sphere_reduction_memo_enabled();
1950    let carrier = memo.then(|| {
1951        let mut lane = crate::mass_properties::TwoLane::new();
1952        crate::mass_properties::hash_surface(&mut lane, &face.surface);
1953        lane.finish()
1954    });
1955    let mut points: Vec<Vec3> = Vec::new();
1956    let mut spans: Vec<(usize, usize)> = Vec::new();
1957    let mut deviation = 0.0f64;
1958    for samples in sampled {
1959        let count = samples.polygon.len();
1960        if count < 2 {
1961            continue;
1962        }
1963        for &(edge_id, first, span) in &samples.coedges {
1964            if span == 0 || uses.get(&edge_id).copied().unwrap_or(0) >= 2 {
1965                continue;
1966            }
1967            let key = carrier.map(|carrier| {
1968                let mut lane = crate::mass_properties::TwoLane::new();
1969                lane.u64(carrier[0]);
1970                lane.u64(carrier[1]);
1971                lane.usize(span);
1972                for offset in 0..=span {
1973                    let uv = samples.polygon[(first + offset) % count];
1974                    lane.f64(uv.x);
1975                    lane.f64(uv.y);
1976                }
1977                lane.finish()
1978            });
1979            if let Some(served) = key.and_then(|key| SPHERE_REDUCTIONS.with(|memo| memo.borrow().get(&key).cloned())) {
1980                let Some((kept, kept_deviation)) = served else {
1981                    return Ok(None);
1982                };
1983                deviation = deviation.max(kept_deviation);
1984                let start = points.len();
1985                points.extend(kept.iter().copied());
1986                spans.push((start, points.len()));
1987                continue;
1988            }
1989            let reduced = reduce_sphere_coedge(face, atlas, &samples.polygon, first, span)?;
1990            if let Some(key) = key {
1991                SPHERE_REDUCTIONS.with(|memo| {
1992                    let mut memo = memo.borrow_mut();
1993                    if memo.len() >= SPHERE_REDUCTION_ENTRIES {
1994                        memo.clear();
1995                    }
1996                    memo.insert(key, reduced.clone());
1997                });
1998            }
1999            let Some((kept, kept_deviation)) = reduced else {
2000                return Ok(None);
2001            };
2002            deviation = deviation.max(kept_deviation);
2003            let start = points.len();
2004            points.extend(kept.iter().copied());
2005            spans.push((start, points.len()));
2006        }
2007    }
2008    if 2 * points.len() > total {
2009        return Ok(None);
2010    }
2011    crate::sphere_chart::canonicalize_points(&mut points, 1e-9);
2012    let mut boundary: Vec<(Vec3, Vec3)> = Vec::new();
2013    for (first, last) in spans {
2014        for index in first..last.saturating_sub(1) {
2015            boundary.push((points[index], points[index + 1]));
2016        }
2017    }
2018    let region = crate::sphere_chart::SphericalRegion::from_segments(atlas.centre, &boundary, outward_face_normal);
2019    let rounding = 4e-9 / atlas.radius.max(f64::MIN_POSITIVE);
2020    Ok(Some((region, 2.0 * deviation + rounding)))
2021}
2022
2023/// One coedge's reduced boundary for [`build_sphere_region_reduced`]: its
2024/// samples `polygon[first..=first + span]` (cyclically) on the ball, kept at the
2025/// largest stride whose dropped samples lie within a tenth of the median chord
2026/// angle of the kept arcs, and that largest deviation; None when a sample is at
2027/// the centre. A function of the carrier and those samples alone.
2028fn reduce_sphere_coedge(
2029    face: &FaceRecord,
2030    atlas: &crate::sphere_chart::SphereAtlas,
2031    polygon: &[Vec2],
2032    first: usize,
2033    span: usize,
2034) -> Result<Option<(std::rc::Rc<Vec<Vec3>>, f64)>, String> {
2035    use crate::sphere_chart::{arc_distance, arc_normal};
2036    let count = polygon.len();
2037    let mut along: Vec<Vec3> = Vec::with_capacity(span + 1);
2038    for offset in 0..=span {
2039        let uv = polygon[(first + offset) % count];
2040        along.push(face.surface.evaluate(uv.x, uv.y)?);
2041    }
2042    let Ok(units) = along.iter().map(|p| p.sub(atlas.centre).normalized()).collect::<Result<Vec<_>, _>>() else {
2043        return Ok(None);
2044    };
2045    let last = along.len() - 1;
2046    let mut chords: Vec<f64> = units.windows(2).map(|pair| pair[0].sub(pair[1]).length()).collect();
2047    chords.sort_by(f64::total_cmp);
2048    let target = 0.1 * chords[chords.len() / 2];
2049    let mut kept: Vec<usize> = (0..=last).collect();
2050    let mut kept_deviation = 0.0f64;
2051    for stride in [32usize, 16, 8, 4, 2] {
2052        if last < 2 * stride {
2053            continue;
2054        }
2055        let indices: Vec<usize> = (0..=last).filter(|&i| i.min(last - i) % stride == 0).collect();
2056        let mut worst = 0.0f64;
2057        for pair in indices.windows(2) {
2058            let (a, b) = (units[pair[0]], units[pair[1]]);
2059            let normal = arc_normal(a, b);
2060            for j in pair[0] + 1..pair[1] {
2061                worst = worst.max(arc_distance(a, b, normal, units[j]));
2062            }
2063        }
2064        if worst <= target {
2065            kept = indices;
2066            kept_deviation = worst;
2067            break;
2068        }
2069    }
2070    Ok(Some((std::rc::Rc::new(kept.iter().map(|&i| along[i]).collect()), kept_deviation)))
2071}
2072
2073/// `BREP_SPHERE_REDUCTION_MEMO` (default on, `=0` reduces every coedge afresh):
2074/// [`build_sphere_region_reduced`] serves a coedge's reduction from
2075/// [`SPHERE_REDUCTIONS`]. brep-bench boolean/golf-ball: every cut changes the
2076/// ball face, so its first query rebuilt the reduced region from all 540 000
2077/// trim samples of its 55 dimple rims, 54 of them the previous cut's (4.3 s of
2078/// the case's 40 s); now each cut reduces its one new rim.
2079fn sphere_reduction_memo_enabled() -> bool {
2080    let enabled = crate::switch!("BREP_SPHERE_REDUCTION_MEMO", true);
2081    enabled
2082}
2083
2084
2085/// Bound on [`SPHERE_REDUCTIONS`]; the memo is emptied when it is reached.
2086const SPHERE_REDUCTION_ENTRIES: usize = 4096;
2087
2088thread_local! {
2089    /// [`reduce_sphere_coedge`]'s result per coedge, keyed by the carrier's
2090    /// identity (`hash_surface`, which fixes the atlas) and the bits of the
2091    /// coedge's samples, everything the reduction reads.
2092    static SPHERE_REDUCTIONS: std::cell::RefCell<std::collections::HashMap<[u64; 2], Option<(std::rc::Rc<Vec<Vec3>>, f64)>>> =
2093        std::cell::RefCell::new(std::collections::HashMap::new());
2094}
2095
2096/// The spherical region [`sphere_chart_point_in_face`] reads `face` against,
2097/// built ONCE per prepared face.
2098///
2099/// A region depends on the face's sphere, its orientation and its trim samples
2100/// — never on the query point — but the trim query is called once PER POINT
2101/// from the innermost loops of the imprint, the fragment builder and the
2102/// classifier's ray casts, and building one costs a surface evaluation per
2103/// boundary sample, a 27-cell canonicalization pass, two hash maps and a seed
2104/// search that can run two dozen crossing counts. Rebuilding that per query is
2105/// the whole cost of this lane; the query itself is one crossing count.
2106///
2107/// It lives on the [`PreparedFace`], whose cache key already holds everything
2108/// the region is built from. It used to have a four-entry cache of its own, and
2109/// a ray cast visits every face its segment's box touches in the same order
2110/// every time: a ball cut into seven faces by a helical tube (the 2026-09-26
2111/// coil fillet report) cycled that cache and missed on every query, 20-35 ms a
2112/// trim test, and the fillet's interference check ran 147 s instead of 30.
2113fn build_sphere_region(
2114    face: &FaceRecord,
2115    atlas: &crate::sphere_chart::SphereAtlas,
2116    sampled: &[LoopSamples],
2117) -> Result<crate::sphere_chart::SphericalRegion, String> {
2118    // An edge used TWICE by one face is a slit — the parametric seam of a ball
2119    // the trim never cut. It bounds no material, so it is left out of the region
2120    // rather than cancelled numerically afterwards.
2121    let mut uses: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
2122    for slot in coedge_slots(face) {
2123        *uses.entry(slot as u64).or_insert(0) += 1;
2124    }
2125    // Boundary SEGMENTS, not one polyline per loop.
2126    //
2127    // A ball drilled through carries BOTH rims in a single loop, joined by the
2128    // seam traversed once each way — a keyhole. Concatenating that loop's
2129    // surviving coedges and closing the ring would bridge rim to rim with two
2130    // chords that are not reverses of each other, and the winding of that figure
2131    // is not the winding of the two rims: on a through-drilled ball it comes out
2132    // exactly inverted. Handing the classifier the segments themselves lets it
2133    // cancel the seam and recover the two real rims.
2134    //
2135    // The samples are the CALLER's, taken once for its own parity count and its
2136    // own nearest-segment scan. Sampling the pcurves again here doubled the cost
2137    // of every query on a spherical face, and the caller only reaches this lane
2138    // when it would otherwise answer by parity — so that second pass was paid on
2139    // nearly every query and used on almost none of them.
2140    //
2141    // They are sampled in each coedge's own traversal direction, which is the
2142    // direction its pcurve is parameterized in: `forward` selects which end of
2143    // the EDGE's samples a traversal starts from, it does not reverse the
2144    // pcurve. A loop walked backwards puts the material on the wrong side of
2145    // every boundary.
2146    // The material-left convention is stated against the FACE normal, which the
2147    // generic path encodes as `(cross > 0) == same_sense` in uv: material is left
2148    // of the boundary seen from `same_sense ? Su x Sv : -(Su x Sv)`.
2149    let outward_face_normal =
2150        face.same_sense == atlas.parameterization_is_outward(&face.surface)?;
2151    let evaluate_started = containment_profile_started();
2152    let mut points: Vec<Vec3> = Vec::new();
2153    let mut spans: Vec<(usize, usize)> = Vec::new();
2154    for samples in sampled {
2155        let count = samples.polygon.len();
2156        if count < 2 {
2157            continue;
2158        }
2159        for &(edge_id, first, span) in &samples.coedges {
2160            if span == 0 || uses.get(&edge_id).copied().unwrap_or(0) >= 2 {
2161                continue;
2162            }
2163            // `span + 1` points: the coedge's own samples plus the first
2164            // sample of the next coedge, which is this one's END point. The
2165            // loop is closed, so the last coedge wraps back to `polygon[0]`.
2166            let start = points.len();
2167            for offset in 0..=span {
2168                let uv = samples.polygon[(first + offset) % count];
2169                points.push(face.surface.evaluate(uv.x, uv.y)?);
2170            }
2171            spans.push((start, points.len()));
2172        }
2173    }
2174    // One canonicalization over ALL the face's points: a vertex named by
2175    // two coedges is evaluated twice and the two answers agree only to
2176    // rounding, which the exact-reverse cancellation and the loop walk
2177    // both need collapsed.
2178    let evaluate_ms = containment_elapsed_ms(evaluate_started);
2179    let canonical_started = containment_profile_started();
2180    crate::sphere_chart::canonicalize_points(&mut points, 1e-9);
2181    let canonical_ms = containment_elapsed_ms(canonical_started);
2182    let segments_started = containment_profile_started();
2183    let mut boundary: Vec<(Vec3, Vec3)> = Vec::new();
2184    for (first, last) in spans {
2185        for index in first..last.saturating_sub(1) {
2186            boundary.push((points[index], points[index + 1]));
2187        }
2188    }
2189    let region = crate::sphere_chart::SphericalRegion::from_segments(
2190        atlas.centre,
2191        &boundary,
2192        outward_face_normal,
2193    );
2194    let segments_ms = containment_elapsed_ms(segments_started);
2195    containment_profile(|p| {
2196        p.region_builds += 1;
2197        p.region_evaluate_ms += evaluate_ms;
2198        p.region_canonical_ms += canonical_ms;
2199        p.region_segments_ms += segments_ms;
2200    });
2201    Ok(region)
2202}
2203
2204
2205/// The sphere the sphere-chart lane reads a face on: an analytic sphere, with
2206/// the lane's switches on ([`sphere_chart_trim_enabled`]).
2207fn sphere_chart_atlas(face: &FaceRecord) -> Option<crate::sphere_chart::SphereAtlas> {
2208    if !sphere_chart_trim_enabled() {
2209        return None;
2210    }
2211    crate::sphere_chart::SphereAtlas::of_surface(&face.surface)
2212}
2213
2214/// Whether `BREP_DEBUG_CONTAINMENT` is set, read once.  `parameter_point_in_face`
2215/// runs per ray-face intersection inside every boolean, so the cost when the
2216/// trace is off must be one `OnceLock` read, not an environment lookup per exit.
2217fn containment_trace_enabled() -> bool {
2218    debug_flag!("BREP_DEBUG_CONTAINMENT")
2219}
2220
2221/// Name the lane that answered, under `BREP_DEBUG_CONTAINMENT`.  This function
2222/// has seven exits and which one fired is the first thing any investigation of a
2223/// wrong containment verdict needs; reconstructing it from the outside means
2224/// re-deriving each lane's gate by hand.
2225fn trace_lane(face: &FaceRecord, point: Vec2, lane: &str, class: PolygonClass) {
2226    if containment_trace_enabled() {
2227        eprintln!(
2228            "containment face={} uv=({:.8},{:.8}) lane={lane} -> {class:?}",
2229            face.id, point.x, point.y
2230        );
2231    }
2232}
2233
2234
2235/// A lane [`parameter_point_in_face`] answers in, with what it prepared.
2236enum Lane<'a> {
2237    SeamBand(&'a SeamBandPrep),
2238    SphereCap(&'a SphereCapPrep),
2239    Horizon(&'a HorizonPrep),
2240    RimStrip(&'a RimStripPrep),
2241    Generic,
2242}
2243
2244/// The first lane at dispatch position `from` or later that is prepared to
2245/// answer `face`, and its position: 0 the seam band, 1 the sphere cap, 2 the
2246/// wrapped horizon, 3 the covering rim strip, 4 the generic scan. Each lane is
2247/// prepared (and its preparation's error surfaces) only when the walk reaches
2248/// it. [`parameter_point_in_face`] walks on from the next position when a lane
2249/// declines a query; [`containment_lane`] reports where the walk starts, so the
2250/// two cannot disagree about which lane a face is in.
2251fn lane_from<'a>(
2252    face: &FaceRecord,
2253    prepared: &'a PreparedFace,
2254    from: usize,
2255) -> Result<(usize, Lane<'a>), String> {
2256    if from == 0 {
2257        let band = timed_lane(&prepared.seam_band, || prepare_seam_band(face), |p, ms| {
2258            p.seam_band_preps += 1;
2259            p.seam_band_ms += ms;
2260        })?;
2261        if !matches!(band, SeamBandPrep::NotApplicable | SeamBandPrep::Declined) {
2262            return Ok((0, Lane::SeamBand(band)));
2263        }
2264    }
2265    if from <= 1 {
2266        if let Some(cap) = timed_lane(&prepared.sphere_cap, || prepare_winding_sphere_cap(face, prepared), |p, ms| {
2267            p.sphere_cap_preps += 1;
2268            p.sphere_cap_ms += ms;
2269        })? {
2270            return Ok((1, Lane::SphereCap(cap)));
2271        }
2272    }
2273    if from <= 2 {
2274        if let Some(horizon) = timed_lane(&prepared.horizon, || prepare_wrapped_horizon(face, prepared), |p, ms| {
2275            p.horizon_preps += 1;
2276            p.horizon_ms += ms;
2277        })? {
2278            return Ok((2, Lane::Horizon(horizon)));
2279        }
2280    }
2281    if from <= 3 {
2282        if let Some(strip) = timed_lane(&prepared.rim_strip, || prepare_covering_rim_strip(face), |p, ms| {
2283            p.rim_strip_preps += 1;
2284            p.rim_strip_ms += ms;
2285        })? {
2286            return Ok((3, Lane::RimStrip(strip)));
2287        }
2288    }
2289    Ok((4, Lane::Generic))
2290}
2291
2292/// Which lane of [`parameter_point_in_face`] a face's questions go to, read off
2293/// the face alone — the kernel's own lane selection, through the same prepared
2294/// data the query uses, for a caller that answers some containment questions
2295/// ahead of the kernel and must know which rule the kernel applies.
2296///
2297/// The lanes are tried in this order, and a face is in the first whose gates it
2298/// passes (each comparand is in the carrier's parameters unless stated):
2299///
2300/// 1. [`SeamBand`](ContainmentLane::SeamBand): `closed_directions() == (true, true)`, and
2301///    the seam-band preparation reads the face — every loop a collapsed
2302///    puncture (its trace within `1e-3` of the domain span in u and in v), one
2303///    seam-crossing loop unwrapped (unless [`SEAM_BAND_MERGED_SWITCH`]), two
2304///    loops the seam-band analysis reassembles, or two clean full-wrap rims (a
2305///    periodic extent of at least `0.6` period and a cross extent of at most
2306///    `0.05` of the cross span each).
2307/// 2. [`SphereCap`](ContainmentLane::SphereCap): an analytic sphere closed in exactly u
2308///    with two loops, each winding one period to within `0.05` period and the
2309///    two opposite, one flat (a v extent within `1e-6` of the v span) at a pole
2310///    (within `1e-6` of the v span of a v end).
2311/// 3. [`WrappedHorizon`](ContainmentLane::WrappedHorizon): closed in exactly u, at least
2312///    one loop, neither [`HORIZON_CONTAINMENT_SWITCH`] nor (for one or two
2313///    loops) [`HORIZON_SINGLE_LOOP_SWITCH`], some loop of three or more samples
2314///    stepping over the seam (a sample-to-sample u step more than a quarter
2315///    period from its period-folded value), and no loop winding the period (its
2316///    unwrapped first and last samples more than a quarter period apart).
2317/// 4. [`CoveringRimStrip`](ContainmentLane::CoveringRimStrip): closed in exactly u, two
2318///    loops, not [`COVERING_RIM_STRIP_SWITCH`], a sample beyond the u domain by
2319///    more than `1e-3` period, each loop travelling one period to within `2e-2`
2320///    period and closing in v to within `1e-3` of the v span, opposite
2321///    directions, v hulls apart by more than `1e-6` of the v span, and the lower
2322///    rim travelling +u exactly when the face is `same_sense`.
2323/// 5. [`Generic`](ContainmentLane::Generic): everything else.
2324///
2325/// Sampling is [`trim_sample_count`] chords per pcurve at [`trim_station`]
2326/// (the seam band: 24). `Err` is the error the face's first query would
2327/// return.
2328///
2329/// It reads the prepared data of a face a query has prepared, and otherwise
2330/// prepares what the walk reaches on the side, leaving the thread's
2331/// prepared-face cache as it was: asked for every face of a part, it would
2332/// otherwise push out the faces the queries are using.
2333pub fn containment_lane(face: &FaceRecord) -> Result<ContainmentLane, String> {
2334    let prepared = cached_prepared_face(face).unwrap_or_else(|| std::rc::Rc::new(PreparedFace::new()));
2335    Ok(match lane_from(face, &prepared, 0)?.1 {
2336        Lane::SeamBand(_) => ContainmentLane::SeamBand,
2337        Lane::SphereCap(_) => ContainmentLane::SphereCap,
2338        Lane::Horizon(horizon) => ContainmentLane::WrappedHorizon {
2339            loops: horizon.loops.clone(),
2340            u_period: horizon.u_period,
2341            cross_frame: horizon_cross_frame(),
2342        },
2343        Lane::RimStrip(_) => ContainmentLane::CoveringRimStrip,
2344        Lane::Generic => ContainmentLane::Generic {
2345            sphere_chart: sphere_chart_atlas(face).is_some(),
2346        },
2347    })
2348}
2349
2350/// The lane [`containment_lane`] finds a face in.
2351#[derive(Clone, Debug)]
2352pub enum ContainmentLane {
2353    /// The doubly-periodic seam-band lane: answers every point of the face.
2354    SeamBand,
2355    /// The winding sphere-cap lane: answers every point within the query's
2356    /// tolerance of a chord of the rim or whose u meets one; any other point
2357    /// goes on to the lanes after it.
2358    SphereCap,
2359    /// The wrapped-horizon lane: answers every point. Within the query's
2360    /// tolerance of a chord of `loops` at `u − u_period`, `u` or `u + u_period`
2361    /// it is `Boundary`; otherwise, with `cross_frame`, each loop's even-odd
2362    /// count (crossings of +u, half-open in v) at the three images summed mod
2363    /// 2, odd being `Inside`; without it ([`HORIZON_CROSS_FRAME_SWITCH`] = `"0"`
2364    /// when this was called), `Inside` when some image counts an odd total.
2365    WrappedHorizon {
2366        /// Each loop's samples, unwrapped sample to sample across the seam:
2367        /// the lane's own polygons, closed from last sample to first.
2368        loops: Vec<Vec<Vec2>>,
2369        u_period: f64,
2370        cross_frame: bool,
2371    },
2372    /// The covering-rim-strip lane: answers every point.
2373    CoveringRimStrip,
2374    /// The generic scan: `Boundary` within the tolerance of a chord; otherwise
2375    /// the side of the nearest pcurve where the nearest chord's foot lands
2376    /// inside it and the point is no farther from that chord than the chord is
2377    /// long; otherwise the even-odd count over every loop — for which, with
2378    /// `sphere_chart`, the sphere-chart lane substitutes where it can decide.
2379    Generic {
2380        /// An analytic sphere with the sphere-chart trim lane on
2381        /// ([`NO_SPHERE_CHART_TRIM_SWITCH`]), read when this was called.
2382        sphere_chart: bool,
2383    },
2384}
2385
2386pub fn parameter_point_in_face(
2387    face: &FaceRecord,
2388    point: Vec2,
2389    tolerance: f64,
2390) -> Result<PolygonClass, String> {
2391    point_in_face_scanned(face, point, tolerance, ChordScan::Auto)
2392}
2393
2394/// [`parameter_point_in_face`] with the generic scan and the wrapped-horizon
2395/// lane read through their chord index (`indexed`, on a face of any size) or
2396/// over every chord. The two give the same answer on every point; this exists
2397/// so a test can show it.
2398#[doc(hidden)]
2399pub fn parameter_point_in_face_scan(
2400    face: &FaceRecord,
2401    point: Vec2,
2402    tolerance: f64,
2403    indexed: bool,
2404) -> Result<PolygonClass, String> {
2405    let scan = if indexed {
2406        ChordScan::Indexed
2407    } else {
2408        ChordScan::Linear
2409    };
2410    point_in_face_scanned(face, point, tolerance, scan)
2411}
2412
2413fn point_in_face_scanned(
2414    face: &FaceRecord,
2415    point: Vec2,
2416    tolerance: f64,
2417    scan: ChordScan,
2418) -> Result<PolygonClass, String> {
2419    let _wall = crate::wall_profile::Scope::enter(crate::wall_profile::CLASSIFY);
2420    let prepared = prepared_face(face);
2421    let mut from = 0;
2422    loop {
2423        let (position, lane) = lane_from(face, &prepared, from)?;
2424        let answer = match lane {
2425            Lane::SeamBand(band) => {
2426                seam_band_point_in_face(band, point, tolerance).map(|class| ("seam_band", class))
2427            }
2428            Lane::SphereCap(cap) => winding_sphere_cap_point_in_face(cap, point, tolerance)
2429                .map(|class| ("winding_sphere_cap", class)),
2430            Lane::Horizon(horizon) => wrapped_horizon_point_in_face(face, horizon, point, tolerance, scan)
2431                .map(|class| ("wrapped_horizon", class)),
2432            Lane::RimStrip(strip) => covering_rim_strip_point_in_face(strip, point, tolerance)
2433                .map(|class| ("covering_rim_strip", class)),
2434            Lane::Generic => break,
2435        };
2436        if let Some((name, class)) = answer {
2437            trace_lane(face, point, name, class);
2438            return Ok(class);
2439        }
2440        from = position + 1;
2441    }
2442    let generic = timed_lane(&prepared.generic, || prepare_generic(face, &prepared), |p, ms| {
2443        p.generic_preps += 1;
2444        p.generic_ms += ms;
2445    })?;
2446    let (crossings, boundary, nearest) = generic_scan(generic, point, tolerance, scan);
2447    if boundary {
2448        trace_lane(face, point, "polygon_boundary", PolygonClass::Boundary);
2449        return Ok(PolygonClass::Boundary);
2450    }
2451    let parity = if crossings % 2 == 1 {
2452        PolygonClass::Inside
2453    } else {
2454        PolygonClass::Outside
2455    };
2456    let chord = nearest.map(|(loop_index, index, distance)| {
2457        let polygon = &generic.loops[loop_index].polygon;
2458        let prep = generic.segments[loop_index][index];
2459        (
2460            polygon[index],
2461            polygon[(index + 1) % polygon.len()],
2462            &face.loops[loop_index].coedges[prep.coedge_index].pcurve,
2463            prep,
2464            distance,
2465        )
2466    });
2467    if containment_trace_enabled() {
2468        let (distance, length) = match chord.as_ref() {
2469            Some((start, end, _, _, distance)) => (*distance, end.sub(*start).length()),
2470            None => (f64::NAN, f64::NAN),
2471        };
2472        eprintln!(
2473            "containment face={} uv=({:.8},{:.8}) crossings={crossings} parity={parity:?} \
2474             nearest distance={distance:.3e} segment length={length:.3e}",
2475            face.id, point.x, point.y
2476        );
2477    }
2478    // The LAST of the special lanes, and the only one that engages here rather
2479    // than ahead of the scan: it substitutes for the parity count and for nothing
2480    // else. The four lanes above were each written for a shape the parity gets
2481    // wrong and are calibrated against real documents; pre-empting them would move
2482    // decisions this change has no business moving. Whenever the nearest-curve
2483    // refinement below can answer, it is more accurate than any global rule and is
2484    // what the imprint and the fragment builder are tuned against — so this asks
2485    // the sphere only on the complement of the refinement's own condition.
2486    let parity_decides = match chord.as_ref() {
2487        None => true,
2488        Some((start, end, _, _, distance)) => {
2489            let length = end.sub(*start).length();
2490            *distance > length || length <= 0.0
2491        }
2492    };
2493    if parity_decides {
2494        if let Some(class) = sphere_chart_point_in_face(face, point, &prepared, &generic.loops)? {
2495            trace_lane(face, point, "sphere_chart", class);
2496            return Ok(class);
2497        }
2498    }
2499    let Some((segment_start, segment_end, curve, prep, distance)) = chord else {
2500        trace_lane(face, point, "parity (no segment)", parity);
2501        return Ok(parity);
2502    };
2503    let segment_length = segment_end.sub(segment_start).length();
2504    if distance > segment_length || segment_length <= 0.0 {
2505        trace_lane(face, point, "parity (far from every segment)", parity);
2506        return Ok(parity);
2507    }
2508    let chord_parameter = prep.parameter_start
2509        + (prep.parameter_end - prep.parameter_start)
2510            * (point.sub(segment_start).dot(segment_end.sub(segment_start))
2511                / (segment_length * segment_length))
2512                .clamp(0.0, 1.0);
2513    let [domain_start, domain_end] = curve.domain()?;
2514    let small = crate::curve::small_curve_derivatives_enabled();
2515    let read = |parameter: f64, count: usize| -> Result<[Vec3; 3], String> {
2516        if small {
2517            return curve.derivatives_small(parameter, count);
2518        }
2519        let heap = curve.derivatives(parameter, count)?;
2520        let mut grid = [Vec3::default(); 3];
2521        grid[..heap.len()].copy_from_slice(&heap);
2522        Ok(grid)
2523    };
2524    use crate::projection::{curve_foot_newton, CurveFootPolicy, FootDomain, FootMode};
2525    let (parameter, _) = curve_foot_newton(curve, Vec3::new(point.x, point.y, 0.0),
2526        chord_parameter, [domain_start, domain_end], CurveFootPolicy {
2527            domain: FootDomain::Clamp,
2528            iterations: 12,
2529            slope_guard: 1e-30,
2530            step_limit: f64::INFINITY,
2531            parameter_tolerance: 1e-14 * (domain_end - domain_start + 1.0),
2532            mode: FootMode::Planar,
2533        })?;
2534    let margin = 1e-9 * (domain_end - domain_start);
2535    if parameter > domain_start + margin && parameter < domain_end - margin {
2536        let derivatives = read(parameter, 1)?;
2537        let on_curve = Vec2 {
2538            x: derivatives[0].x,
2539            y: derivatives[0].y,
2540        };
2541        let tangent = Vec2 {
2542            x: derivatives[1].x,
2543            y: derivatives[1].y,
2544        };
2545        let offset = point.sub(on_curve);
2546        if offset.length() <= tolerance {
2547            trace_lane(face, point, "nearest curve (on it)", PolygonClass::Boundary);
2548            return Ok(PolygonClass::Boundary);
2549        }
2550        let cross = tangent.x * offset.y - tangent.y * offset.x;
2551        if cross.abs() > 1e-30 {
2552            let class = if (cross > 0.0) == face.same_sense {
2553                PolygonClass::Inside
2554            } else {
2555                PolygonClass::Outside
2556            };
2557            trace_lane(face, point, "nearest curve (side)", class);
2558            return Ok(class);
2559        }
2560    }
2561    trace_lane(face, point, "parity (nearest curve declined)", parity);
2562    Ok(parity)
2563}
2564
2565
2566