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.
150pub(crate) fn 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(|| {
597            let started = containment_profile_started();
598            let built = ChordIndex::build(generic.loops.as_slice());
599            let ms = containment_elapsed_ms(started);
600            containment_profile(|p| {
601                p.index_builds += 1;
602                p.index_ms += ms;
603            });
604            built
605        }) {
606            let loops = generic.loops.as_slice();
607            let nearest = index.nearest(loops, point);
608            let boundary = nearest.is_some_and(|(_, _, distance)| distance <= tolerance);
609            let crossings = if boundary {
610                0
611            } else {
612                let mut odd = vec![false; loops.len()];
613                index.flip_crossings(loops, point, &mut odd);
614                odd.into_iter().filter(|&parity| parity).count()
615            };
616            return (crossings, boundary, nearest);
617        }
618    }
619    linear_scan(generic, point, tolerance)
620}
621
622/// Everything [`parameter_point_in_face`] derives from the FACE alone, so a
623/// face queried many times — the imprint clip asks once per marched sample,
624/// the fragment builder and the classifier's rays once per crossing — samples
625/// its trim once. Each lane is prepared the first time a query REACHES it, so
626/// an error in a lane's sampling surfaces on exactly the faces and queries it
627/// did when the lanes sampled inline.
628struct PreparedFace {
629    seam_band: std::cell::OnceCell<Result<SeamBandPrep, String>>,
630    sphere_cap: std::cell::OnceCell<Result<Option<SphereCapPrep>, String>>,
631    horizon: std::cell::OnceCell<Result<Option<HorizonPrep>, String>>,
632    rim_strip: std::cell::OnceCell<Result<Option<RimStripPrep>, String>>,
633    generic: std::cell::OnceCell<Result<GenericPrep, String>>,
634    /// The spherical trim lane's region, built from `generic`'s samples.
635    sphere_region: std::cell::OnceCell<Result<crate::sphere_chart::SphericalRegion, String>>,
636    /// Each loop's trim samples at [`trim_station`] `0..count` per pcurve, in
637    /// coedge order, taken once and shared by the sphere-cap, wrapped-horizon
638    /// and generic lanes ([`loop_samples`]); one entry per loop, filled the
639    /// first time a lane reaches that loop.
640    loop_samples: std::cell::RefCell<Vec<Option<Result<std::rc::Rc<Vec<Vec2>>, String>>>>,
641}
642
643impl PreparedFace {
644    fn new() -> Self {
645        Self {
646            seam_band: std::cell::OnceCell::new(),
647            sphere_cap: std::cell::OnceCell::new(),
648            horizon: std::cell::OnceCell::new(),
649            rim_strip: std::cell::OnceCell::new(),
650            generic: std::cell::OnceCell::new(),
651            sphere_region: std::cell::OnceCell::new(),
652            loop_samples: std::cell::RefCell::new(Vec::new()),
653        }
654    }
655}
656
657/// Loop `loop_index` of `face` sampled as the lanes sample it: every pcurve
658/// at [`trim_sample_count`] chords, [`trim_station`] `0..count`, in coedge
659/// order. The three lanes that read exactly these samples (the sphere cap,
660/// the wrapped horizon, the generic scan) used to each evaluate them again,
661/// 2 500 pcurve evaluations per pass on a box-fillet corner patch; the
662/// samples are taken the first time any lane reaches the loop and shared
663/// from then on, loop by loop, so a loop a lane never reaches (it declined
664/// on an earlier loop) is never sampled, and a sampling error surfaces at
665/// the loop and the query it did when each lane sampled for itself.
666fn loop_samples(prepared: &PreparedFace, face: &FaceRecord, loop_index: usize) -> Result<std::rc::Rc<Vec<Vec2>>, String> {
667    if let Some(Some(sampled)) = prepared.loop_samples.borrow().get(loop_index) {
668        return sampled.clone();
669    }
670    let sampled = (|| {
671        let mut points: Vec<Vec2> = Vec::new();
672        for coedge in &face.loops[loop_index].coedges {
673            let curve = &coedge.pcurve;
674            let [start, end] = curve.domain()?;
675            let sample_count = trim_sample_count(curve);
676            for index in 0..sample_count {
677                let parameter = trim_station(start, end, index, sample_count);
678                let evaluated = curve.evaluate(parameter)?;
679                points.push(Vec2 {
680                    x: evaluated.x,
681                    y: evaluated.y,
682                });
683            }
684        }
685        Ok(std::rc::Rc::new(points))
686    })();
687    let mut cells = prepared.loop_samples.borrow_mut();
688    if cells.len() < face.loops.len() {
689        cells.resize(face.loops.len(), None);
690    }
691    cells[loop_index] = Some(sampled.clone());
692    sampled
693}
694
695/// Per-thread split of what a face's FIRST query pays to prepare it, printed
696/// by [`containment_profile_report`] when `BREP_PROFILE` is set (the
697/// restriction lane and the interference scan report it beside the
698/// classifier's `classify.profile`): how many faces missed the prepared-face
699/// cache, how many of each lane's preparations ran and their wall time, the
700/// generic scan's chord index builds, and the spherical lane's region builds
701/// split into the surface evaluations, the canonicalization and the region's
702/// own construction (`from_segments`, its seed included). When the switch is
703/// off every counter is one thread-local flag test.
704#[derive(Default, Clone, Copy)]
705struct ContainmentProfile {
706    enabled: bool,
707    cache_misses: u64,
708    seam_band_preps: u64,
709    seam_band_ms: f64,
710    sphere_cap_preps: u64,
711    sphere_cap_ms: f64,
712    horizon_preps: u64,
713    horizon_ms: f64,
714    rim_strip_preps: u64,
715    rim_strip_ms: f64,
716    generic_preps: u64,
717    generic_ms: f64,
718    generic_chords: u64,
719    index_builds: u64,
720    index_ms: f64,
721    region_builds: u64,
722    region_evaluate_ms: f64,
723    region_canonical_ms: f64,
724    region_segments_ms: f64,
725}
726
727thread_local! {
728    static CONTAINMENT_PROFILE: std::cell::Cell<ContainmentProfile> =
729        const { std::cell::Cell::new(ContainmentProfile {
730            enabled: false, cache_misses: 0, seam_band_preps: 0, seam_band_ms: 0.0,
731            sphere_cap_preps: 0, sphere_cap_ms: 0.0, horizon_preps: 0, horizon_ms: 0.0,
732            rim_strip_preps: 0, rim_strip_ms: 0.0, generic_preps: 0, generic_ms: 0.0,
733            generic_chords: 0, index_builds: 0, index_ms: 0.0, region_builds: 0,
734            region_evaluate_ms: 0.0, region_canonical_ms: 0.0, region_segments_ms: 0.0,
735        }) };
736}
737
738fn containment_profile(update: impl FnOnce(&mut ContainmentProfile)) {
739    CONTAINMENT_PROFILE.with(|cell| {
740        let mut profile = cell.get();
741        if profile.enabled {
742            update(&mut profile);
743            cell.set(profile);
744        }
745    });
746}
747
748fn containment_profile_started() -> Option<web_time::Instant> {
749    CONTAINMENT_PROFILE
750        .with(|cell| cell.get().enabled)
751        .then(web_time::Instant::now)
752}
753
754fn containment_elapsed_ms(started: Option<web_time::Instant>) -> f64 {
755    started.map_or(0.0, |s| s.elapsed().as_secs_f64() * 1_000.0)
756}
757
758/// Arm the preparation counters (reset to zero) when `BREP_PROFILE` is set.
759pub(crate) fn containment_profile_begin() {
760    let enabled = std::env::var("BREP_PROFILE").is_ok();
761    CONTAINMENT_PROFILE.with(|cell| {
762        cell.set(ContainmentProfile {
763            enabled,
764            ..ContainmentProfile::default()
765        })
766    });
767}
768
769/// Print the counters gathered since [`containment_profile_begin`] under
770/// `label` and disarm them. Silent when profiling is off.
771pub(crate) fn containment_profile_report(label: &str) {
772    let p = CONTAINMENT_PROFILE.with(|cell| cell.get());
773    if !p.enabled {
774        return;
775    }
776    eprintln!(
777        "containment.profile {label} cache_misses={} seam_band: preps={} ms={:.2} sphere_cap: preps={} ms={:.2} horizon: preps={} ms={:.2} rim_strip: preps={} ms={:.2} generic: preps={} ms={:.2} chords={} index: builds={} ms={:.2} region: builds={} evaluate_ms={:.2} canonical_ms={:.2} segments_ms={:.2}",
778        p.cache_misses, p.seam_band_preps, p.seam_band_ms, p.sphere_cap_preps, p.sphere_cap_ms,
779        p.horizon_preps, p.horizon_ms, p.rim_strip_preps, p.rim_strip_ms, p.generic_preps,
780        p.generic_ms, p.generic_chords, p.index_builds, p.index_ms, p.region_builds,
781        p.region_evaluate_ms, p.region_canonical_ms, p.region_segments_ms,
782    );
783    CONTAINMENT_PROFILE.with(|cell| cell.set(ContainmentProfile::default()));
784}
785
786/// A lane's preparation, timed into the profile's `(count, ms)` pair when it
787/// is on.
788fn timed_lane<'a, T>(
789    cell: &'a std::cell::OnceCell<Result<T, String>>,
790    build: impl FnOnce() -> Result<T, String>,
791    record: fn(&mut ContainmentProfile, f64),
792) -> Result<&'a T, String> {
793    lane(cell, || {
794        let started = containment_profile_started();
795        let built = build();
796        let ms = containment_elapsed_ms(started);
797        containment_profile(|p| record(p, ms));
798        built
799    })
800}
801
802fn lane<'a, T>(
803    cell: &'a std::cell::OnceCell<Result<T, String>>,
804    build: impl FnOnce() -> Result<T, String>,
805) -> Result<&'a T, String> {
806    match cell.get_or_init(build) {
807        Ok(value) => Ok(value),
808        Err(error) => Err(error.clone()),
809    }
810}
811
812/// The prepared faces this thread queried most recently, keyed by the EXACT
813/// data the lanes read: the environment switches, the face sense, the surface's
814/// degrees, knots and control points, and per loop each coedge's edge id,
815/// direction, pcurve degree, knots and control points. A hit compares the whole
816/// signature, value by value to the bit (and never a NaN); the hash only
817/// chooses which entries to compare. It also holds the spherical lane's region
818/// (`PreparedFace::sphere_region`, [`build_sphere_region`]). Sampling a
819/// 16-coedge sphere trim at 2 600 chords per coedge four times per query was 1.5 s of
820/// `anotherBooleanFail`'s imprint clip and most of its fragment selection, for
821/// samples that never change between one query and the next.
822///
823/// The lookup is paid on every query, so it walks the face once against the
824/// stored signature and copies nothing: the hash reads the signature's shape
825/// and a few values, not all of it. Building, hashing and comparing the whole
826/// signature per query was 0.68 ms on `gear-hex-bore-push`'s face 8307 (562
827/// pcurves, 36 530 control points), which is all a query there costs once its
828/// chords are indexed.
829struct PreparedFaceCache {
830    entries: Vec<(u64, Vec<f64>, std::rc::Rc<PreparedFace>)>,
831}
832
833const PREPARED_FACE_CACHE_ENTRIES: usize = 64;
834
835thread_local! {
836    static PREPARED_FACES: std::cell::RefCell<PreparedFaceCache> = const {
837        std::cell::RefCell::new(PreparedFaceCache {
838            entries: Vec::new(),
839        })
840    };
841}
842
843/// Walks a face's signature — `switches` ([`lane_switches`], read once by the
844/// caller), then the face's data in a fixed order, every count before the
845/// values it counts — handing each value to `visit` until it returns false.
846/// Returns whether every value was visited.
847fn walk_face_signature<F: FnMut(f64) -> bool>(face: &FaceRecord, switches: f64, visit: &mut F) -> bool {
848    let surface = &face.surface;
849    let point = |visit: &mut F, p: &crate::Vec4| visit(p.x) && visit(p.y) && visit(p.z) && visit(p.w);
850    if !(visit(switches)
851        && visit(if face.same_sense { 1.0 } else { 0.0 })
852        && visit(surface.degree_u as f64)
853        && visit(surface.degree_v as f64)
854        && visit(surface.knots_u.len() as f64))
855    {
856        return false;
857    }
858    if !(surface.knots_u.iter().all(|&knot| visit(knot)) && visit(surface.knots_v.len() as f64)) {
859        return false;
860    }
861    if !(surface.knots_v.iter().all(|&knot| visit(knot)) && visit(surface.control_points.len() as f64)) {
862        return false;
863    }
864    for row in &surface.control_points {
865        if !(visit(row.len() as f64) && row.iter().all(|p| point(visit, p))) {
866            return false;
867        }
868    }
869    if !visit(face.loops.len() as f64) {
870        return false;
871    }
872    for loop_record in &face.loops {
873        if !visit(loop_record.coedges.len() as f64) {
874            return false;
875        }
876        for coedge in &loop_record.coedges {
877            let curve = &coedge.pcurve;
878            if !(visit(coedge.edge_id as f64)
879                && visit(if coedge.forward { 1.0 } else { 0.0 })
880                && visit(curve.degree as f64)
881                && visit(curve.knots.len() as f64))
882            {
883                return false;
884            }
885            if !(curve.knots.iter().all(|&knot| visit(knot)) && visit(curve.control_points.len() as f64)) {
886                return false;
887            }
888            if !curve.control_points.iter().all(|p| point(visit, p)) {
889                return false;
890            }
891        }
892    }
893    true
894}
895
896/// The cache's hash of a face: its signature's every count, the switches and
897/// sense, the surface's first and last control point, and each pcurve's
898/// first and last knot and control point, each value by its bits. Two faces
899/// whose signatures agree to the bit hash the same.
900fn face_key_hash(face: &FaceRecord, switches: f64) -> u64 {
901    use std::hash::Hasher;
902    let mut hasher = rustc_hash::FxHasher::default();
903    let mut write = |value: f64| hasher.write_u64(value.to_bits());
904    let surface = &face.surface;
905    write(switches);
906    write(if face.same_sense { 1.0 } else { 0.0 });
907    write(surface.degree_u as f64);
908    write(surface.degree_v as f64);
909    write(surface.knots_u.len() as f64);
910    write(surface.knots_v.len() as f64);
911    write(surface.control_points.len() as f64);
912    let corners = [surface.control_points.first().and_then(|row| row.first()), surface.control_points.last().and_then(|row| row.last())];
913    for p in corners.into_iter().flatten() {
914        [p.x, p.y, p.z, p.w].into_iter().for_each(&mut write);
915    }
916    write(face.loops.len() as f64);
917    for loop_record in &face.loops {
918        write(loop_record.coedges.len() as f64);
919        for coedge in &loop_record.coedges {
920            let curve = &coedge.pcurve;
921            write(coedge.edge_id as f64);
922            write(if coedge.forward { 1.0 } else { 0.0 });
923            write(curve.degree as f64);
924            write(curve.knots.len() as f64);
925            write(curve.control_points.len() as f64);
926            for knot in [curve.knots.first(), curve.knots.last()].into_iter().flatten() {
927                write(*knot);
928            }
929            for p in [curve.control_points.first(), curve.control_points.last()].into_iter().flatten() {
930                [p.x, p.y, p.z, p.w].into_iter().for_each(&mut write);
931            }
932        }
933    }
934    hasher.finish()
935}
936
937/// Where `face` is in the cache's `entries`, compared exactly.
938fn cached_entry(
939    entries: &[(u64, Vec<f64>, std::rc::Rc<PreparedFace>)],
940    face: &FaceRecord,
941    switches: f64,
942    hash: u64,
943) -> Option<usize> {
944    entries.iter().position(|(entry_hash, signature, _)| {
945        if *entry_hash != hash {
946            return false;
947        }
948        let mut at = 0;
949        let walked = walk_face_signature(face, switches, &mut |value: f64| {
950            let same = signature
951                .get(at)
952                .is_some_and(|stored| stored.to_bits() == value.to_bits() && !value.is_nan());
953            at += 1;
954            same
955        });
956        walked && at == signature.len()
957    })
958}
959
960/// Empties this thread's prepared-face cache, spherical regions included. For
961/// the boolean's operand-state instrument (`BREP_DEBUG_OPERAND_STATE=tls`) only.
962pub(crate) fn forget_prepared_faces() {
963    PREPARED_FACES.with(|cache| cache.borrow_mut().entries.clear());
964}
965
966/// `face`'s prepared data if a query on this thread prepared it and it is
967/// still cached, without touching the cache: a caller that only wants the
968/// lane ([`containment_lane`]) must not push out the faces queries are using.
969fn cached_prepared_face(face: &FaceRecord) -> Option<std::rc::Rc<PreparedFace>> {
970    PREPARED_FACES.with(|cache| {
971        let PreparedFaceCache { entries } = &*cache.borrow();
972        let switches = lane_switches();
973        let hash = face_key_hash(face, switches);
974        cached_entry(entries, face, switches, hash).map(|index| entries[index].2.clone())
975    })
976}
977
978fn prepared_face(face: &FaceRecord) -> std::rc::Rc<PreparedFace> {
979    PREPARED_FACES.with(|cache| {
980        let PreparedFaceCache { entries } = &mut *cache.borrow_mut();
981        let switches = lane_switches();
982        let hash = face_key_hash(face, switches);
983        if let Some(index) = cached_entry(entries, face, switches, hash) {
984            if index != 0 {
985                let entry = entries.remove(index);
986                entries.insert(0, entry);
987            }
988            return entries[0].2.clone();
989        }
990        let mut signature = Vec::new();
991        walk_face_signature(face, switches, &mut |value: f64| {
992            signature.push(value);
993            true
994        });
995        let prepared = std::rc::Rc::new(PreparedFace::new());
996        entries.insert(0, (hash, signature, prepared.clone()));
997        entries.truncate(PREPARED_FACE_CACHE_ENTRIES);
998        containment_profile(|p| p.cache_misses += 1);
999        prepared
1000    })
1001}
1002
1003/// Point-in-face for a DOUBLY-periodic (torus) seam-band face, whose material
1004/// region the raw even-odd/tangent test below gets wrong: the constant-level
1005/// seam rim is a zero-area iso line and the wavy cut's own polygon encloses only
1006/// the thin strip it bounds, so a point in the band reads Outside (or flips on
1007/// the fragile nearest-tangent refinement). Reconstruct the band as one simple
1008/// (u,v) polygon — identical topology to the mass integrator and tessellator —
1009/// and test the point against it, so all three subsystems agree on the material
1010/// side. Returns None (keep the general path) for every other face.
1011fn prepare_seam_band(face: &FaceRecord) -> Result<SeamBandPrep, String> {
1012    if face.surface.closed_directions()? != (true, true) {
1013        return Ok(SeamBandPrep::NotApplicable);
1014    }
1015    let [u0, u1] = face.surface.domain_u()?;
1016    let [v0, v1] = face.surface.domain_v()?;
1017    if crate::topology::doubly_periodic_has_only_collapsed_loops(face)? {
1018        return Ok(SeamBandPrep::AlwaysInside);
1019    }
1020    let u_span = (u1 - u0).abs().max(1e-30);
1021    let v_span = (v1 - v0).abs().max(1e-30);
1022    let mut loops_uv: Vec<Vec<[f64; 2]>> = Vec::with_capacity(face.loops.len());
1023    // Which face loop each retained entry came from: the collapsed punctures
1024    // dropped below make the two lists disagree, and the merged-band lane needs
1025    // the ORIGINAL coedges of the loop it is about to unwrap.
1026    let mut retained: Vec<usize> = Vec::with_capacity(face.loops.len());
1027    for (loop_index, loop_record) in face.loops.iter().enumerate() {
1028        let mut points: Vec<[f64; 2]> = Vec::new();
1029        for coedge in &loop_record.coedges {
1030            let [d0, d1] = coedge.pcurve.domain()?;
1031            let samples = 24;
1032            for k in 0..=samples {
1033                let t = trim_station(d0, d1, k, samples);
1034                let p = coedge.pcurve.evaluate(t)?;
1035                points.push([p.x, p.y]);
1036            }
1037        }
1038        let (mut umin, mut umax, mut vmin, mut vmax) = (
1039            f64::INFINITY,
1040            f64::NEG_INFINITY,
1041            f64::INFINITY,
1042            f64::NEG_INFINITY,
1043        );
1044        for p in &points {
1045            umin = umin.min(p[0]);
1046            umax = umax.max(p[0]);
1047            vmin = vmin.min(p[1]);
1048            vmax = vmax.max(p[1]);
1049        }
1050        // Mirror `mass_properties::biperiodic_band_range`: a torus has no
1051        // geometric pole, so a VERTEX_LOOP whose whole pcurve trace collapses
1052        // to one parameter point is a zero-area puncture, not a band boundary.
1053        //
1054        // This used to drop the puncture only on faces with MORE than two loops,
1055        // to leave "ordinary two-loop torus classification" alone. That gate was
1056        // wrong, and mass integration never had it: one merged seam-carrying band
1057        // plus one puncture IS a two-loop face, and keeping the puncture put it at
1058        // `loops_uv.len() == 2`, where the two-loop analyses both decline (a
1059        // collapsed loop never full-wraps) and the face fell through to the plain
1060        // even-odd — whose verdict across a seam-wrapped band is the COMPLEMENT.
1061        // Measured on `anotherBooleanFail`'s imported body: face 280 read its
1062        // material band as v ∈ [0.125, 0.875] where its puncture-free twin face
1063        // 166 reads v ∈ [0.875, 1] ∪ [0, 0.125], and a ray crossing there was
1064        // silently lost, so points OpenCASCADE calls interior classified `Out`.
1065        // A zero-area point removes no material from any face, so drop it
1066        // wherever it appears; the all-collapsed case (an untrimmed torus written
1067        // as a sole VERTEX_LOOP) is already answered above, so at least one real
1068        // loop always survives.
1069        if (umax - umin) <= 1e-3 * u_span && (vmax - vmin) <= 1e-3 * v_span {
1070            continue;
1071        }
1072        retained.push(loop_index);
1073        loops_uv.push(points);
1074    }
1075    // MERGED SEAM-CARRYING BAND (single loop): `insert_periodic_band_seam_edges`
1076    // fuses a wrapped band's two rims into ONE loop joined by seam columns, so
1077    // the two-loop paths below never see it, and the plain even-odd polygon
1078    // carries a period-magnitude hop at each rim→column junction (a phantom
1079    // diagonal across the domain) that misclassifies the whole middle zone.
1080    // Unwrap the loop onto the covering plane (`loop_seam_offsets`, the same
1081    // fold mass integration and tessellation use), where it IS a simple
1082    // polygon, and even-odd the query's period images against it. Gated on a
1083    // genuinely seam-crossing single loop on a doubly-periodic surface; every
1084    // other face falls through unchanged. Hatch: BREP_SEAM_BAND_MERGED=0.
1085    if loops_uv.len() == 1 && !switched_off(SEAM_BAND_MERGED_SWITCH) {
1086        let coedges = &face.loops[retained[0]].coedges;
1087        let offsets = crate::topology::loop_seam_offsets(coedges, true, true, u_span, v_span)?;
1088        if offsets.iter().any(|o| o[0] != 0.0 || o[1] != 0.0) {
1089            let mut polygon: Vec<Vec2> = Vec::new();
1090            for (coedge_index, coedge) in coedges.iter().enumerate() {
1091                let [d0, d1] = coedge.pcurve.domain()?;
1092                let samples = 24;
1093                for k in 0..samples {
1094                    let t = trim_station(d0, d1, k, samples);
1095                    let p = coedge.pcurve.evaluate(t)?;
1096                    polygon.push(Vec2 {
1097                        x: p.x + offsets[coedge_index][0],
1098                        y: p.y + offsets[coedge_index][1],
1099                    });
1100                }
1101            }
1102            return Ok(SeamBandPrep::Merged { polygon, u_span, v_span });
1103        }
1104    }
1105    if loops_uv.len() != 2 {
1106        return Ok(SeamBandPrep::Declined);
1107    }
1108    if let Some(band) = crate::topology::analyze_doubly_periodic_seam_band(
1109        &loops_uv,
1110        [u0, u1, v0, v1],
1111        face.same_sense,
1112    ) {
1113        let polygon: Vec<Vec2> =
1114            crate::topology::seam_band_uv_polygon(&loops_uv, [u0, u1, v0, v1], &band)
1115                .into_iter()
1116                .map(|p| Vec2 { x: p[0], y: p[1] })
1117                .collect();
1118        return Ok(SeamBandPrep::Analyzed { polygon });
1119    }
1120    // TWO clean full-wrap rims are the companion `biperiodic_band_range` case,
1121    // with or without an extra collapsed puncture. Classify only the cross
1122    // parameter: the material is either the strip between the rims or its
1123    // periodic complement. The exact two-loop/full-wrap/constant-cross-level
1124    // gates below mirror mass integration and tessellation; every other torus
1125    // face retains the general classifier.
1126    for p_is_u in [true, false] {
1127        let (period, q_extent) = if p_is_u {
1128            (u1 - u0, v_span)
1129        } else {
1130            (v1 - v0, u_span)
1131        };
1132        if !(period > 0.0) {
1133            continue;
1134        }
1135        let coord = |p: &[f64; 2]| if p_is_u { (p[0], p[1]) } else { (p[1], p[0]) };
1136        let mut rings = Vec::with_capacity(2);
1137        for points in &loops_uv {
1138            let (mut pmin, mut pmax, mut qmin, mut qmax) = (
1139                f64::INFINITY,
1140                f64::NEG_INFINITY,
1141                f64::INFINITY,
1142                f64::NEG_INFINITY,
1143            );
1144            for p in points {
1145                let (periodic, cross) = coord(p);
1146                pmin = pmin.min(periodic);
1147                pmax = pmax.max(periodic);
1148                qmin = qmin.min(cross);
1149                qmax = qmax.max(cross);
1150            }
1151            if (pmax - pmin) < 0.6 * period || (qmax - qmin) > 0.05 * q_extent {
1152                rings.clear();
1153                break;
1154            }
1155            let mut net = 0.0;
1156            for pair in points.windows(2) {
1157                let mut delta = coord(&pair[1]).0 - coord(&pair[0]).0;
1158                if delta > 0.5 * period {
1159                    delta -= period;
1160                } else if delta < -0.5 * period {
1161                    delta += period;
1162                }
1163                net += delta;
1164            }
1165            let direction = if net > 0.25 * period {
1166                1
1167            } else if net < -0.25 * period {
1168                -1
1169            } else {
1170                0
1171            };
1172            rings.push((0.5 * (qmin + qmax), direction));
1173        }
1174        if rings.len() != 2 {
1175            continue;
1176        }
1177        rings.sort_by(|a, b| a.0.total_cmp(&b.0));
1178        let [(q_lo, lower_direction), (q_hi, upper_direction)] = rings.as_slice() else {
1179            unreachable!()
1180        };
1181        let inconclusive =
1182            *lower_direction == 0 || *upper_direction == 0 || lower_direction == upper_direction;
1183        let between_is_ccw_uv = if p_is_u {
1184            *lower_direction > 0
1185        } else {
1186            *lower_direction < 0
1187        };
1188        let complement = !inconclusive && between_is_ccw_uv != face.same_sense;
1189        return Ok(SeamBandPrep::Rings {
1190            p_is_u,
1191            q_lo: *q_lo,
1192            q_hi: *q_hi,
1193            complement,
1194        });
1195    }
1196    Ok(SeamBandPrep::Declined)
1197}
1198
1199fn seam_band_point_in_face(
1200    prepared: &SeamBandPrep,
1201    point: Vec2,
1202    tolerance: f64,
1203) -> Option<PolygonClass> {
1204    match prepared {
1205        SeamBandPrep::NotApplicable | SeamBandPrep::Declined => None,
1206        SeamBandPrep::AlwaysInside => Some(PolygonClass::Inside),
1207        SeamBandPrep::Merged { polygon, u_span, v_span } => {
1208            let mut best = PolygonClass::Outside;
1209            'images: for du in [-1.0, 0.0, 1.0] {
1210                for dv in [-1.0, 0.0, 1.0] {
1211                    let image = Vec2 {
1212                        x: point.x + du * u_span,
1213                        y: point.y + dv * v_span,
1214                    };
1215                    match point_in_polygon(image, polygon, tolerance) {
1216                        PolygonClass::Boundary => {
1217                            best = PolygonClass::Boundary;
1218                            break 'images;
1219                        }
1220                        PolygonClass::Inside => best = PolygonClass::Inside,
1221                        PolygonClass::Outside => {}
1222                    }
1223                }
1224            }
1225            Some(best)
1226        }
1227        SeamBandPrep::Analyzed { polygon } => Some(point_in_polygon(point, polygon, tolerance)),
1228        SeamBandPrep::Rings { p_is_u, q_lo, q_hi, complement } => {
1229            let q = if *p_is_u { point.y } else { point.x };
1230            if (q - q_lo).abs() <= tolerance || (q - q_hi).abs() <= tolerance {
1231                return Some(PolygonClass::Boundary);
1232            }
1233            let between = q > *q_lo && q < *q_hi;
1234            Some(if between != *complement {
1235                PolygonClass::Inside
1236            } else {
1237                PolygonClass::Outside
1238            })
1239        }
1240    }
1241}
1242
1243/// Point-in-face for a SINGLY-PERIODIC face with a SEAM-STRADDLING loop: a
1244/// trim loop crossing the u seam is stored with its samples folded into the
1245/// domain, so its flat polygon is TORN at the seam and plain even-odd
1246/// misclassifies near the tear (STEP 00000585-family: face 519's big inner
1247/// loop straddles the seam and a REAL section sub-segment between two accepted
1248/// pieces read Outside in `process_curve`'s midpoint gate — trial 174's
1249/// missing middle segment). Mirror fragment.rs's proven seam-straddling
1250/// fallback: UNWRAP every loop by accumulating period-folded deltas (a
1251/// zero-winding loop closes in the covering plane), then even-odd the query at
1252/// its −P/0/+P period images across all unwrapped loops. Fires only when the
1253/// face is closed in exactly u, has >2 loops, and at least one loop straddles
1254/// the seam (≥1 fold jump); every other face returns None and keeps the plain
1255/// classifier bit-identically. Loops that WIND the period (net winding ≠ 0)
1256/// do not close under unwrapping — bail to the plain path rather than guess.
1257/// Escape hatch `BREP_HORIZON_CONTAINMENT=0`.
1258fn prepare_wrapped_horizon(face: &FaceRecord, prepared: &PreparedFace) -> Result<Option<HorizonPrep>, String> {
1259    if face.surface.closed_directions()? != (true, false) || face.loops.is_empty() {
1260        return Ok(None);
1261    }
1262    if switched_off(HORIZON_CONTAINMENT_SWITCH) {
1263        return Ok(None);
1264    }
1265    // SINGLE/DOUBLE-LOOP straddlers (t91's face 675: ONE loop drawn in-domain
1266    // that hops the u-seam twice, trimming a sliver strip AT the seam) used
1267    // to be excluded by a `loops.len() <= 2` gate and fell to the plain
1268    // even-odd, whose verdict on the hopped polygon is INVERTED (the strip
1269    // interior read Outside, far azimuths read Inside) — minting bogus
1270    // far-azimuth section pieces and discarding the real ones. The unwrap +
1271    // period-image even-odd below is loop-count-agnostic, so the gate now
1272    // only requires a non-empty loop set; non-straddling loops still bail to
1273    // the plain path unchanged. Escape hatch: BREP_HORIZON_SINGLE_LOOP=0
1274    // restores the old minimum.
1275    if face.loops.len() <= 2 && switched_off(HORIZON_SINGLE_LOOP_SWITCH) {
1276        return Ok(None);
1277    }
1278    let [u0, u1] = face.surface.domain_u()?;
1279    let u_period = u1 - u0;
1280    if u_period <= 0.0 {
1281        return Ok(None);
1282    }
1283    let debug = std::env::var("BREP_DEBUG_HORIZON").is_ok();
1284    let mut loops: Vec<Vec<Vec2>> = Vec::new();
1285    let mut straddling = false;
1286    for loop_index in 0..face.loops.len() {
1287        let points = loop_samples(prepared, face, loop_index)?;
1288        if points.len() < 3 {
1289            continue;
1290        }
1291        // Unwrap into the covering plane: place each sample at the previous
1292        // one plus the period-folded delta. A seam-straddling loop becomes a
1293        // continuous closed polygon; a WINDING loop does not close — bail.
1294        let mut unwrapped = Vec::with_capacity(points.len());
1295        let mut jumps = 0usize;
1296        let mut cursor = points[0];
1297        unwrapped.push(cursor);
1298        for pair in points.windows(2) {
1299            let mut du = pair[1].x - pair[0].x;
1300            let folded = du - u_period * (du / u_period).round();
1301            if (du - folded).abs() > 0.25 * u_period {
1302                jumps += 1;
1303            }
1304            du = folded;
1305            cursor = Vec2 {
1306                x: cursor.x + du,
1307                y: pair[1].y,
1308            };
1309            unwrapped.push(cursor);
1310        }
1311        let closure = (unwrapped[0].x - unwrapped[unwrapped.len() - 1].x).abs();
1312        if closure > 0.25 * u_period {
1313            if debug {
1314                eprintln!(
1315                    "horizon: face {} loop winds the period (closure {closure:.3}) — bail",
1316                    face.id
1317                );
1318            }
1319            return Ok(None); // winding loop: unwrapping cannot close it
1320        }
1321        if jumps > 0 {
1322            straddling = true;
1323        }
1324        loops.push(unwrapped);
1325    }
1326    if !straddling || loops.is_empty() {
1327        return Ok(None);
1328    }
1329    Ok(Some(HorizonPrep {
1330        loops,
1331        u_period,
1332        index: std::cell::OnceCell::new(),
1333    }))
1334}
1335
1336fn wrapped_horizon_point_in_face(
1337    face: &FaceRecord,
1338    prepared: &HorizonPrep,
1339    point: Vec2,
1340    tolerance: f64,
1341    scan: ChordScan,
1342) -> Option<PolygonClass> {
1343    let HorizonPrep { loops, u_period, index } = prepared;
1344    let u_period = *u_period;
1345    let debug = std::env::var("BREP_DEBUG_HORIZON").is_ok();
1346    // Even-odd on the QUOTIENT cylinder: each unwrapped loop is tested at
1347    // every period image of the query and contributes its own parity. The old
1348    // combining ("any single image with an odd crossing count ⇒ Inside") is
1349    // wrong whenever two loops live in DIFFERENT period frames after
1350    // unwrapping — t181/00000312#p7: face 519's seam-straddling HOLE unwraps
1351    // to u∈[−0.039, 0.039] while the outer rectangle spans [0, 1], so a query
1352    // inside the hole at u≈0.986 is contained by the outer loop at shift 0
1353    // (odd ⇒ Inside) and by the hole at shift −P (odd ⇒ Inside again); the
1354    // true even count (outer + hole = 2 ⇒ Outside) is never assembled at any
1355    // single image. Fragment selection then kept a PHANTOM half-hole region
1356    // whose boundary (half the hole loop + a derived chord) minted same-sense
1357    // coedges at assembly. Summing per-loop parities across images restores
1358    // the covering-space even-odd. Escape hatch: BREP_HORIZON_CROSS_FRAME=0
1359    // restores the old per-image combining.
1360    if !horizon_cross_frame() {
1361        let mut best: Option<PolygonClass> = None;
1362        for shift in [-u_period, 0.0, u_period] {
1363            let image = Vec2 {
1364                x: point.x + shift,
1365                y: point.y,
1366            };
1367            let mut crossings = 0usize;
1368            for polygon in loops {
1369                match point_in_polygon(image, polygon, tolerance) {
1370                    PolygonClass::Boundary => return Some(PolygonClass::Boundary),
1371                    PolygonClass::Inside => crossings += 1,
1372                    PolygonClass::Outside => {}
1373                }
1374            }
1375            if crossings % 2 == 1 {
1376                best = Some(PolygonClass::Inside);
1377            } else if best.is_none() {
1378                best = Some(PolygonClass::Outside);
1379            }
1380        }
1381        if debug {
1382            eprintln!(
1383                "horizon: face {} loops={} straddling (legacy per-image) -> {:?}",
1384                face.id,
1385                loops.len(),
1386                best
1387            );
1388        }
1389        return best;
1390    }
1391    let images = [-u_period, 0.0, u_period].map(|shift| Vec2 {
1392        x: point.x + shift,
1393        y: point.y,
1394    });
1395    let chords: usize = loops.iter().map(Vec::len).sum();
1396    let indexed = (scan.indexes(chords) && images.iter().all(|image| in_index_range(*image)))
1397        .then(|| index.get_or_init(|| ChordIndex::build(loops.as_slice())).as_ref())
1398        .flatten();
1399    let mut crossings = 0usize;
1400    if let Some(index) = indexed {
1401        // The per-loop parities summed over the images, as the scan below
1402        // sums them: `Boundary` if any image is within the band of any chord,
1403        // otherwise each loop's crossings flipped across all three images.
1404        let loops = loops.as_slice();
1405        if images.iter().any(|image| index.any_within(loops, *image, tolerance)) {
1406            return Some(PolygonClass::Boundary);
1407        }
1408        let mut odd = vec![false; loops.len()];
1409        for image in images {
1410            index.flip_crossings(loops, image, &mut odd);
1411        }
1412        crossings = odd.into_iter().filter(|&parity| parity).count();
1413    } else {
1414        for polygon in loops {
1415            let mut image_hits = 0usize;
1416            for image in images {
1417                match point_in_polygon(image, polygon, tolerance) {
1418                    PolygonClass::Boundary => return Some(PolygonClass::Boundary),
1419                    PolygonClass::Inside => image_hits += 1,
1420                    PolygonClass::Outside => {}
1421                }
1422            }
1423            crossings += image_hits % 2;
1424        }
1425    }
1426    let class = if crossings % 2 == 1 {
1427        PolygonClass::Inside
1428    } else {
1429        PolygonClass::Outside
1430    };
1431    if debug {
1432        eprintln!(
1433            "horizon: face {} loops={} straddling -> {:?}",
1434            face.id,
1435            loops.len(),
1436            class
1437        );
1438    }
1439    Some(class)
1440}
1441
1442/// Classify a spherical cap represented by a varying full-wrap contact rim and
1443/// the oppositely wound collapsed rim at one sphere pole.  This is the natural
1444/// topology when a circle about an oblique axis encloses a pole of the sphere's
1445/// stored parameter frame.
1446fn prepare_winding_sphere_cap(face: &FaceRecord, prepared: &PreparedFace) -> Result<Option<SphereCapPrep>, String> {
1447    if !matches!(
1448        face.surface.analytic(),
1449        Some(crate::AnalyticSurface::Sphere { .. })
1450    ) || face.surface.closed_directions()? != (true, false)
1451        || face.loops.len() != 2
1452    {
1453        return Ok(None);
1454    }
1455    let [u0, u1] = face.surface.domain_u()?;
1456    let [v0, v1] = face.surface.domain_v()?;
1457    let period = u1 - u0;
1458    let v_span = v1 - v0;
1459    if !(period > 0.0 && v_span > 0.0) {
1460        return Ok(None);
1461    }
1462    struct WindingLoop {
1463        points: Vec<Vec2>,
1464        winding: f64,
1465        vmin: f64,
1466        vmax: f64,
1467    }
1468    let mut loops = Vec::with_capacity(2);
1469    for loop_index in 0..face.loops.len() {
1470        let points = loop_samples(prepared, face, loop_index)?;
1471        if points.len() < 2 {
1472            return Ok(None);
1473        }
1474        let first = points[0];
1475        let mut cursor = first;
1476        let mut unwrapped = vec![cursor];
1477        for next in points.iter().skip(1) {
1478            let du = next.x - cursor.x;
1479            let folded = du - period * (du / period).round();
1480            cursor = Vec2 {
1481                x: cursor.x + folded,
1482                y: next.y,
1483            };
1484            unwrapped.push(cursor);
1485        }
1486        let last_raw = *points.last().unwrap();
1487        let closing_du = first.x - last_raw.x;
1488        let closing_folded = closing_du - period * (closing_du / period).round();
1489        let winding = cursor.x + closing_folded - first.x;
1490        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
1491        for p in &unwrapped {
1492            vmin = vmin.min(p.y);
1493            vmax = vmax.max(p.y);
1494        }
1495        loops.push(WindingLoop {
1496            points: unwrapped,
1497            winding,
1498            vmin,
1499            vmax,
1500        });
1501    }
1502    if loops
1503        .iter()
1504        .any(|loop_data| (loop_data.winding.abs() - period).abs() > 0.05 * period)
1505        || loops[0].winding * loops[1].winding >= 0.0
1506    {
1507        return Ok(None);
1508    }
1509    let flat = |loop_data: &WindingLoop| loop_data.vmax - loop_data.vmin <= 1e-6 * v_span;
1510    let (pole, rim) = match (flat(&loops[0]), flat(&loops[1])) {
1511        (true, false) => (&loops[0], &loops[1]),
1512        (false, true) => (&loops[1], &loops[0]),
1513        _ => return Ok(None),
1514    };
1515    let pole_v = 0.5 * (pole.vmin + pole.vmax);
1516    if (pole_v - v0).abs() > 1e-6 * v_span && (pole_v - v1).abs() > 1e-6 * v_span {
1517        return Ok(None);
1518    }
1519    Ok(Some(SphereCapPrep {
1520        rim: rim.points.clone(),
1521        pole_v,
1522        period,
1523    }))
1524}
1525
1526fn winding_sphere_cap_point_in_face(
1527    prepared: &SphereCapPrep,
1528    point: Vec2,
1529    tolerance: f64,
1530) -> Option<PolygonClass> {
1531    let SphereCapPrep { rim, pole_v, period } = prepared;
1532    let (pole_v, period) = (*pole_v, *period);
1533    let window_start = rim[0].x.min(rim[rim.len() - 1].x);
1534    let query_u = window_start + (point.x - window_start).rem_euclid(period);
1535    let mut crossings = Vec::new();
1536    for pair in rim.windows(2) {
1537        let (a, b) = (pair[0], pair[1]);
1538        if (a.x > query_u) != (b.x > query_u) {
1539            crossings.push(a.y + (query_u - a.x) / (b.x - a.x) * (b.y - a.y));
1540        }
1541        for image in [-period, 0.0, period] {
1542            if point_segment_distance(
1543                Vec2 {
1544                    x: point.x + image,
1545                    y: point.y,
1546                },
1547                a,
1548                b,
1549            ) <= tolerance
1550            {
1551                return Some(PolygonClass::Boundary);
1552            }
1553        }
1554    }
1555    let rim_v = crossings
1556        .into_iter()
1557        .min_by(|a, b| (a - point.y).abs().total_cmp(&(b - point.y).abs()))?;
1558    let inside = if pole_v < rim_v {
1559        point.y <= rim_v + tolerance
1560    } else {
1561        point.y >= rim_v - tolerance
1562    };
1563    Some(if inside {
1564        PolygonClass::Inside
1565    } else {
1566        PolygonClass::Outside
1567    })
1568}
1569
1570/// Point-in-face for a SINGLY-PERIODIC (closed-u) FULL-BAND STRIP whose two
1571/// full-wrap rim loops are drawn as COVERING-PLANE pcurves running OUT OF the
1572/// u-domain by up to a whole period (case 08:10-genus face 1513, the 4th seam
1573/// representation after helmet-unwrapped / band-complement / 675-hops): the
1574/// raw even-odd sees u-winding loop polygons whose parity is meaningless, so
1575/// nearly the whole material strip reads Outside, the marched clip crumbles
1576/// the section curves to sub-mm bits, and the neighbours strand one-use.
1577///
1578/// The material region is the strip BETWEEN the two rims: on an open-v
1579/// surface a 2-loop face whose loops both wrap the full period has no other
1580/// representable region. The verdict is a sampled per-u hull — each rim is a
1581/// single periodic polyline v = rim(u) in the covering plane, and the query
1582/// point is between the rims iff an upward (+v) vertical ray at the query's
1583/// u (folded modulo the period into each rim's own one-period window) crosses
1584/// the two rim polylines an odd number of times in total. NO flat-level
1585/// approximation is used (the rims are scalloped: 1513's upper rim is flat at
1586/// v=1 on parts of u but dips to v=0.745 elsewhere — a v-between-extremes
1587/// rule is provably wrong on it).
1588///
1589/// STRUCTURAL gates (all must hold; anything else returns None and keeps the
1590/// previous classification bit-identically):
1591///   - surface closed in exactly u; exactly 2 loops;
1592///   - at least one loop's samples run OUT of the u-domain by >1e-3 period
1593///     (the covering-plane discriminator — in-domain 2-rim bands keep their
1594///     current path);
1595///   - each loop's unwrapped net u-travel is exactly ±one period and its v
1596///     closes (a true full-wrap rim, not a partial arc);
1597///   - opposite travel directions, disjoint v-hulls, and the material-left
1598///     winding rule confirming the BETWEEN strip (a complement verdict cannot
1599///     be represented on an open-v surface — decline, never guess).
1600/// Escape hatch: BREP_COVERING_RIM_STRIP=0.
1601fn prepare_covering_rim_strip(face: &FaceRecord) -> Result<Option<RimStripPrep>, String> {
1602    if face.surface.closed_directions()? != (true, false) || face.loops.len() != 2 {
1603        return Ok(None);
1604    }
1605    if switched_off(COVERING_RIM_STRIP_SWITCH) {
1606        return Ok(None);
1607    }
1608    let [u0, u1] = face.surface.domain_u()?;
1609    let [v0, v1] = face.surface.domain_v()?;
1610    let period = u1 - u0;
1611    if !(period > 0.0) {
1612        return Ok(None);
1613    }
1614    let v_span = (v1 - v0).abs().max(1e-30);
1615    struct Rim {
1616        points: Vec<Vec2>,
1617        vmin: f64,
1618        vmax: f64,
1619        ascending: bool,
1620    }
1621    let mut rims: Vec<Rim> = Vec::with_capacity(2);
1622    let mut out_of_domain = false;
1623    for loop_record in &face.loops {
1624        let mut points: Vec<Vec2> = Vec::new();
1625        for coedge in &loop_record.coedges {
1626            let curve = &coedge.pcurve;
1627            let [start, end] = curve.domain()?;
1628            let sample_count = trim_sample_count(curve);
1629            for index in 0..=sample_count {
1630                let parameter = trim_station(start, end, index, sample_count);
1631                let evaluated = curve.evaluate(parameter)?;
1632                points.push(Vec2 {
1633                    x: evaluated.x,
1634                    y: evaluated.y,
1635                });
1636            }
1637        }
1638        if points.len() < 3 {
1639            return Ok(None);
1640        }
1641        // Covering-plane discriminator: this lane exists for loops drawn PAST
1642        // the domain; everything in-domain keeps the existing classifiers.
1643        for p in &points {
1644            if p.x < u0 - 1e-3 * period || p.x > u1 + 1e-3 * period {
1645                out_of_domain = true;
1646            }
1647        }
1648        // Unwrap into the covering plane (period-folded deltas — a no-op for
1649        // an already-continuous covering-plane representation, and the same
1650        // fold the horizon lane uses for in-domain seam hops).
1651        let mut unwrapped: Vec<Vec2> = Vec::with_capacity(points.len());
1652        let mut cursor = points[0];
1653        unwrapped.push(cursor);
1654        for pair in points.windows(2) {
1655            let du = pair[1].x - pair[0].x;
1656            let folded = du - period * (du / period).round();
1657            cursor = Vec2 {
1658                x: cursor.x + folded,
1659                y: pair[1].y,
1660            };
1661            unwrapped.push(cursor);
1662        }
1663        let net = unwrapped[unwrapped.len() - 1].x - unwrapped[0].x;
1664        if (net.abs() - period).abs() > 2e-2 * period {
1665            return Ok(None); // not a clean single full wrap
1666        }
1667        if (unwrapped[unwrapped.len() - 1].y - unwrapped[0].y).abs() > 1e-3 * v_span {
1668            return Ok(None); // rim does not close in v
1669        }
1670        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
1671        for p in &unwrapped {
1672            vmin = vmin.min(p.y);
1673            vmax = vmax.max(p.y);
1674        }
1675        rims.push(Rim {
1676            points: unwrapped,
1677            vmin,
1678            vmax,
1679            ascending: net > 0.0,
1680        });
1681    }
1682    if !out_of_domain {
1683        return Ok(None);
1684    }
1685    if rims[0].ascending == rims[1].ascending {
1686        return Ok(None); // rims must traverse opposite u directions
1687    }
1688    let (lower, upper) = if rims[0].vmax <= rims[1].vmin {
1689        (&rims[0], &rims[1])
1690    } else if rims[1].vmax <= rims[0].vmin {
1691        (&rims[1], &rims[0])
1692    } else {
1693        return Ok(None); // interleaved v-hulls: not a clean strip
1694    };
1695    if upper.vmin - lower.vmax <= 1e-6 * v_span {
1696        return Ok(None);
1697    }
1698    // Material-left rule (same convention as the doubly-periodic band lanes):
1699    // the between strip is CCW in uv iff the LOWER rim travels +u; that must
1700    // match the face sense, else the material would be the complement — which
1701    // an open-v surface cannot represent. Decline rather than guess.
1702    if (lower.ascending) != face.same_sense {
1703        return Ok(None);
1704    }
1705    Ok(Some(RimStripPrep {
1706        lower: lower.points.clone(),
1707        upper: upper.points.clone(),
1708        period,
1709    }))
1710}
1711
1712fn covering_rim_strip_point_in_face(
1713    prepared: &RimStripPrep,
1714    point: Vec2,
1715    tolerance: f64,
1716) -> Option<PolygonClass> {
1717    let RimStripPrep { lower, upper, period } = prepared;
1718    let period = *period;
1719    // Boundary: proximity to either rim polyline at the query's period images.
1720    for rim in [lower, upper] {
1721        for shift in [-period, 0.0, period] {
1722            let image = Vec2 {
1723                x: point.x + shift,
1724                y: point.y,
1725            };
1726            for pair in rim.windows(2) {
1727                if point_segment_distance(image, pair[0], pair[1]) <= tolerance {
1728                    return Some(PolygonClass::Boundary);
1729                }
1730            }
1731        }
1732    }
1733    // Sampled per-u hull: fold the query into each rim's own one-period
1734    // window and count upward-ray crossings; odd total = between the rims.
1735    let mut crossings = 0usize;
1736    for rim in [lower, upper] {
1737        let window_base = rim[0].x.min(rim[rim.len() - 1].x);
1738        let x = window_base + (point.x - window_base).rem_euclid(period);
1739        for pair in rim.windows(2) {
1740            let (a, b) = (pair[0], pair[1]);
1741            if (a.x > x) != (b.x > x) {
1742                let v_cross = a.y + (x - a.x) / (b.x - a.x) * (b.y - a.y);
1743                if v_cross > point.y {
1744                    crossings += 1;
1745                }
1746            }
1747        }
1748    }
1749    Some(if crossings % 2 == 1 {
1750        PolygonClass::Inside
1751    } else {
1752        PolygonClass::Outside
1753    })
1754}
1755
1756/// The generic scan's trim samples: every pcurve at `(interior knots + 1) ×
1757/// (degree + 1) × 4` chords, the closed polygon per loop, and the chord
1758/// parameters behind every polygon edge.
1759fn prepare_generic(face: &FaceRecord, prepared: &PreparedFace) -> Result<GenericPrep, String> {
1760    let mut loops: Vec<LoopSamples> = Vec::with_capacity(face.loops.len());
1761    let mut segments: Vec<Vec<SegmentPrep>> = Vec::with_capacity(face.loops.len());
1762    let mut chord_count = 0;
1763    for (loop_index, loop_record) in face.loops.iter().enumerate() {
1764        let polygon = loop_samples(prepared, face, loop_index)?;
1765        let mut chords = Vec::with_capacity(polygon.len());
1766        let mut coedges = Vec::with_capacity(loop_record.coedges.len());
1767        let mut first = 0;
1768        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
1769            let curve = &coedge.pcurve;
1770            let [start, end] = curve.domain()?;
1771            let sample_count = trim_sample_count(curve);
1772            for index in 0..sample_count {
1773                let parameter = trim_station(start, end, index, sample_count);
1774                let parameter_end = if index + 1 < sample_count {
1775                    trim_station(start, end, index + 1, sample_count)
1776                } else {
1777                    end
1778                };
1779                chords.push(SegmentPrep {
1780                    coedge_index,
1781                    parameter_start: parameter,
1782                    parameter_end,
1783                });
1784            }
1785            coedges.push((coedge.edge_id, first, sample_count));
1786            first += sample_count;
1787        }
1788        debug_assert_eq!(first, polygon.len());
1789        chord_count += chords.len();
1790        loops.push(LoopSamples { polygon, coedges });
1791        segments.push(chords);
1792    }
1793    containment_profile(|p| p.generic_chords += chord_count as u64);
1794    Ok(GenericPrep {
1795        loops,
1796        segments,
1797        chord_count,
1798        index: std::cell::OnceCell::new(),
1799    })
1800}
1801
1802/// One trim loop as [`parameter_point_in_face`] already sampled it: the closed
1803/// polygon it counts parity against, plus `(edge id, first sample, sample count)`
1804/// per coedge in loop order, which is all the spherical lane needs to drop a slit
1805/// and to rebuild the boundary as SEGMENTS rather than as one ring.
1806struct LoopSamples {
1807    polygon: std::rc::Rc<Vec<Vec2>>,
1808    coedges: Vec<(u64, usize, usize)>,
1809}
1810
1811/// Point-in-face for a SPHERICAL carrier, decided on the ball itself instead of
1812/// in its polar parameter domain — but ONLY in the far field, where the generic
1813/// path has nothing better than a parity count.
1814///
1815/// The generic classifier below does not really answer by parity.  Whenever the
1816/// query has a nearest trim segment whose foot is interior to that pcurve, it
1817/// answers by which SIDE of that curve the point is on, which is locally exact
1818/// and is what the imprint and the fragment builder are tuned against.  Parity is
1819/// its fallback for points further from every trim segment than that segment is
1820/// long — and parity is exactly what the polar domain breaks: a loop enclosing a
1821/// pole does not enclose it in `uv` (it wraps the domain instead), and a region
1822/// straddling the seam is not one polygon there at all.  Three separate special
1823/// cases in this file exist because of that.
1824///
1825/// So this lane engages on the complement of the refinement's condition: it
1826/// replaces the parity count and nothing else.  Material is the intersection of
1827/// the regions to the LEFT of each trim loop, and the side a point falls on is
1828/// the sign of the signed solid angle that loop subtends there — a question with
1829/// no parameter domain in it, so pole and seam need no mention.  A collapsed pole
1830/// loop has no area and a seam is traversed once each way; both cancel exactly.
1831///
1832/// [`crate::sphere_chart::SphericalRegion`] is the same classifier the chart
1833/// tessellation uses, so the trim query and the mesh cannot disagree about where
1834/// the material is.
1835fn sphere_chart_point_in_face(
1836    face: &FaceRecord,
1837    point: Vec2,
1838    prepared: &PreparedFace,
1839    sampled: &[LoopSamples],
1840) -> Result<Option<PolygonClass>, String> {
1841    let Some(atlas) = sphere_chart_atlas(face) else {
1842        return Ok(None);
1843    };
1844    let region = lane(&prepared.sphere_region, || build_sphere_region(face, &atlas, sampled))?;
1845    if region.is_whole_sphere() {
1846        return Ok(Some(PolygonClass::Inside));
1847    }
1848    if !region.is_decidable() {
1849        // No seed: decline to the generic path rather than answer Inside for
1850        // the whole ball.
1851        return Ok(None);
1852    }
1853    let probe = face.surface.evaluate(point.x, point.y)?;
1854    Ok(Some(if region.contains(atlas.centre, probe) {
1855        PolygonClass::Inside
1856    } else {
1857        PolygonClass::Outside
1858    }))
1859}
1860
1861/// The spherical region [`sphere_chart_point_in_face`] reads `face` against,
1862/// built ONCE per prepared face.
1863///
1864/// A region depends on the face's sphere, its orientation and its trim samples
1865/// — never on the query point — but the trim query is called once PER POINT
1866/// from the innermost loops of the imprint, the fragment builder and the
1867/// classifier's ray casts, and building one costs a surface evaluation per
1868/// boundary sample, a 27-cell canonicalization pass, two hash maps and a seed
1869/// search that can run two dozen crossing counts. Rebuilding that per query is
1870/// the whole cost of this lane; the query itself is one crossing count.
1871///
1872/// It lives on the [`PreparedFace`], whose cache key already holds everything
1873/// the region is built from. It used to have a four-entry cache of its own, and
1874/// a ray cast visits every face its segment's box touches in the same order
1875/// every time: a ball cut into seven faces by a helical tube (the 2026-09-26
1876/// coil fillet report) cycled that cache and missed on every query, 20-35 ms a
1877/// trim test, and the fillet's interference check ran 147 s instead of 30.
1878fn build_sphere_region(
1879    face: &FaceRecord,
1880    atlas: &crate::sphere_chart::SphereAtlas,
1881    sampled: &[LoopSamples],
1882) -> Result<crate::sphere_chart::SphericalRegion, String> {
1883    // An edge used TWICE by one face is a slit — the parametric seam of a ball
1884    // the trim never cut. It bounds no material, so it is left out of the region
1885    // rather than cancelled numerically afterwards.
1886    let mut uses: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
1887    for coedge in face.loops.iter().flat_map(|record| record.coedges.iter()) {
1888        *uses.entry(coedge.edge_id).or_insert(0) += 1;
1889    }
1890    // Boundary SEGMENTS, not one polyline per loop.
1891    //
1892    // A ball drilled through carries BOTH rims in a single loop, joined by the
1893    // seam traversed once each way — a keyhole. Concatenating that loop's
1894    // surviving coedges and closing the ring would bridge rim to rim with two
1895    // chords that are not reverses of each other, and the winding of that figure
1896    // is not the winding of the two rims: on a through-drilled ball it comes out
1897    // exactly inverted. Handing the classifier the segments themselves lets it
1898    // cancel the seam and recover the two real rims.
1899    //
1900    // The samples are the CALLER's, taken once for its own parity count and its
1901    // own nearest-segment scan. Sampling the pcurves again here doubled the cost
1902    // of every query on a spherical face, and the caller only reaches this lane
1903    // when it would otherwise answer by parity — so that second pass was paid on
1904    // nearly every query and used on almost none of them.
1905    //
1906    // They are sampled in each coedge's own traversal direction, which is the
1907    // direction its pcurve is parameterized in: `forward` selects which end of
1908    // the EDGE's samples a traversal starts from, it does not reverse the
1909    // pcurve. A loop walked backwards puts the material on the wrong side of
1910    // every boundary.
1911    // The material-left convention is stated against the FACE normal, which the
1912    // generic path encodes as `(cross > 0) == same_sense` in uv: material is left
1913    // of the boundary seen from `same_sense ? Su x Sv : -(Su x Sv)`.
1914    let outward_face_normal =
1915        face.same_sense == atlas.parameterization_is_outward(&face.surface)?;
1916    let evaluate_started = containment_profile_started();
1917    let mut points: Vec<Vec3> = Vec::new();
1918    let mut spans: Vec<(usize, usize)> = Vec::new();
1919    for samples in sampled {
1920        let count = samples.polygon.len();
1921        if count < 2 {
1922            continue;
1923        }
1924        for &(edge_id, first, span) in &samples.coedges {
1925            if span == 0 || uses.get(&edge_id).copied().unwrap_or(0) >= 2 {
1926                continue;
1927            }
1928            // `span + 1` points: the coedge's own samples plus the first
1929            // sample of the next coedge, which is this one's END point. The
1930            // loop is closed, so the last coedge wraps back to `polygon[0]`.
1931            let start = points.len();
1932            for offset in 0..=span {
1933                let uv = samples.polygon[(first + offset) % count];
1934                points.push(face.surface.evaluate(uv.x, uv.y)?);
1935            }
1936            spans.push((start, points.len()));
1937        }
1938    }
1939    // One canonicalization over ALL the face's points: a vertex named by
1940    // two coedges is evaluated twice and the two answers agree only to
1941    // rounding, which the exact-reverse cancellation and the loop walk
1942    // both need collapsed.
1943    let evaluate_ms = containment_elapsed_ms(evaluate_started);
1944    let canonical_started = containment_profile_started();
1945    crate::sphere_chart::canonicalize_points(&mut points, 1e-9);
1946    let canonical_ms = containment_elapsed_ms(canonical_started);
1947    let segments_started = containment_profile_started();
1948    let mut boundary: Vec<(Vec3, Vec3)> = Vec::new();
1949    for (first, last) in spans {
1950        for index in first..last.saturating_sub(1) {
1951            boundary.push((points[index], points[index + 1]));
1952        }
1953    }
1954    let region = crate::sphere_chart::SphericalRegion::from_segments(
1955        atlas.centre,
1956        &boundary,
1957        outward_face_normal,
1958    );
1959    let segments_ms = containment_elapsed_ms(segments_started);
1960    containment_profile(|p| {
1961        p.region_builds += 1;
1962        p.region_evaluate_ms += evaluate_ms;
1963        p.region_canonical_ms += canonical_ms;
1964        p.region_segments_ms += segments_ms;
1965    });
1966    Ok(region)
1967}
1968
1969
1970/// The sphere the sphere-chart lane reads a face on: an analytic sphere, with
1971/// the lane's switches on ([`sphere_chart_trim_enabled`]).
1972fn sphere_chart_atlas(face: &FaceRecord) -> Option<crate::sphere_chart::SphereAtlas> {
1973    if !sphere_chart_trim_enabled() {
1974        return None;
1975    }
1976    crate::sphere_chart::SphereAtlas::of_surface(&face.surface)
1977}
1978
1979/// Whether `BREP_DEBUG_CONTAINMENT` is set, read once.  `parameter_point_in_face`
1980/// runs per ray-face intersection inside every boolean, so the cost when the
1981/// trace is off must be one `OnceLock` read, not an environment lookup per exit.
1982fn containment_trace_enabled() -> bool {
1983    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1984    *ON.get_or_init(|| std::env::var("BREP_DEBUG_CONTAINMENT").is_ok())
1985}
1986
1987/// Name the lane that answered, under `BREP_DEBUG_CONTAINMENT`.  This function
1988/// has seven exits and which one fired is the first thing any investigation of a
1989/// wrong containment verdict needs; reconstructing it from the outside means
1990/// re-deriving each lane's gate by hand.
1991fn trace_lane(face: &FaceRecord, point: Vec2, lane: &str, class: PolygonClass) {
1992    if containment_trace_enabled() {
1993        eprintln!(
1994            "containment face={} uv=({:.8},{:.8}) lane={lane} -> {class:?}",
1995            face.id, point.x, point.y
1996        );
1997    }
1998}
1999
2000
2001/// A lane [`parameter_point_in_face`] answers in, with what it prepared.
2002enum Lane<'a> {
2003    SeamBand(&'a SeamBandPrep),
2004    SphereCap(&'a SphereCapPrep),
2005    Horizon(&'a HorizonPrep),
2006    RimStrip(&'a RimStripPrep),
2007    Generic,
2008}
2009
2010/// The first lane at dispatch position `from` or later that is prepared to
2011/// answer `face`, and its position: 0 the seam band, 1 the sphere cap, 2 the
2012/// wrapped horizon, 3 the covering rim strip, 4 the generic scan. Each lane is
2013/// prepared (and its preparation's error surfaces) only when the walk reaches
2014/// it. [`parameter_point_in_face`] walks on from the next position when a lane
2015/// declines a query; [`containment_lane`] reports where the walk starts, so the
2016/// two cannot disagree about which lane a face is in.
2017fn lane_from<'a>(
2018    face: &FaceRecord,
2019    prepared: &'a PreparedFace,
2020    from: usize,
2021) -> Result<(usize, Lane<'a>), String> {
2022    if from == 0 {
2023        let band = timed_lane(&prepared.seam_band, || prepare_seam_band(face), |p, ms| {
2024            p.seam_band_preps += 1;
2025            p.seam_band_ms += ms;
2026        })?;
2027        if !matches!(band, SeamBandPrep::NotApplicable | SeamBandPrep::Declined) {
2028            return Ok((0, Lane::SeamBand(band)));
2029        }
2030    }
2031    if from <= 1 {
2032        if let Some(cap) = timed_lane(&prepared.sphere_cap, || prepare_winding_sphere_cap(face, prepared), |p, ms| {
2033            p.sphere_cap_preps += 1;
2034            p.sphere_cap_ms += ms;
2035        })? {
2036            return Ok((1, Lane::SphereCap(cap)));
2037        }
2038    }
2039    if from <= 2 {
2040        if let Some(horizon) = timed_lane(&prepared.horizon, || prepare_wrapped_horizon(face, prepared), |p, ms| {
2041            p.horizon_preps += 1;
2042            p.horizon_ms += ms;
2043        })? {
2044            return Ok((2, Lane::Horizon(horizon)));
2045        }
2046    }
2047    if from <= 3 {
2048        if let Some(strip) = timed_lane(&prepared.rim_strip, || prepare_covering_rim_strip(face), |p, ms| {
2049            p.rim_strip_preps += 1;
2050            p.rim_strip_ms += ms;
2051        })? {
2052            return Ok((3, Lane::RimStrip(strip)));
2053        }
2054    }
2055    Ok((4, Lane::Generic))
2056}
2057
2058/// Which lane of [`parameter_point_in_face`] a face's questions go to, read off
2059/// the face alone — the kernel's own lane selection, through the same prepared
2060/// data the query uses, for a caller that answers some containment questions
2061/// ahead of the kernel and must know which rule the kernel applies.
2062///
2063/// The lanes are tried in this order, and a face is in the first whose gates it
2064/// passes (each comparand is in the carrier's parameters unless stated):
2065///
2066/// 1. [`SeamBand`](Self::SeamBand): `closed_directions() == (true, true)`, and
2067///    the seam-band preparation reads the face — every loop a collapsed
2068///    puncture (its trace within `1e-3` of the domain span in u and in v), one
2069///    seam-crossing loop unwrapped (unless [`SEAM_BAND_MERGED_SWITCH`]), two
2070///    loops the seam-band analysis reassembles, or two clean full-wrap rims (a
2071///    periodic extent of at least `0.6` period and a cross extent of at most
2072///    `0.05` of the cross span each).
2073/// 2. [`SphereCap`](Self::SphereCap): an analytic sphere closed in exactly u
2074///    with two loops, each winding one period to within `0.05` period and the
2075///    two opposite, one flat (a v extent within `1e-6` of the v span) at a pole
2076///    (within `1e-6` of the v span of a v end).
2077/// 3. [`WrappedHorizon`](Self::WrappedHorizon): closed in exactly u, at least
2078///    one loop, neither [`HORIZON_CONTAINMENT_SWITCH`] nor (for one or two
2079///    loops) [`HORIZON_SINGLE_LOOP_SWITCH`], some loop of three or more samples
2080///    stepping over the seam (a sample-to-sample u step more than a quarter
2081///    period from its period-folded value), and no loop winding the period (its
2082///    unwrapped first and last samples more than a quarter period apart).
2083/// 4. [`CoveringRimStrip`](Self::CoveringRimStrip): closed in exactly u, two
2084///    loops, not [`COVERING_RIM_STRIP_SWITCH`], a sample beyond the u domain by
2085///    more than `1e-3` period, each loop travelling one period to within `2e-2`
2086///    period and closing in v to within `1e-3` of the v span, opposite
2087///    directions, v hulls apart by more than `1e-6` of the v span, and the lower
2088///    rim travelling +u exactly when the face is `same_sense`.
2089/// 5. [`Generic`](Self::Generic): everything else.
2090///
2091/// Sampling is [`trim_sample_count`] chords per pcurve at [`trim_station`]
2092/// (the seam band: 24). `Err` is the error the face's first query would
2093/// return.
2094///
2095/// It reads the prepared data of a face a query has prepared, and otherwise
2096/// prepares what the walk reaches on the side, leaving the thread's
2097/// prepared-face cache as it was: asked for every face of a part, it would
2098/// otherwise push out the faces the queries are using.
2099pub fn containment_lane(face: &FaceRecord) -> Result<ContainmentLane, String> {
2100    let prepared = cached_prepared_face(face).unwrap_or_else(|| std::rc::Rc::new(PreparedFace::new()));
2101    Ok(match lane_from(face, &prepared, 0)?.1 {
2102        Lane::SeamBand(_) => ContainmentLane::SeamBand,
2103        Lane::SphereCap(_) => ContainmentLane::SphereCap,
2104        Lane::Horizon(horizon) => ContainmentLane::WrappedHorizon {
2105            loops: horizon.loops.clone(),
2106            u_period: horizon.u_period,
2107            cross_frame: horizon_cross_frame(),
2108        },
2109        Lane::RimStrip(_) => ContainmentLane::CoveringRimStrip,
2110        Lane::Generic => ContainmentLane::Generic {
2111            sphere_chart: sphere_chart_atlas(face).is_some(),
2112        },
2113    })
2114}
2115
2116/// The lane [`containment_lane`] finds a face in.
2117#[derive(Clone, Debug)]
2118pub enum ContainmentLane {
2119    /// The doubly-periodic seam-band lane: answers every point of the face.
2120    SeamBand,
2121    /// The winding sphere-cap lane: answers every point within the query's
2122    /// tolerance of a chord of the rim or whose u meets one; any other point
2123    /// goes on to the lanes after it.
2124    SphereCap,
2125    /// The wrapped-horizon lane: answers every point. Within the query's
2126    /// tolerance of a chord of `loops` at `u − u_period`, `u` or `u + u_period`
2127    /// it is `Boundary`; otherwise, with `cross_frame`, each loop's even-odd
2128    /// count (crossings of +u, half-open in v) at the three images summed mod
2129    /// 2, odd being `Inside`; without it ([`HORIZON_CROSS_FRAME_SWITCH`] = `"0"`
2130    /// when this was called), `Inside` when some image counts an odd total.
2131    WrappedHorizon {
2132        /// Each loop's samples, unwrapped sample to sample across the seam:
2133        /// the lane's own polygons, closed from last sample to first.
2134        loops: Vec<Vec<Vec2>>,
2135        u_period: f64,
2136        cross_frame: bool,
2137    },
2138    /// The covering-rim-strip lane: answers every point.
2139    CoveringRimStrip,
2140    /// The generic scan: `Boundary` within the tolerance of a chord; otherwise
2141    /// the side of the nearest pcurve where the nearest chord's foot lands
2142    /// inside it and the point is no farther from that chord than the chord is
2143    /// long; otherwise the even-odd count over every loop — for which, with
2144    /// `sphere_chart`, the sphere-chart lane substitutes where it can decide.
2145    Generic {
2146        /// An analytic sphere with the sphere-chart trim lane on
2147        /// ([`NO_SPHERE_CHART_TRIM_SWITCH`]), read when this was called.
2148        sphere_chart: bool,
2149    },
2150}
2151
2152pub fn parameter_point_in_face(
2153    face: &FaceRecord,
2154    point: Vec2,
2155    tolerance: f64,
2156) -> Result<PolygonClass, String> {
2157    point_in_face_scanned(face, point, tolerance, ChordScan::Auto)
2158}
2159
2160/// [`parameter_point_in_face`] with the generic scan and the wrapped-horizon
2161/// lane read through their chord index (`indexed`, on a face of any size) or
2162/// over every chord. The two give the same answer on every point; this exists
2163/// so a test can show it.
2164#[doc(hidden)]
2165pub fn parameter_point_in_face_scan(
2166    face: &FaceRecord,
2167    point: Vec2,
2168    tolerance: f64,
2169    indexed: bool,
2170) -> Result<PolygonClass, String> {
2171    let scan = if indexed {
2172        ChordScan::Indexed
2173    } else {
2174        ChordScan::Linear
2175    };
2176    point_in_face_scanned(face, point, tolerance, scan)
2177}
2178
2179fn point_in_face_scanned(
2180    face: &FaceRecord,
2181    point: Vec2,
2182    tolerance: f64,
2183    scan: ChordScan,
2184) -> Result<PolygonClass, String> {
2185    let prepared = prepared_face(face);
2186    let mut from = 0;
2187    loop {
2188        let (position, lane) = lane_from(face, &prepared, from)?;
2189        let answer = match lane {
2190            Lane::SeamBand(band) => {
2191                seam_band_point_in_face(band, point, tolerance).map(|class| ("seam_band", class))
2192            }
2193            Lane::SphereCap(cap) => winding_sphere_cap_point_in_face(cap, point, tolerance)
2194                .map(|class| ("winding_sphere_cap", class)),
2195            Lane::Horizon(horizon) => wrapped_horizon_point_in_face(face, horizon, point, tolerance, scan)
2196                .map(|class| ("wrapped_horizon", class)),
2197            Lane::RimStrip(strip) => covering_rim_strip_point_in_face(strip, point, tolerance)
2198                .map(|class| ("covering_rim_strip", class)),
2199            Lane::Generic => break,
2200        };
2201        if let Some((name, class)) = answer {
2202            trace_lane(face, point, name, class);
2203            return Ok(class);
2204        }
2205        from = position + 1;
2206    }
2207    let generic = timed_lane(&prepared.generic, || prepare_generic(face, &prepared), |p, ms| {
2208        p.generic_preps += 1;
2209        p.generic_ms += ms;
2210    })?;
2211    let (crossings, boundary, nearest) = generic_scan(generic, point, tolerance, scan);
2212    if boundary {
2213        trace_lane(face, point, "polygon_boundary", PolygonClass::Boundary);
2214        return Ok(PolygonClass::Boundary);
2215    }
2216    let parity = if crossings % 2 == 1 {
2217        PolygonClass::Inside
2218    } else {
2219        PolygonClass::Outside
2220    };
2221    let chord = nearest.map(|(loop_index, index, distance)| {
2222        let polygon = &generic.loops[loop_index].polygon;
2223        let prep = generic.segments[loop_index][index];
2224        (
2225            polygon[index],
2226            polygon[(index + 1) % polygon.len()],
2227            &face.loops[loop_index].coedges[prep.coedge_index].pcurve,
2228            prep,
2229            distance,
2230        )
2231    });
2232    if containment_trace_enabled() {
2233        let (distance, length) = match chord.as_ref() {
2234            Some((start, end, _, _, distance)) => (*distance, end.sub(*start).length()),
2235            None => (f64::NAN, f64::NAN),
2236        };
2237        eprintln!(
2238            "containment face={} uv=({:.8},{:.8}) crossings={crossings} parity={parity:?} \
2239             nearest distance={distance:.3e} segment length={length:.3e}",
2240            face.id, point.x, point.y
2241        );
2242    }
2243    // The LAST of the special lanes, and the only one that engages here rather
2244    // than ahead of the scan: it substitutes for the parity count and for nothing
2245    // else. The four lanes above were each written for a shape the parity gets
2246    // wrong and are calibrated against real documents; pre-empting them would move
2247    // decisions this change has no business moving. Whenever the nearest-curve
2248    // refinement below can answer, it is more accurate than any global rule and is
2249    // what the imprint and the fragment builder are tuned against — so this asks
2250    // the sphere only on the complement of the refinement's own condition.
2251    let parity_decides = match chord.as_ref() {
2252        None => true,
2253        Some((start, end, _, _, distance)) => {
2254            let length = end.sub(*start).length();
2255            *distance > length || length <= 0.0
2256        }
2257    };
2258    if parity_decides {
2259        if let Some(class) = sphere_chart_point_in_face(face, point, &prepared, &generic.loops)? {
2260            trace_lane(face, point, "sphere_chart", class);
2261            return Ok(class);
2262        }
2263    }
2264    let Some((segment_start, segment_end, curve, prep, distance)) = chord else {
2265        trace_lane(face, point, "parity (no segment)", parity);
2266        return Ok(parity);
2267    };
2268    let segment_length = segment_end.sub(segment_start).length();
2269    if distance > segment_length || segment_length <= 0.0 {
2270        trace_lane(face, point, "parity (far from every segment)", parity);
2271        return Ok(parity);
2272    }
2273    let chord_parameter = prep.parameter_start
2274        + (prep.parameter_end - prep.parameter_start)
2275            * (point.sub(segment_start).dot(segment_end.sub(segment_start))
2276                / (segment_length * segment_length))
2277                .clamp(0.0, 1.0);
2278    let [domain_start, domain_end] = curve.domain()?;
2279    let mut parameter = chord_parameter;
2280    for _ in 0..12 {
2281        let derivatives = curve.derivatives(parameter, 2)?;
2282        let on_curve = Vec2 {
2283            x: derivatives[0].x,
2284            y: derivatives[0].y,
2285        };
2286        let tangent = Vec2 {
2287            x: derivatives[1].x,
2288            y: derivatives[1].y,
2289        };
2290        let second = Vec2 {
2291            x: derivatives[2].x,
2292            y: derivatives[2].y,
2293        };
2294        let residual = on_curve.sub(point);
2295        let denominator = tangent.dot(tangent) + residual.dot(second);
2296        if denominator.abs() < 1e-30 {
2297            break;
2298        }
2299        let step = -residual.dot(tangent) / denominator;
2300        parameter = (parameter + step).clamp(domain_start, domain_end);
2301        if step.abs() < 1e-14 * (domain_end - domain_start + 1.0) {
2302            break;
2303        }
2304    }
2305    let margin = 1e-9 * (domain_end - domain_start);
2306    if parameter > domain_start + margin && parameter < domain_end - margin {
2307        let derivatives = curve.derivatives(parameter, 1)?;
2308        let on_curve = Vec2 {
2309            x: derivatives[0].x,
2310            y: derivatives[0].y,
2311        };
2312        let tangent = Vec2 {
2313            x: derivatives[1].x,
2314            y: derivatives[1].y,
2315        };
2316        let offset = point.sub(on_curve);
2317        if offset.length() <= tolerance {
2318            trace_lane(face, point, "nearest curve (on it)", PolygonClass::Boundary);
2319            return Ok(PolygonClass::Boundary);
2320        }
2321        let cross = tangent.x * offset.y - tangent.y * offset.x;
2322        if cross.abs() > 1e-30 {
2323            let class = if (cross > 0.0) == face.same_sense {
2324                PolygonClass::Inside
2325            } else {
2326                PolygonClass::Outside
2327            };
2328            trace_lane(face, point, "nearest curve (side)", class);
2329            return Ok(class);
2330        }
2331    }
2332    trace_lane(face, point, "parity (nearest curve declined)", parity);
2333    Ok(parity)
2334}