Skip to main content

brep_kernel/brep/classification/
face_containment.rs

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