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