Skip to main content

brep_kernel/brep/classification/
face_containment.rs

1use crate::curve::interior_knot_count;
2use super::*;
3
4fn point_segment_distance(point: Vec2, start: Vec2, end: Vec2) -> f64 {
5    let segment = end.sub(start);
6    let length_squared = segment.dot(segment);
7    if length_squared <= 1e-30 {
8        return point.sub(start).length();
9    }
10    let parameter = (point.sub(start).dot(segment) / length_squared).clamp(0.0, 1.0);
11    point.sub(start.add(segment.scale(parameter))).length()
12}
13
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub enum PolygonClass {
16    Inside,
17    Outside,
18    Boundary,
19}
20
21fn point_in_polygon(point: Vec2, polygon: &[Vec2], tolerance: f64) -> PolygonClass {
22    for index in 0..polygon.len() {
23        if point_segment_distance(point, polygon[index], polygon[(index + 1) % polygon.len()])
24            <= tolerance
25        {
26            return PolygonClass::Boundary;
27        }
28    }
29    let mut inside = false;
30    for index in 0..polygon.len() {
31        let a = polygon[index];
32        let b = polygon[(index + 1) % polygon.len()];
33        if (a.y > point.y) != (b.y > point.y) {
34            let crossing = a.x + (point.y - a.y) / (b.y - a.y) * (b.x - a.x);
35            if crossing > point.x {
36                inside = !inside;
37            }
38        }
39    }
40    if inside {
41        PolygonClass::Inside
42    } else {
43        PolygonClass::Outside
44    }
45}
46
47struct SegmentReference<'a> {
48    start: Vec2,
49    end: Vec2,
50    curve: &'a NurbsCurve,
51    parameter_start: f64,
52    parameter_end: f64,
53}
54
55
56/// Point-in-face for a DOUBLY-periodic (torus) seam-band face, whose material
57/// region the raw even-odd/tangent test below gets wrong: the constant-level
58/// seam rim is a zero-area iso line and the wavy cut's own polygon encloses only
59/// the thin strip it bounds, so a point in the band reads Outside (or flips on
60/// the fragile nearest-tangent refinement). Reconstruct the band as one simple
61/// (u,v) polygon — identical topology to the mass integrator and tessellator —
62/// and test the point against it, so all three subsystems agree on the material
63/// side. Returns None (keep the general path) for every other face.
64fn seam_band_point_in_face(
65    face: &FaceRecord,
66    point: Vec2,
67    tolerance: f64,
68) -> Result<Option<PolygonClass>, String> {
69    if face.surface.closed_directions()? != (true, true) {
70        return Ok(None);
71    }
72    let [u0, u1] = face.surface.domain_u()?;
73    let [v0, v1] = face.surface.domain_v()?;
74    if crate::topology::doubly_periodic_has_only_collapsed_loops(face)? {
75        return Ok(Some(PolygonClass::Inside));
76    }
77    let u_span = (u1 - u0).abs().max(1e-30);
78    let v_span = (v1 - v0).abs().max(1e-30);
79    let mut loops_uv: Vec<Vec<[f64; 2]>> = Vec::with_capacity(face.loops.len());
80    for loop_record in &face.loops {
81        let mut points: Vec<[f64; 2]> = Vec::new();
82        for coedge in &loop_record.coedges {
83            let [d0, d1] = coedge.pcurve.domain()?;
84            let samples = 24;
85            for k in 0..=samples {
86                let t = d0 + (d1 - d0) * k as f64 / samples as f64;
87                let p = coedge.pcurve.evaluate(t)?;
88                points.push([p.x, p.y]);
89            }
90        }
91        let (mut umin, mut umax, mut vmin, mut vmax) = (
92            f64::INFINITY,
93            f64::NEG_INFINITY,
94            f64::INFINITY,
95            f64::NEG_INFINITY,
96        );
97        for p in &points {
98            umin = umin.min(p[0]);
99            umax = umax.max(p[0]);
100            vmin = vmin.min(p[1]);
101            vmax = vmax.max(p[1]);
102        }
103        // Mirror `mass_properties::biperiodic_band_range`: a torus has no
104        // geometric pole, so a VERTEX_LOOP whose whole pcurve trace collapses
105        // to one parameter point is a zero-area puncture, not a band boundary.
106        // Restrict the new behavior to faces that actually contain such an
107        // extra loop; ordinary two-loop torus classification stays unchanged.
108        if face.loops.len() > 2 && (umax - umin) <= 1e-3 * u_span && (vmax - vmin) <= 1e-3 * v_span
109        {
110            continue;
111        }
112        loops_uv.push(points);
113    }
114    // MERGED SEAM-CARRYING BAND (single loop): `insert_periodic_band_seam_edges`
115    // fuses a wrapped band's two rims into ONE loop joined by seam columns, so
116    // the two-loop paths below never see it, and the plain even-odd polygon
117    // carries a period-magnitude hop at each rim→column junction (a phantom
118    // diagonal across the domain) that misclassifies the whole middle zone.
119    // Unwrap the loop onto the covering plane (`loop_seam_offsets`, the same
120    // fold mass integration and tessellation use), where it IS a simple
121    // polygon, and even-odd the query's period images against it. Gated on a
122    // genuinely seam-crossing single loop on a doubly-periodic surface; every
123    // other face falls through unchanged. Hatch: BREP_SEAM_BAND_MERGED=0.
124    if face.loops.len() == 1
125        && loops_uv.len() == 1
126        && std::env::var("BREP_SEAM_BAND_MERGED").as_deref() != Ok("0")
127    {
128        let coedges = &face.loops[0].coedges;
129        let offsets = crate::topology::loop_seam_offsets(coedges, true, true, u_span, v_span)?;
130        if offsets.iter().any(|o| o[0] != 0.0 || o[1] != 0.0) {
131            let mut polygon: Vec<Vec2> = Vec::new();
132            for (coedge_index, coedge) in coedges.iter().enumerate() {
133                let [d0, d1] = coedge.pcurve.domain()?;
134                let samples = 24;
135                for k in 0..samples {
136                    let t = d0 + (d1 - d0) * k as f64 / samples as f64;
137                    let p = coedge.pcurve.evaluate(t)?;
138                    polygon.push(Vec2 {
139                        x: p.x + offsets[coedge_index][0],
140                        y: p.y + offsets[coedge_index][1],
141                    });
142                }
143            }
144            let mut best = PolygonClass::Outside;
145            'images: for du in [-1.0, 0.0, 1.0] {
146                for dv in [-1.0, 0.0, 1.0] {
147                    let image = Vec2 {
148                        x: point.x + du * u_span,
149                        y: point.y + dv * v_span,
150                    };
151                    match point_in_polygon(image, &polygon, tolerance) {
152                        PolygonClass::Boundary => {
153                            best = PolygonClass::Boundary;
154                            break 'images;
155                        }
156                        PolygonClass::Inside => best = PolygonClass::Inside,
157                        PolygonClass::Outside => {}
158                    }
159                }
160            }
161            return Ok(Some(best));
162        }
163    }
164    if loops_uv.len() != 2 {
165        return Ok(None);
166    }
167    if let Some(band) = crate::topology::analyze_doubly_periodic_seam_band(
168        &loops_uv,
169        [u0, u1, v0, v1],
170        face.same_sense,
171    ) {
172        let polygon: Vec<Vec2> =
173            crate::topology::seam_band_uv_polygon(&loops_uv, [u0, u1, v0, v1], &band)
174                .into_iter()
175                .map(|p| Vec2 { x: p[0], y: p[1] })
176                .collect();
177        return Ok(Some(point_in_polygon(point, &polygon, tolerance)));
178    }
179    // TWO clean full-wrap rims are the companion `biperiodic_band_range` case,
180    // with or without an extra collapsed puncture. Classify only the cross
181    // parameter: the material is either the strip between the rims or its
182    // periodic complement. The exact two-loop/full-wrap/constant-cross-level
183    // gates below mirror mass integration and tessellation; every other torus
184    // face retains the general classifier.
185    for p_is_u in [true, false] {
186        let (period, q_extent) = if p_is_u {
187            (u1 - u0, v_span)
188        } else {
189            (v1 - v0, u_span)
190        };
191        if !(period > 0.0) {
192            continue;
193        }
194        let coord = |p: &[f64; 2]| if p_is_u { (p[0], p[1]) } else { (p[1], p[0]) };
195        let mut rings = Vec::with_capacity(2);
196        for points in &loops_uv {
197            let (mut pmin, mut pmax, mut qmin, mut qmax) = (
198                f64::INFINITY,
199                f64::NEG_INFINITY,
200                f64::INFINITY,
201                f64::NEG_INFINITY,
202            );
203            for p in points {
204                let (periodic, cross) = coord(p);
205                pmin = pmin.min(periodic);
206                pmax = pmax.max(periodic);
207                qmin = qmin.min(cross);
208                qmax = qmax.max(cross);
209            }
210            if (pmax - pmin) < 0.6 * period || (qmax - qmin) > 0.05 * q_extent {
211                rings.clear();
212                break;
213            }
214            let mut net = 0.0;
215            for pair in points.windows(2) {
216                let mut delta = coord(&pair[1]).0 - coord(&pair[0]).0;
217                if delta > 0.5 * period {
218                    delta -= period;
219                } else if delta < -0.5 * period {
220                    delta += period;
221                }
222                net += delta;
223            }
224            let direction = if net > 0.25 * period {
225                1
226            } else if net < -0.25 * period {
227                -1
228            } else {
229                0
230            };
231            rings.push((0.5 * (qmin + qmax), direction));
232        }
233        if rings.len() != 2 {
234            continue;
235        }
236        rings.sort_by(|a, b| a.0.total_cmp(&b.0));
237        let [(q_lo, lower_direction), (q_hi, upper_direction)] = rings.as_slice() else {
238            unreachable!()
239        };
240        let inconclusive =
241            *lower_direction == 0 || *upper_direction == 0 || lower_direction == upper_direction;
242        let between_is_ccw_uv = if p_is_u {
243            *lower_direction > 0
244        } else {
245            *lower_direction < 0
246        };
247        let complement = !inconclusive && between_is_ccw_uv != face.same_sense;
248        let q = if p_is_u { point.y } else { point.x };
249        if (q - q_lo).abs() <= tolerance || (q - q_hi).abs() <= tolerance {
250            return Ok(Some(PolygonClass::Boundary));
251        }
252        let between = q > *q_lo && q < *q_hi;
253        return Ok(Some(if between != complement {
254            PolygonClass::Inside
255        } else {
256            PolygonClass::Outside
257        }));
258    }
259    Ok(None)
260}
261
262/// Point-in-face for a SINGLY-PERIODIC face with a SEAM-STRADDLING loop: a
263/// trim loop crossing the u seam is stored with its samples folded into the
264/// domain, so its flat polygon is TORN at the seam and plain even-odd
265/// misclassifies near the tear (STEP 00000585-family: face 519's big inner
266/// loop straddles the seam and a REAL section sub-segment between two accepted
267/// pieces read Outside in `process_curve`'s midpoint gate — trial 174's
268/// missing middle segment). Mirror fragment.rs's proven seam-straddling
269/// fallback: UNWRAP every loop by accumulating period-folded deltas (a
270/// zero-winding loop closes in the covering plane), then even-odd the query at
271/// its −P/0/+P period images across all unwrapped loops. Fires only when the
272/// face is closed in exactly u, has >2 loops, and at least one loop straddles
273/// the seam (≥1 fold jump); every other face returns None and keeps the plain
274/// classifier bit-identically. Loops that WIND the period (net winding ≠ 0)
275/// do not close under unwrapping — bail to the plain path rather than guess.
276/// Escape hatch `BREP_HORIZON_CONTAINMENT=0`.
277fn wrapped_horizon_point_in_face(
278    face: &FaceRecord,
279    point: Vec2,
280    tolerance: f64,
281) -> Result<Option<PolygonClass>, String> {
282    if face.surface.closed_directions()? != (true, false) || face.loops.is_empty() {
283        return Ok(None);
284    }
285    if std::env::var("BREP_HORIZON_CONTAINMENT").as_deref() == Ok("0") {
286        return Ok(None);
287    }
288    // SINGLE/DOUBLE-LOOP straddlers (t91's face 675: ONE loop drawn in-domain
289    // that hops the u-seam twice, trimming a sliver strip AT the seam) used
290    // to be excluded by a `loops.len() <= 2` gate and fell to the plain
291    // even-odd, whose verdict on the hopped polygon is INVERTED (the strip
292    // interior read Outside, far azimuths read Inside) — minting bogus
293    // far-azimuth section pieces and discarding the real ones. The unwrap +
294    // period-image even-odd below is loop-count-agnostic, so the gate now
295    // only requires a non-empty loop set; non-straddling loops still bail to
296    // the plain path unchanged. Escape hatch: BREP_HORIZON_SINGLE_LOOP=0
297    // restores the old minimum.
298    if face.loops.len() <= 2 && std::env::var("BREP_HORIZON_SINGLE_LOOP").as_deref() == Ok("0") {
299        return Ok(None);
300    }
301    let [u0, u1] = face.surface.domain_u()?;
302    let u_period = u1 - u0;
303    if u_period <= 0.0 {
304        return Ok(None);
305    }
306    let debug = std::env::var("BREP_DEBUG_HORIZON").is_ok();
307    let mut loops: Vec<Vec<Vec2>> = Vec::new();
308    let mut straddling = false;
309    for loop_record in &face.loops {
310        let mut points: Vec<Vec2> = Vec::new();
311        for coedge in &loop_record.coedges {
312            let curve = &coedge.pcurve;
313            let [start, end] = curve.domain()?;
314            let sample_count =
315                2usize.max((interior_knot_count(&curve.knots, curve.degree) + 1) * (curve.degree + 1) * 4);
316            for index in 0..sample_count {
317                let parameter = start + (end - start) * index as f64 / sample_count as f64;
318                let evaluated = curve.evaluate(parameter)?;
319                points.push(Vec2 {
320                    x: evaluated.x,
321                    y: evaluated.y,
322                });
323            }
324        }
325        if points.len() < 3 {
326            continue;
327        }
328        // Unwrap into the covering plane: place each sample at the previous
329        // one plus the period-folded delta. A seam-straddling loop becomes a
330        // continuous closed polygon; a WINDING loop does not close — bail.
331        let mut unwrapped = Vec::with_capacity(points.len());
332        let mut jumps = 0usize;
333        let mut cursor = points[0];
334        unwrapped.push(cursor);
335        for pair in points.windows(2) {
336            let mut du = pair[1].x - pair[0].x;
337            let folded = du - u_period * (du / u_period).round();
338            if (du - folded).abs() > 0.25 * u_period {
339                jumps += 1;
340            }
341            du = folded;
342            cursor = Vec2 {
343                x: cursor.x + du,
344                y: pair[1].y,
345            };
346            unwrapped.push(cursor);
347        }
348        let closure = (unwrapped[0].x - unwrapped[unwrapped.len() - 1].x).abs();
349        if closure > 0.25 * u_period {
350            if debug {
351                eprintln!(
352                    "horizon: face {} loop winds the period (closure {closure:.3}) — bail",
353                    face.id
354                );
355            }
356            return Ok(None); // winding loop: unwrapping cannot close it
357        }
358        if jumps > 0 {
359            straddling = true;
360        }
361        loops.push(unwrapped);
362    }
363    if !straddling || loops.is_empty() {
364        return Ok(None);
365    }
366    // Even-odd on the QUOTIENT cylinder: each unwrapped loop is tested at
367    // every period image of the query and contributes its own parity. The old
368    // combining ("any single image with an odd crossing count ⇒ Inside") is
369    // wrong whenever two loops live in DIFFERENT period frames after
370    // unwrapping — t181/00000312#p7: face 519's seam-straddling HOLE unwraps
371    // to u∈[−0.039, 0.039] while the outer rectangle spans [0, 1], so a query
372    // inside the hole at u≈0.986 is contained by the outer loop at shift 0
373    // (odd ⇒ Inside) and by the hole at shift −P (odd ⇒ Inside again); the
374    // true even count (outer + hole = 2 ⇒ Outside) is never assembled at any
375    // single image. Fragment selection then kept a PHANTOM half-hole region
376    // whose boundary (half the hole loop + a derived chord) minted same-sense
377    // coedges at assembly. Summing per-loop parities across images restores
378    // the covering-space even-odd. Escape hatch: BREP_HORIZON_CROSS_FRAME=0
379    // restores the old per-image combining.
380    if std::env::var("BREP_HORIZON_CROSS_FRAME").as_deref() == Ok("0") {
381        let mut best: Option<PolygonClass> = None;
382        for shift in [-u_period, 0.0, u_period] {
383            let image = Vec2 {
384                x: point.x + shift,
385                y: point.y,
386            };
387            let mut crossings = 0usize;
388            for polygon in &loops {
389                match point_in_polygon(image, polygon, tolerance) {
390                    PolygonClass::Boundary => return Ok(Some(PolygonClass::Boundary)),
391                    PolygonClass::Inside => crossings += 1,
392                    PolygonClass::Outside => {}
393                }
394            }
395            if crossings % 2 == 1 {
396                best = Some(PolygonClass::Inside);
397            } else if best.is_none() {
398                best = Some(PolygonClass::Outside);
399            }
400        }
401        if debug {
402            eprintln!(
403                "horizon: face {} loops={} straddling (legacy per-image) -> {:?}",
404                face.id,
405                loops.len(),
406                best
407            );
408        }
409        return Ok(best);
410    }
411    let mut crossings = 0usize;
412    for polygon in &loops {
413        let mut image_hits = 0usize;
414        for shift in [-u_period, 0.0, u_period] {
415            let image = Vec2 {
416                x: point.x + shift,
417                y: point.y,
418            };
419            match point_in_polygon(image, polygon, tolerance) {
420                PolygonClass::Boundary => return Ok(Some(PolygonClass::Boundary)),
421                PolygonClass::Inside => image_hits += 1,
422                PolygonClass::Outside => {}
423            }
424        }
425        crossings += image_hits % 2;
426    }
427    let class = if crossings % 2 == 1 {
428        PolygonClass::Inside
429    } else {
430        PolygonClass::Outside
431    };
432    if debug {
433        eprintln!(
434            "horizon: face {} loops={} straddling -> {:?}",
435            face.id,
436            loops.len(),
437            class
438        );
439    }
440    Ok(Some(class))
441}
442
443/// Classify a spherical cap represented by a varying full-wrap contact rim and
444/// the oppositely wound collapsed rim at one sphere pole.  This is the natural
445/// topology when a circle about an oblique axis encloses a pole of the sphere's
446/// stored parameter frame.
447fn winding_sphere_cap_point_in_face(
448    face: &FaceRecord,
449    point: Vec2,
450    tolerance: f64,
451) -> Result<Option<PolygonClass>, String> {
452    if !matches!(
453        face.surface.analytic(),
454        Some(crate::AnalyticSurface::Sphere { .. })
455    ) || face.surface.closed_directions()? != (true, false)
456        || face.loops.len() != 2
457    {
458        return Ok(None);
459    }
460    let [u0, u1] = face.surface.domain_u()?;
461    let [v0, v1] = face.surface.domain_v()?;
462    let period = u1 - u0;
463    let v_span = v1 - v0;
464    if !(period > 0.0 && v_span > 0.0) {
465        return Ok(None);
466    }
467    struct WindingLoop {
468        points: Vec<Vec2>,
469        winding: f64,
470        vmin: f64,
471        vmax: f64,
472    }
473    let mut loops = Vec::with_capacity(2);
474    for loop_record in &face.loops {
475        let mut points = Vec::new();
476        for coedge in &loop_record.coedges {
477            let [start, end] = coedge.pcurve.domain()?;
478            let samples = 2usize
479                .max((interior_knot_count(&coedge.pcurve.knots, coedge.pcurve.degree) + 1) * (coedge.pcurve.degree + 1) * 4);
480            for index in 0..samples {
481                let parameter = start + (end - start) * index as f64 / samples as f64;
482                let p = coedge.pcurve.evaluate(parameter)?;
483                points.push(Vec2 { x: p.x, y: p.y });
484            }
485        }
486        if points.len() < 2 {
487            return Ok(None);
488        }
489        let first = points[0];
490        let mut cursor = first;
491        let mut unwrapped = vec![cursor];
492        for next in points.iter().skip(1) {
493            let du = next.x - cursor.x;
494            let folded = du - period * (du / period).round();
495            cursor = Vec2 {
496                x: cursor.x + folded,
497                y: next.y,
498            };
499            unwrapped.push(cursor);
500        }
501        let last_raw = *points.last().unwrap();
502        let closing_du = first.x - last_raw.x;
503        let closing_folded = closing_du - period * (closing_du / period).round();
504        let winding = cursor.x + closing_folded - first.x;
505        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
506        for p in &unwrapped {
507            vmin = vmin.min(p.y);
508            vmax = vmax.max(p.y);
509        }
510        loops.push(WindingLoop {
511            points: unwrapped,
512            winding,
513            vmin,
514            vmax,
515        });
516    }
517    if loops
518        .iter()
519        .any(|loop_data| (loop_data.winding.abs() - period).abs() > 0.05 * period)
520        || loops[0].winding * loops[1].winding >= 0.0
521    {
522        return Ok(None);
523    }
524    let flat = |loop_data: &WindingLoop| loop_data.vmax - loop_data.vmin <= 1e-6 * v_span;
525    let (pole, rim) = match (flat(&loops[0]), flat(&loops[1])) {
526        (true, false) => (&loops[0], &loops[1]),
527        (false, true) => (&loops[1], &loops[0]),
528        _ => return Ok(None),
529    };
530    let pole_v = 0.5 * (pole.vmin + pole.vmax);
531    if (pole_v - v0).abs() > 1e-6 * v_span && (pole_v - v1).abs() > 1e-6 * v_span {
532        return Ok(None);
533    }
534
535    let window_start = rim.points[0].x.min(rim.points[rim.points.len() - 1].x);
536    let query_u = window_start + (point.x - window_start).rem_euclid(period);
537    let mut crossings = Vec::new();
538    for pair in rim.points.windows(2) {
539        let (a, b) = (pair[0], pair[1]);
540        if (a.x > query_u) != (b.x > query_u) {
541            crossings.push(a.y + (query_u - a.x) / (b.x - a.x) * (b.y - a.y));
542        }
543        for image in [-period, 0.0, period] {
544            if point_segment_distance(
545                Vec2 {
546                    x: point.x + image,
547                    y: point.y,
548                },
549                a,
550                b,
551            ) <= tolerance
552            {
553                return Ok(Some(PolygonClass::Boundary));
554            }
555        }
556    }
557    let Some(rim_v) = crossings
558        .into_iter()
559        .min_by(|a, b| (a - point.y).abs().total_cmp(&(b - point.y).abs()))
560    else {
561        return Ok(None);
562    };
563    let inside = if pole_v < rim_v {
564        point.y <= rim_v + tolerance
565    } else {
566        point.y >= rim_v - tolerance
567    };
568    Ok(Some(if inside {
569        PolygonClass::Inside
570    } else {
571        PolygonClass::Outside
572    }))
573}
574
575/// Point-in-face for a SINGLY-PERIODIC (closed-u) FULL-BAND STRIP whose two
576/// full-wrap rim loops are drawn as COVERING-PLANE pcurves running OUT OF the
577/// u-domain by up to a whole period (case 08:10-genus face 1513, the 4th seam
578/// representation after helmet-unwrapped / band-complement / 675-hops): the
579/// raw even-odd sees u-winding loop polygons whose parity is meaningless, so
580/// nearly the whole material strip reads Outside, the marched clip crumbles
581/// the section curves to sub-mm bits, and the neighbours strand one-use.
582///
583/// The material region is the strip BETWEEN the two rims: on an open-v
584/// surface a 2-loop face whose loops both wrap the full period has no other
585/// representable region. The verdict is a sampled per-u hull — each rim is a
586/// single periodic polyline v = rim(u) in the covering plane, and the query
587/// point is between the rims iff an upward (+v) vertical ray at the query's
588/// u (folded modulo the period into each rim's own one-period window) crosses
589/// the two rim polylines an odd number of times in total. NO flat-level
590/// approximation is used (the rims are scalloped: 1513's upper rim is flat at
591/// v=1 on parts of u but dips to v=0.745 elsewhere — a v-between-extremes
592/// rule is provably wrong on it).
593///
594/// STRUCTURAL gates (all must hold; anything else returns None and keeps the
595/// previous classification bit-identically):
596///   - surface closed in exactly u; exactly 2 loops;
597///   - at least one loop's samples run OUT of the u-domain by >1e-3 period
598///     (the covering-plane discriminator — in-domain 2-rim bands keep their
599///     current path);
600///   - each loop's unwrapped net u-travel is exactly ±one period and its v
601///     closes (a true full-wrap rim, not a partial arc);
602///   - opposite travel directions, disjoint v-hulls, and the material-left
603///     winding rule confirming the BETWEEN strip (a complement verdict cannot
604///     be represented on an open-v surface — decline, never guess).
605/// Escape hatch: BREP_COVERING_RIM_STRIP=0.
606fn covering_rim_strip_point_in_face(
607    face: &FaceRecord,
608    point: Vec2,
609    tolerance: f64,
610) -> Result<Option<PolygonClass>, String> {
611    if face.surface.closed_directions()? != (true, false) || face.loops.len() != 2 {
612        return Ok(None);
613    }
614    if std::env::var("BREP_COVERING_RIM_STRIP").as_deref() == Ok("0") {
615        return Ok(None);
616    }
617    let [u0, u1] = face.surface.domain_u()?;
618    let [v0, v1] = face.surface.domain_v()?;
619    let period = u1 - u0;
620    if !(period > 0.0) {
621        return Ok(None);
622    }
623    let v_span = (v1 - v0).abs().max(1e-30);
624    struct Rim {
625        points: Vec<Vec2>,
626        vmin: f64,
627        vmax: f64,
628        ascending: bool,
629    }
630    let mut rims: Vec<Rim> = Vec::with_capacity(2);
631    let mut out_of_domain = false;
632    for loop_record in &face.loops {
633        let mut points: Vec<Vec2> = Vec::new();
634        for coedge in &loop_record.coedges {
635            let curve = &coedge.pcurve;
636            let [start, end] = curve.domain()?;
637            let sample_count =
638                2usize.max((interior_knot_count(&curve.knots, curve.degree) + 1) * (curve.degree + 1) * 4);
639            for index in 0..=sample_count {
640                let parameter = start + (end - start) * index as f64 / sample_count as f64;
641                let evaluated = curve.evaluate(parameter)?;
642                points.push(Vec2 {
643                    x: evaluated.x,
644                    y: evaluated.y,
645                });
646            }
647        }
648        if points.len() < 3 {
649            return Ok(None);
650        }
651        // Covering-plane discriminator: this lane exists for loops drawn PAST
652        // the domain; everything in-domain keeps the existing classifiers.
653        for p in &points {
654            if p.x < u0 - 1e-3 * period || p.x > u1 + 1e-3 * period {
655                out_of_domain = true;
656            }
657        }
658        // Unwrap into the covering plane (period-folded deltas — a no-op for
659        // an already-continuous covering-plane representation, and the same
660        // fold the horizon lane uses for in-domain seam hops).
661        let mut unwrapped: Vec<Vec2> = Vec::with_capacity(points.len());
662        let mut cursor = points[0];
663        unwrapped.push(cursor);
664        for pair in points.windows(2) {
665            let du = pair[1].x - pair[0].x;
666            let folded = du - period * (du / period).round();
667            cursor = Vec2 {
668                x: cursor.x + folded,
669                y: pair[1].y,
670            };
671            unwrapped.push(cursor);
672        }
673        let net = unwrapped[unwrapped.len() - 1].x - unwrapped[0].x;
674        if (net.abs() - period).abs() > 2e-2 * period {
675            return Ok(None); // not a clean single full wrap
676        }
677        if (unwrapped[unwrapped.len() - 1].y - unwrapped[0].y).abs() > 1e-3 * v_span {
678            return Ok(None); // rim does not close in v
679        }
680        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
681        for p in &unwrapped {
682            vmin = vmin.min(p.y);
683            vmax = vmax.max(p.y);
684        }
685        rims.push(Rim {
686            points: unwrapped,
687            vmin,
688            vmax,
689            ascending: net > 0.0,
690        });
691    }
692    if !out_of_domain {
693        return Ok(None);
694    }
695    if rims[0].ascending == rims[1].ascending {
696        return Ok(None); // rims must traverse opposite u directions
697    }
698    let (lower, upper) = if rims[0].vmax <= rims[1].vmin {
699        (&rims[0], &rims[1])
700    } else if rims[1].vmax <= rims[0].vmin {
701        (&rims[1], &rims[0])
702    } else {
703        return Ok(None); // interleaved v-hulls: not a clean strip
704    };
705    if upper.vmin - lower.vmax <= 1e-6 * v_span {
706        return Ok(None);
707    }
708    // Material-left rule (same convention as the doubly-periodic band lanes):
709    // the between strip is CCW in uv iff the LOWER rim travels +u; that must
710    // match the face sense, else the material would be the complement — which
711    // an open-v surface cannot represent. Decline rather than guess.
712    if (lower.ascending) != face.same_sense {
713        return Ok(None);
714    }
715    // Boundary: proximity to either rim polyline at the query's period images.
716    for rim in [lower, upper] {
717        for shift in [-period, 0.0, period] {
718            let image = Vec2 {
719                x: point.x + shift,
720                y: point.y,
721            };
722            for pair in rim.points.windows(2) {
723                if point_segment_distance(image, pair[0], pair[1]) <= tolerance {
724                    return Ok(Some(PolygonClass::Boundary));
725                }
726            }
727        }
728    }
729    // Sampled per-u hull: fold the query into each rim's own one-period
730    // window and count upward-ray crossings; odd total = between the rims.
731    let mut crossings = 0usize;
732    for rim in [lower, upper] {
733        let window_base = rim.points[0].x.min(rim.points[rim.points.len() - 1].x);
734        let x = window_base + (point.x - window_base).rem_euclid(period);
735        for pair in rim.points.windows(2) {
736            let (a, b) = (pair[0], pair[1]);
737            if (a.x > x) != (b.x > x) {
738                let v_cross = a.y + (x - a.x) / (b.x - a.x) * (b.y - a.y);
739                if v_cross > point.y {
740                    crossings += 1;
741                }
742            }
743        }
744    }
745    Ok(Some(if crossings % 2 == 1 {
746        PolygonClass::Inside
747    } else {
748        PolygonClass::Outside
749    }))
750}
751
752/// The last few spherical regions built by [`sphere_chart_point_in_face`], keyed
753/// by the exact inputs that determine them.
754///
755/// A region depends on the face's sphere, its orientation and its trim samples —
756/// never on the query point — but the trim query is called once PER POINT from
757/// the innermost loops of the imprint and the fragment builder, and building one
758/// costs a surface evaluation per boundary sample, a 27-cell canonicalization
759/// pass, two hash maps and a seed search that can run two dozen crossing counts.
760/// Rebuilding that for every point of the same face is the whole cost of this
761/// lane; the query itself is one crossing count.
762///
763/// The key is compared EXACTLY, not hashed: a signature is a few hundred `f64`s
764/// and walking it costs a fraction of one surface evaluation, so there is no
765/// reason to accept even a remote chance of answering from another face's region.
766/// Four entries, most-recent-first — a boolean alternates between a handful of
767/// faces at a time, and a miss only costs what this lane used to cost always.
768struct SphereRegionCache {
769    scratch: Vec<f64>,
770    entries: Vec<(Vec<f64>, crate::sphere_chart::SphericalRegion)>,
771}
772
773const SPHERE_REGION_CACHE_ENTRIES: usize = 4;
774
775thread_local! {
776    static SPHERE_REGIONS: std::cell::RefCell<SphereRegionCache> = const {
777        std::cell::RefCell::new(SphereRegionCache {
778            scratch: Vec::new(),
779            entries: Vec::new(),
780        })
781    };
782}
783
784/// One trim loop as [`parameter_point_in_face`] already sampled it: the closed
785/// polygon it counts parity against, plus `(edge id, first sample, sample count)`
786/// per coedge in loop order, which is all the spherical lane needs to drop a slit
787/// and to rebuild the boundary as SEGMENTS rather than as one ring.
788struct LoopSamples {
789    polygon: Vec<Vec2>,
790    coedges: Vec<(u64, usize, usize)>,
791}
792
793/// Point-in-face for a SPHERICAL carrier, decided on the ball itself instead of
794/// in its polar parameter domain — but ONLY in the far field, where the generic
795/// path has nothing better than a parity count.
796///
797/// The generic classifier below does not really answer by parity.  Whenever the
798/// query has a nearest trim segment whose foot is interior to that pcurve, it
799/// answers by which SIDE of that curve the point is on, which is locally exact
800/// and is what the imprint and the fragment builder are tuned against.  Parity is
801/// its fallback for points further from every trim segment than that segment is
802/// long — and parity is exactly what the polar domain breaks: a loop enclosing a
803/// pole does not enclose it in `uv` (it wraps the domain instead), and a region
804/// straddling the seam is not one polygon there at all.  Three separate special
805/// cases in this file exist because of that.
806///
807/// So this lane engages on the complement of the refinement's condition: it
808/// replaces the parity count and nothing else.  Material is the intersection of
809/// the regions to the LEFT of each trim loop, and the side a point falls on is
810/// the sign of the signed solid angle that loop subtends there — a question with
811/// no parameter domain in it, so pole and seam need no mention.  A collapsed pole
812/// loop has no area and a seam is traversed once each way; both cancel exactly.
813///
814/// [`crate::sphere_chart::SphericalRegion`] is the same classifier the chart
815/// tessellation uses, so the trim query and the mesh cannot disagree about where
816/// the material is.
817fn sphere_chart_point_in_face(
818    face: &FaceRecord,
819    point: Vec2,
820    sampled: &[LoopSamples],
821) -> Result<Option<PolygonClass>, String> {
822    if std::env::var("BREP_NO_SPHERE_CHARTS").is_ok()
823        || std::env::var("BREP_NO_SPHERE_CHART_TRIM").is_ok()
824    {
825        return Ok(None);
826    }
827    let Some(atlas) = crate::sphere_chart::SphereAtlas::of_surface(&face.surface) else {
828        return Ok(None);
829    };
830    // An edge used TWICE by one face is a slit — the parametric seam of a ball
831    // the trim never cut. It bounds no material, so it is left out of the region
832    // rather than cancelled numerically afterwards.
833    let mut uses: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
834    for coedge in face.loops.iter().flat_map(|record| record.coedges.iter()) {
835        *uses.entry(coedge.edge_id).or_insert(0) += 1;
836    }
837    // Boundary SEGMENTS, not one polyline per loop.
838    //
839    // A ball drilled through carries BOTH rims in a single loop, joined by the
840    // seam traversed once each way — a keyhole. Concatenating that loop's
841    // surviving coedges and closing the ring would bridge rim to rim with two
842    // chords that are not reverses of each other, and the winding of that figure
843    // is not the winding of the two rims: on a through-drilled ball it comes out
844    // exactly inverted. Handing the classifier the segments themselves lets it
845    // cancel the seam and recover the two real rims.
846    //
847    // The samples are the CALLER's, taken once for its own parity count and its
848    // own nearest-segment scan. Sampling the pcurves again here doubled the cost
849    // of every query on a spherical face, and the caller only reaches this lane
850    // when it would otherwise answer by parity — so that second pass was paid on
851    // nearly every query and used on almost none of them.
852    //
853    // They are sampled in each coedge's own traversal direction, which is the
854    // direction its pcurve is parameterized in: `forward` selects which end of
855    // the EDGE's samples a traversal starts from, it does not reverse the
856    // pcurve. A loop walked backwards puts the material on the wrong side of
857    // every boundary.
858    // The material-left convention is stated against the FACE normal, which the
859    // generic path encodes as `(cross > 0) == same_sense` in uv: material is left
860    // of the boundary seen from `same_sense ? Su x Sv : -(Su x Sv)`.
861    let outward_face_normal =
862        face.same_sense == atlas.parameterization_is_outward(&face.surface)?;
863    SPHERE_REGIONS.with(|cache| {
864        let SphereRegionCache { scratch, entries } = &mut *cache.borrow_mut();
865        // The signature: everything the region is built from, and nothing that
866        // varies with the query point.
867        scratch.clear();
868        scratch.extend_from_slice(&[
869            atlas.centre.x,
870            atlas.centre.y,
871            atlas.centre.z,
872            atlas.radius,
873            if outward_face_normal { 1.0 } else { 0.0 },
874        ]);
875        for axis in atlas.basis {
876            scratch.extend_from_slice(&[axis.x, axis.y, axis.z]);
877        }
878        // Where the per-coedge runs start, so the miss path below can read the
879        // samples back out of the signature instead of re-deriving which coedges
880        // survived.
881        let header = scratch.len();
882        for samples in sampled.iter() {
883            let count = samples.polygon.len();
884            if count < 2 {
885                continue;
886            }
887            for &(edge_id, first, span) in &samples.coedges {
888                if span == 0 || uses.get(&edge_id).copied().unwrap_or(0) >= 2 {
889                    continue;
890                }
891                scratch.push(span as f64);
892                // `span + 1` points: the coedge's own samples plus the first
893                // sample of the next coedge, which is this one's END point. The
894                // loop is closed, so the last coedge wraps back to `polygon[0]`.
895                for offset in 0..=span {
896                    let uv = samples.polygon[(first + offset) % count];
897                    scratch.push(uv.x);
898                    scratch.push(uv.y);
899                }
900            }
901        }
902        if let Some(index) = entries.iter().position(|(signature, _)| signature == scratch) {
903            if index != 0 {
904                entries.swap(0, index);
905            }
906        } else {
907            let mut points: Vec<Vec3> = Vec::new();
908            let mut spans: Vec<(usize, usize)> = Vec::new();
909            let mut cursor = header;
910            while cursor < scratch.len() {
911                let span = scratch[cursor] as usize;
912                cursor += 1;
913                let start = points.len();
914                for index in 0..=span {
915                    let uv = (scratch[cursor + 2 * index], scratch[cursor + 2 * index + 1]);
916                    points.push(face.surface.evaluate(uv.0, uv.1)?);
917                }
918                cursor += 2 * (span + 1);
919                spans.push((start, points.len()));
920            }
921            // One canonicalization over ALL the face's points: a vertex named by
922            // two coedges is evaluated twice and the two answers agree only to
923            // rounding, which the exact-reverse cancellation and the loop walk
924            // both need collapsed.
925            crate::sphere_chart::canonicalize_points(&mut points, 1e-9);
926            let mut boundary: Vec<(Vec3, Vec3)> = Vec::new();
927            for (first, last) in spans {
928                for index in first..last.saturating_sub(1) {
929                    boundary.push((points[index], points[index + 1]));
930                }
931            }
932            let region = crate::sphere_chart::SphericalRegion::from_segments(
933                atlas.centre,
934                &boundary,
935                outward_face_normal,
936            );
937            entries.insert(0, (scratch.clone(), region));
938            entries.truncate(SPHERE_REGION_CACHE_ENTRIES);
939        }
940        let region = &entries[0].1;
941        if region.is_whole_sphere() {
942            return Ok(Some(PolygonClass::Inside));
943        }
944        if !region.is_decidable() {
945            // No seed: decline to the generic path rather than answer Inside for
946            // the whole ball.
947            return Ok(None);
948        }
949        let probe = face.surface.evaluate(point.x, point.y)?;
950        Ok(Some(if region.contains(atlas.centre, probe) {
951            PolygonClass::Inside
952        } else {
953            PolygonClass::Outside
954        }))
955    })
956}
957
958pub fn parameter_point_in_face(
959    face: &FaceRecord,
960    point: Vec2,
961    tolerance: f64,
962) -> Result<PolygonClass, String> {
963    if let Some(class) = seam_band_point_in_face(face, point, tolerance)? {
964        return Ok(class);
965    }
966    if let Some(class) = winding_sphere_cap_point_in_face(face, point, tolerance)? {
967        return Ok(class);
968    }
969    if let Some(class) = wrapped_horizon_point_in_face(face, point, tolerance)? {
970        return Ok(class);
971    }
972    if let Some(class) = covering_rim_strip_point_in_face(face, point, tolerance)? {
973        return Ok(class);
974    }
975    let mut crossings = 0;
976    let mut boundary = false;
977    let mut nearest: Option<(SegmentReference<'_>, f64)> = None;
978    // Retained, not dropped per loop: the spherical lane below reads exactly
979    // these samples instead of taking a second set of its own.
980    let mut sampled: Vec<LoopSamples> = Vec::with_capacity(face.loops.len());
981    for loop_record in &face.loops {
982        let mut polygon = Vec::new();
983        let mut segments = Vec::new();
984        let mut coedges = Vec::with_capacity(loop_record.coedges.len());
985        for coedge in &loop_record.coedges {
986            let first = polygon.len();
987            let curve = &coedge.pcurve;
988            let [start, end] = curve.domain()?;
989            let sample_count =
990                2usize.max((interior_knot_count(&curve.knots, curve.degree) + 1) * (curve.degree + 1) * 4);
991            for index in 0..sample_count {
992                let parameter = start + (end - start) * index as f64 / sample_count as f64;
993                let evaluated = curve.evaluate(parameter)?;
994                polygon.push(Vec2 {
995                    x: evaluated.x,
996                    y: evaluated.y,
997                });
998                let parameter_end = if index + 1 < sample_count {
999                    start + (end - start) * (index + 1) as f64 / sample_count as f64
1000                } else {
1001                    end
1002                };
1003                segments.push(SegmentReference {
1004                    start: Vec2 {
1005                        x: evaluated.x,
1006                        y: evaluated.y,
1007                    },
1008                    end: Vec2 { x: 0.0, y: 0.0 },
1009                    curve,
1010                    parameter_start: parameter,
1011                    parameter_end,
1012                });
1013            }
1014            coedges.push((coedge.edge_id, first, polygon.len() - first));
1015        }
1016        for index in 0..polygon.len() {
1017            segments[index].end = polygon[(index + 1) % polygon.len()];
1018        }
1019        match point_in_polygon(point, &polygon, tolerance) {
1020            PolygonClass::Boundary => boundary = true,
1021            PolygonClass::Inside => crossings += 1,
1022            PolygonClass::Outside => {}
1023        }
1024        for segment in segments {
1025            let distance = point_segment_distance(point, segment.start, segment.end);
1026            if nearest
1027                .as_ref()
1028                .is_none_or(|(_, nearest_distance)| distance < *nearest_distance)
1029            {
1030                nearest = Some((segment, distance));
1031            }
1032        }
1033        sampled.push(LoopSamples { polygon, coedges });
1034    }
1035    if boundary {
1036        return Ok(PolygonClass::Boundary);
1037    }
1038    let parity = if crossings % 2 == 1 {
1039        PolygonClass::Inside
1040    } else {
1041        PolygonClass::Outside
1042    };
1043    // The LAST of the special lanes, and the only one that engages here rather
1044    // than ahead of the scan: it substitutes for the parity count and for nothing
1045    // else. The four lanes above were each written for a shape the parity gets
1046    // wrong and are calibrated against real documents; pre-empting them would move
1047    // decisions this change has no business moving. Whenever the nearest-curve
1048    // refinement below can answer, it is more accurate than any global rule and is
1049    // what the imprint and the fragment builder are tuned against — so this asks
1050    // the sphere only on the complement of the refinement's own condition.
1051    let parity_decides = match nearest.as_ref() {
1052        None => true,
1053        Some((segment, distance)) => {
1054            let length = segment.end.sub(segment.start).length();
1055            *distance > length || length <= 0.0
1056        }
1057    };
1058    if parity_decides {
1059        if let Some(class) = sphere_chart_point_in_face(face, point, &sampled)? {
1060            return Ok(class);
1061        }
1062    }
1063    let Some((segment, distance)) = nearest else {
1064        return Ok(parity);
1065    };
1066    let segment_length = segment.end.sub(segment.start).length();
1067    if distance > segment_length || segment_length <= 0.0 {
1068        return Ok(parity);
1069    }
1070    let chord_parameter = segment.parameter_start
1071        + (segment.parameter_end - segment.parameter_start)
1072            * (point.sub(segment.start).dot(segment.end.sub(segment.start))
1073                / (segment_length * segment_length))
1074                .clamp(0.0, 1.0);
1075    let [domain_start, domain_end] = segment.curve.domain()?;
1076    let mut parameter = chord_parameter;
1077    for _ in 0..12 {
1078        let derivatives = segment.curve.derivatives(parameter, 2)?;
1079        let on_curve = Vec2 {
1080            x: derivatives[0].x,
1081            y: derivatives[0].y,
1082        };
1083        let tangent = Vec2 {
1084            x: derivatives[1].x,
1085            y: derivatives[1].y,
1086        };
1087        let second = Vec2 {
1088            x: derivatives[2].x,
1089            y: derivatives[2].y,
1090        };
1091        let residual = on_curve.sub(point);
1092        let denominator = tangent.dot(tangent) + residual.dot(second);
1093        if denominator.abs() < 1e-30 {
1094            break;
1095        }
1096        let step = -residual.dot(tangent) / denominator;
1097        parameter = (parameter + step).clamp(domain_start, domain_end);
1098        if step.abs() < 1e-14 * (domain_end - domain_start + 1.0) {
1099            break;
1100        }
1101    }
1102    let margin = 1e-9 * (domain_end - domain_start);
1103    if parameter > domain_start + margin && parameter < domain_end - margin {
1104        let derivatives = segment.curve.derivatives(parameter, 1)?;
1105        let on_curve = Vec2 {
1106            x: derivatives[0].x,
1107            y: derivatives[0].y,
1108        };
1109        let tangent = Vec2 {
1110            x: derivatives[1].x,
1111            y: derivatives[1].y,
1112        };
1113        let offset = point.sub(on_curve);
1114        if offset.length() <= tolerance {
1115            return Ok(PolygonClass::Boundary);
1116        }
1117        let cross = tangent.x * offset.y - tangent.y * offset.x;
1118        if cross.abs() > 1e-30 {
1119            return Ok(if (cross > 0.0) == face.same_sense {
1120                PolygonClass::Inside
1121            } else {
1122                PolygonClass::Outside
1123            });
1124        }
1125    }
1126    Ok(parity)
1127}