Skip to main content

brep_kernel/brep/topology/
validate.rs

1use super::*;
2
3impl BrepSolid {
4    pub fn validate(&self) -> Vec<ValidationIssue> {
5        self.validate_with_tolerances(&KernelTolerances::for_solid(self, 1e-7))
6    }
7
8    pub fn validate_with_tolerances(&self, tolerances: &KernelTolerances) -> Vec<ValidationIssue> {
9        self.validate_detailed(tolerances).issues
10    }
11
12    pub fn validate_detailed(&self, tolerances: &KernelTolerances) -> ValidationReport {
13        let mut issues = Vec::new();
14        let mut wire_warnings = Vec::new();
15        let mut max_pcurve_error = 0.0f64;
16        // Model bounding-box diagonal: the LOCAL extent the edge/pcurve
17        // coincidence band size-couples to (see `pcurve_acceptance`). Raw, not
18        // `solid_scale` — that floors at 1.0, which would hand a genuinely tiny
19        // part a coincidence band 30x its own size; the tight absolute
20        // `pcurve_consistency` floor already guards the small end.
21        let mut bbox_lo = crate::Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
22        let mut bbox_hi = crate::Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
23        for vertex in &self.vertices {
24            bbox_lo.x = bbox_lo.x.min(vertex.point.x);
25            bbox_lo.y = bbox_lo.y.min(vertex.point.y);
26            bbox_lo.z = bbox_lo.z.min(vertex.point.z);
27            bbox_hi.x = bbox_hi.x.max(vertex.point.x);
28            bbox_hi.y = bbox_hi.y.max(vertex.point.y);
29            bbox_hi.z = bbox_hi.z.max(vertex.point.z);
30        }
31        let model_diagonal = if self.vertices.is_empty() {
32            0.0
33        } else {
34            bbox_hi.sub(bbox_lo).length()
35        };
36        let pcurve_limit = tolerances.pcurve_acceptance(model_diagonal);
37        // Vendor STEP files commit a vertex point and its incident edges' 3D-curve
38        // endpoints as INDEPENDENT approximations that disagree by a small, roughly
39        // ABSOLUTE amount (ABC 00000084/109/218 fail ONLY here: every gap clusters
40        // tightly at ~1.0-2.2e-5 regardless of the sub-unit part size; ABC 00010746
41        // has the same repeated pattern at ~3.2-3.9e-5 on 24 matching endpoints —
42        // a fixed export-precision offset, not a size-relative one). Like OCC/Parasolid
43        // absorbing the gap in a widened per-vertex tolerance, accept it: floor the
44        // identity band at a vendor-precision absolute and size-couple for large
45        // parts via `heal_band`. The old fixed `max(model*100, 1e-5)` band (model
46        // is pinned at 1e-7, so a flat 1e-5) rejected these shared vertices; a
47        // genuinely disconnected edge sits orders of magnitude above this floor, so
48        // real breakage is still refused. Clean parts already match far tighter, so
49        // this only admits the vendor near-miss.
50        // k = 2e-5: at metre numerics the .max(4e-5) ABSOLUTE floor was the
51        // effective acceptance (~1.4e-4 of a 0.28-extent part) and silently
52        // absorbed vendor edge-vs-vertex gaps; in mm the floor is inert and
53        // k=1e-5 narrowly rejected an authored 1.25e-5-of-diagonal gap
54        // (ABC 8575). 2e-5 keeps the check meaningful while accepting vendor
55        // imprecision the old floor accepted. Validation acceptance only —
56        // no merge radius derives from this.
57        // Floor = vendor EXPORT PRECISION in mm. The old 4e-5 floor encoded
58        // metre-era numerics (~4e-5 of a metre); the same physical export
59        // imprecision lands x1000 bigger now that imports convert to mm
60        // (ABC 8575: a 3.5e-6 m authored vertex/curve offset = 3.5e-3 mm).
61        // 5e-3 mm (= 5 um) accepts vendor precision for metre- and
62        // mm-authored files alike. Validation acceptance only — no merge
63        // radius derives from this.
64        let vertex_match = tolerances
65            .heal_band(model_diagonal, 2e-5)
66            .max(crate::VERTEX_MATCH_FLOOR);
67        let vertices: HashMap<u64, &VertexRecord> = self
68            .vertices
69            .iter()
70            .map(|vertex| (vertex.id, vertex))
71            .collect();
72        let edges: HashMap<u64, &EdgeRecord> =
73            self.edges.iter().map(|edge| (edge.id, edge)).collect();
74        if vertices.len() != self.vertices.len() {
75            issues.push(ValidationIssue::error("duplicate vertex id"));
76        }
77        if edges.len() != self.edges.len() {
78            issues.push(ValidationIssue::error("duplicate edge id"));
79        }
80
81        let mut edge_uses: HashMap<u64, Vec<bool>> = HashMap::default();
82        let mut face_ids = HashSet::default();
83        for shell in &self.shells {
84            for face in &shell.faces {
85                if !face_ids.insert(face.id) {
86                    issues.push(ValidationIssue::error(format!(
87                        "duplicate face id {}",
88                        face.id
89                    )));
90                }
91                let surface = match NurbsSurface::new(
92                    face.surface.degree_u,
93                    face.surface.degree_v,
94                    face.surface.knots_u.clone(),
95                    face.surface.knots_v.clone(),
96                    face.surface.control_points.clone(),
97                ) {
98                    Ok(surface) => surface,
99                    Err(error) => {
100                        issues.push(ValidationIssue::error(format!(
101                            "face {} has invalid surface: {}",
102                            face.id, error
103                        )));
104                        continue;
105                    }
106                };
107                for (loop_index, loop_record) in face.loops.iter().enumerate() {
108                    if loop_record.coedges.is_empty() {
109                        issues.push(ValidationIssue::error(format!(
110                            "loop {} of face {} is empty",
111                            loop_record.id, face.id
112                        )));
113                        continue;
114                    }
115                    for coedge in &loop_record.coedges {
116                        edge_uses
117                            .entry(coedge.edge_id)
118                            .or_default()
119                            .push(coedge.forward);
120                    }
121                    for index in 0..loop_record.coedges.len() {
122                        let current = &loop_record.coedges[index];
123                        let next = &loop_record.coedges[(index + 1) % loop_record.coedges.len()];
124                        let Some(current_edge) = edges.get(&current.edge_id) else {
125                            issues.push(ValidationIssue::error(format!(
126                                "coedge {} references missing edge {}",
127                                current.id, current.edge_id
128                            )));
129                            continue;
130                        };
131                        let Some(next_edge) = edges.get(&next.edge_id) else {
132                            issues.push(ValidationIssue::error(format!(
133                                "coedge {} references missing edge {}",
134                                next.id, next.edge_id
135                            )));
136                            continue;
137                        };
138                        let current_end = if current.forward {
139                            current_edge.end_vertex_id
140                        } else {
141                            current_edge.start_vertex_id
142                        };
143                        let next_start = if next.forward {
144                            next_edge.start_vertex_id
145                        } else {
146                            next_edge.end_vertex_id
147                        };
148                        if current_end != next_start {
149                            issues.push(ValidationIssue::error(format!(
150                                "loop {} of face {} is open between coedges {} and {}",
151                                loop_record.id, face.id, current.id, next.id
152                            )));
153                        }
154
155                        let pcurve = match NurbsCurve::new(
156                            current.pcurve.degree,
157                            current.pcurve.knots.clone(),
158                            current.pcurve.control_points.clone(),
159                        ) {
160                            Ok(curve) => curve,
161                            Err(error) => {
162                                issues.push(ValidationIssue::error(format!(
163                                    "coedge {} has invalid pcurve: {}",
164                                    current.id, error
165                                )));
166                                continue;
167                            }
168                        };
169                        let curve = match NurbsCurve::new(
170                            current_edge.curve.degree,
171                            current_edge.curve.knots.clone(),
172                            current_edge.curve.control_points.clone(),
173                        ) {
174                            Ok(curve) => curve,
175                            Err(error) => {
176                                issues.push(ValidationIssue::error(format!(
177                                    "edge {} has invalid curve: {}",
178                                    current_edge.id, error
179                                )));
180                                continue;
181                            }
182                        };
183                        match adaptive_coedge_error(
184                            &surface,
185                            &pcurve,
186                            &curve,
187                            current_edge,
188                            current.forward,
189                            pcurve_limit,
190                        ) {
191                            Ok(error) => {
192                                max_pcurve_error = max_pcurve_error.max(error);
193                                if error > pcurve_limit {
194                                    issues.push(ValidationIssue::error(format!(
195                                        "coedge {} of face {} pcurve is inconsistent with edge {} \
196                                         (max deviation {:.6}, limit {:.6})",
197                                        current.id, face.id, current_edge.id, error, pcurve_limit,
198                                    )));
199                                }
200                            }
201                            Err(error) => issues.push(ValidationIssue::error(format!(
202                                "coedge {} of face {} cannot be evaluated: {}",
203                                current.id, face.id, error
204                            ))),
205                        }
206                    }
207
208                    if let Some(warning) = validate_uv_wire(
209                        &surface,
210                        loop_record,
211                        &edges,
212                        tolerances,
213                        loop_index == 0,
214                        face.same_sense,
215                    ) {
216                        wire_warnings.push(ValidationIssue::warning(format!(
217                            "face {} loop {}: {}",
218                            face.id, loop_record.id, warning
219                        )));
220                    }
221                }
222            }
223        }
224
225        for edge in &self.edges {
226            if !(edge.t0.is_finite() && edge.t1.is_finite() && edge.t0 < edge.t1) {
227                issues.push(ValidationIssue::error(format!(
228                    "edge {} has invalid parameter range",
229                    edge.id
230                )));
231            }
232            let Some(start) = vertices.get(&edge.start_vertex_id) else {
233                issues.push(ValidationIssue::error(format!(
234                    "edge {} references missing start vertex {}",
235                    edge.id, edge.start_vertex_id
236                )));
237                continue;
238            };
239            let Some(end) = vertices.get(&edge.end_vertex_id) else {
240                issues.push(ValidationIssue::error(format!(
241                    "edge {} references missing end vertex {}",
242                    edge.id, edge.end_vertex_id
243                )));
244                continue;
245            };
246            if let Ok(curve) = NurbsCurve::new(
247                edge.curve.degree,
248                edge.curve.knots.clone(),
249                edge.curve.control_points.clone(),
250            ) {
251                if let Ok(point) = curve.evaluate(edge.t0) {
252                    let gap = point.sub(start.point).length();
253                    if gap > vertex_match {
254                        issues.push(ValidationIssue::error_kind(IssueKind::CurveVertexGap { at_start: true }, format!(
255                            "edge {} curve start does not match vertex {} \
256                             (gap={gap:.9}, curve={point:?}, vertex={:?})",
257                            edge.id, start.id, start.point
258                        )));
259                    }
260                }
261                if let Ok(point) = curve.evaluate(edge.t1) {
262                    let gap = point.sub(end.point).length();
263                    if gap > vertex_match {
264                        issues.push(ValidationIssue::error_kind(IssueKind::CurveVertexGap { at_start: false }, format!(
265                            "edge {} curve end does not match vertex {} \
266                             (gap={gap:.9}, curve={point:?}, vertex={:?})",
267                            edge.id, end.id, end.point
268                        )));
269                    }
270                }
271            }
272            let uses = edge_uses.get(&edge.id).map(Vec::as_slice).unwrap_or(&[]);
273            if edge.degenerate {
274                // A synthesized VERTEX_LOOP/pole boundary is single-use, but
275                // vendor STEP may share one explicit collapsed EDGE_CURVE
276                // between the two faces meeting at that pole. The latter is a
277                // valid manifold incidence exactly when the coedge senses are
278                // opposite, just like an ordinary shared edge.
279                let valid = uses.len() == 1 || (uses.len() == 2 && uses[0] != uses[1]);
280                if !valid {
281                    issues.push(ValidationIssue::error(format!(
282                        "degenerate edge {} has invalid incidence {:?} \
283                         (expected one use or an opposite-sense pair)",
284                        edge.id, uses,
285                    )));
286                }
287            } else if uses.len() != 2 {
288                let kind = if uses.len() < 2 { IssueKind::OpenEdge } else { IssueKind::OverUsedEdge };
289                issues.push(ValidationIssue::error_kind(kind, format!(
290                    "edge {} used {} times (expected 2)",
291                    edge.id,
292                    uses.len()
293                )));
294            } else if uses[0] == uses[1] {
295                issues.push(ValidationIssue::error(format!(
296                    "edge {} has coedges with the same sense",
297                    edge.id
298                )));
299            }
300        }
301
302        for edge_id in edge_uses.keys() {
303            if !edges.contains_key(edge_id) {
304                issues.push(ValidationIssue::error(format!(
305                    "topology references missing edge {}",
306                    edge_id
307                )));
308            }
309        }
310
311        // A pole edge is a collapsed parameter-space boundary. Its endpoint
312        // is not an independent 0-cell unless a non-degenerate edge also
313        // uses it, so use the reduced complex for Euler accounting.
314        let non_degenerate_vertex_ids = self
315            .edges
316            .iter()
317            .filter(|edge| !edge.degenerate)
318            .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
319            .collect::<HashSet<_>>();
320        let vertex_count = vertices
321            .keys()
322            .filter(|id| non_degenerate_vertex_ids.contains(id))
323            .count() as i64;
324        let edge_count = self.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
325        let face_count = face_ids.len() as i64;
326        let hole_count: i64 = self
327            .shells
328            .iter()
329            .flat_map(|shell| &shell.faces)
330            .map(|face| face.loops.len().saturating_sub(1) as i64)
331            .sum();
332        let shell_count = self.shells.len() as i64;
333        let actual = vertex_count - edge_count + face_count - hole_count;
334        let expected = 2 * (shell_count - self.genus);
335        // The reduced V-E+F formula does not model parameter-space pole
336        // collapses reliably. Degenerate edges are validated separately by
337        // incidence, so reserve this Euler check for ordinary cell complexes.
338        if actual != expected && !self.edges.iter().any(|edge| edge.degenerate) {
339            // OCC-style models carry COINCIDENT PARALLEL edges: two
340            // ref-distinct edges riding one geometric locus between the same
341            // vertices (a wall split exactly where an adjoining ring meets
342            // it). The complex is manifold — the four incident faces pair
343            // off along the shared locus — but the formula counts the locus
344            // twice. Re-check with each coincident pair credited once; the
345            // scan runs only on this failure path, so ordinary solids pay
346            // nothing for it.
347            let pairs = self.coincident_parallel_edge_pairs() as i64;
348            if actual + pairs != expected {
349                issues.push(ValidationIssue::error_kind(IssueKind::GenusMismatch, format!(
350                    "Euler formula: V-E+F-H = {}, expected {} ({} coincident parallel edge pair(s) credited)",
351                    actual + pairs,
352                    expected,
353                    pairs
354                )));
355            }
356        }
357        ValidationReport {
358            issues,
359            wire_warnings,
360            max_pcurve_error,
361        }
362    }
363
364    /// Count disjoint pairs of non-degenerate edges that ride ONE geometric
365    /// locus between the same endpoint vertices (OCC wall-split rims). Each
366    /// such pair contributes a single locus to the cell complex, so Euler
367    /// accounting credits it once.
368    pub(crate) fn coincident_parallel_edge_pairs(&self) -> usize {
369        let candidates: Vec<&EdgeRecord> =
370            self.edges.iter().filter(|edge| !edge.degenerate).collect();
371        let mut consumed = vec![false; candidates.len()];
372        let mut pairs = 0usize;
373        for first_index in 0..candidates.len() {
374            if consumed[first_index] {
375                continue;
376            }
377            let first = candidates[first_index];
378            for second_index in first_index + 1..candidates.len() {
379                if consumed[second_index] {
380                    continue;
381                }
382                let second = candidates[second_index];
383                let endpoints_match = (first.start_vertex_id == second.start_vertex_id
384                    && first.end_vertex_id == second.end_vertex_id)
385                    || (first.start_vertex_id == second.end_vertex_id
386                        && first.end_vertex_id == second.start_vertex_id);
387                if !endpoints_match {
388                    continue;
389                }
390                // Locus agreement at interior samples, direction-agnostic.
391                let coincident = (1..4).all(|sample| {
392                    let fraction = sample as f64 / 4.0;
393                    let Ok(on_first) = first
394                        .curve
395                        .evaluate(first.t0 + (first.t1 - first.t0) * fraction)
396                    else {
397                        return false;
398                    };
399                    let forward = second.t0 + (second.t1 - second.t0) * fraction;
400                    let backward = second.t1 - (second.t1 - second.t0) * fraction;
401                    let tolerance = 1e-6 * (1.0 + on_first.length());
402                    let matches_forward = second
403                        .curve
404                        .evaluate(forward)
405                        .map(|point| point.sub(on_first).length() <= tolerance)
406                        .unwrap_or(false);
407                    let matches_backward = second
408                        .curve
409                        .evaluate(backward)
410                        .map(|point| point.sub(on_first).length() <= tolerance)
411                        .unwrap_or(false);
412                    matches_forward || matches_backward
413                });
414                if coincident {
415                    consumed[first_index] = true;
416                    consumed[second_index] = true;
417                    pairs += 1;
418                    break;
419                }
420            }
421        }
422        pairs
423    }
424}
425
426fn coedge_sample(
427    surface: &NurbsSurface,
428    pcurve: &NurbsCurve,
429    curve: &NurbsCurve,
430    edge: &EdgeRecord,
431    forward: bool,
432    fraction: f64,
433) -> Result<(f64, Vec3, Vec3), String> {
434    let [q0, q1] = pcurve.domain()?;
435    let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
436    // A pcurve that straddles a periodic seam carries parameters just past the
437    // domain; the surface WRAPS there (evaluate_extended), so a coedge riding
438    // the seam still reproduces its edge exactly instead of reading as a gross
439    // deviation against the clamped boundary.
440    let on_surface = surface.evaluate_extended(uv.x, uv.y)?;
441    let t = if forward {
442        edge.t0 + (edge.t1 - edge.t0) * fraction
443    } else {
444        edge.t1 - (edge.t1 - edge.t0) * fraction
445    };
446    let on_curve = curve.evaluate(t)?;
447    Ok((on_surface.sub(on_curve).length(), on_surface, on_curve))
448}
449
450/// Start with 32 intervals, then subdivide where either represented curve
451/// bends appreciably or the deviation function is non-linear.  This catches
452/// interior NURBS drift that four fixed samples cannot see without imposing
453/// the cost of a uniformly extreme sampling count on every coedge.
454pub(crate) fn adaptive_coedge_error(
455    surface: &NurbsSurface,
456    pcurve: &NurbsCurve,
457    curve: &NurbsCurve,
458    edge: &EdgeRecord,
459    forward: bool,
460    tolerance: f64,
461) -> Result<f64, String> {
462    fn interval(
463        surface: &NurbsSurface,
464        pcurve: &NurbsCurve,
465        curve: &NurbsCurve,
466        edge: &EdgeRecord,
467        forward: bool,
468        a: f64,
469        b: f64,
470        sample_a: (f64, Vec3, Vec3),
471        sample_b: (f64, Vec3, Vec3),
472        tolerance: f64,
473        depth: usize,
474    ) -> Result<f64, String> {
475        let mid = (a + b) * 0.5;
476        let sample_mid = coedge_sample(surface, pcurve, curve, edge, forward, mid)?;
477        let error_nonlinearity = (sample_mid.0 - (sample_a.0 + sample_b.0) * 0.5).abs();
478        let surface_bend = sample_mid
479            .1
480            .sub(sample_a.1.add(sample_b.1).scale(0.5))
481            .length();
482        let curve_bend = sample_mid
483            .2
484            .sub(sample_a.2.add(sample_b.2).scale(0.5))
485            .length();
486        let local_max = sample_a.0.max(sample_mid.0).max(sample_b.0);
487        if depth >= 3
488            || (error_nonlinearity <= tolerance * 0.05
489                && surface_bend.max(curve_bend) <= tolerance * 0.25)
490        {
491            return Ok(local_max);
492        }
493        Ok(interval(
494            surface,
495            pcurve,
496            curve,
497            edge,
498            forward,
499            a,
500            mid,
501            sample_a,
502            sample_mid,
503            tolerance,
504            depth + 1,
505        )?
506        .max(interval(
507            surface,
508            pcurve,
509            curve,
510            edge,
511            forward,
512            mid,
513            b,
514            sample_mid,
515            sample_b,
516            tolerance,
517            depth + 1,
518        )?))
519    }
520
521    let mut maximum = 0.0f64;
522    let mut previous = coedge_sample(surface, pcurve, curve, edge, forward, 0.0)?;
523    maximum = maximum.max(previous.0);
524    for index in 1..=32 {
525        let a = (index - 1) as f64 / 32.0;
526        let b = index as f64 / 32.0;
527        let next = coedge_sample(surface, pcurve, curve, edge, forward, b)?;
528        maximum = maximum.max(interval(
529            surface, pcurve, curve, edge, forward, a, b, previous, next, tolerance, 0,
530        )?);
531        previous = next;
532    }
533    Ok(maximum)
534}
535
536fn segments_cross(a: Vec2, b: Vec2, c: Vec2, d: Vec2, tolerance: f64) -> bool {
537    let cross = |first: Vec2, second: Vec2| first.x * second.y - first.y * second.x;
538    let ab = b.sub(a);
539    let cd = d.sub(c);
540    let denominator = cross(ab, cd);
541    if denominator.abs() <= tolerance {
542        return false;
543    }
544    let ac = c.sub(a);
545    let t = cross(ac, cd) / denominator;
546    let u = cross(ac, ab) / denominator;
547    t > tolerance && t < 1.0 - tolerance && u > tolerance && u < 1.0 - tolerance
548}
549
550fn validate_uv_wire(
551    surface: &NurbsSurface,
552    loop_record: &LoopRecord,
553    edges: &HashMap<u64, &EdgeRecord>,
554    tolerances: &KernelTolerances,
555    outer: bool,
556    same_sense: bool,
557) -> Option<String> {
558    let u_domain = crate::KnotVector::new(surface.knots_u.clone(), surface.degree_u)
559        .ok()?
560        .domain();
561    let v_domain = crate::KnotVector::new(surface.knots_v.clone(), surface.degree_v)
562        .ok()?
563        .domain();
564    let u_span = u_domain[1] - u_domain[0];
565    let v_span = v_domain[1] - v_domain[0];
566    let mut points = Vec::<Vec2>::new();
567    for coedge in &loop_record.coedges {
568        let edge = edges.get(&coedge.edge_id)?;
569        if edge.degenerate {
570            continue;
571        }
572        let [q0, q1] = coedge.pcurve.domain().ok()?;
573        for index in 0..=8 {
574            if !points.is_empty() && index == 0 {
575                continue;
576            }
577            let fraction = index as f64 / 8.0;
578            let parameter = q0 + (q1 - q0) * fraction;
579            let value = coedge.pcurve.evaluate(parameter).ok()?;
580            let mut point = Vec2 {
581                x: value.x,
582                y: value.y,
583            };
584            if let Some(previous) = points.last() {
585                while point.x - previous.x > u_span * 0.5 {
586                    point.x -= u_span;
587                }
588                while point.x - previous.x < -u_span * 0.5 {
589                    point.x += u_span;
590                }
591                while point.y - previous.y > v_span * 0.5 {
592                    point.y -= v_span;
593                }
594                while point.y - previous.y < -v_span * 0.5 {
595                    point.y += v_span;
596                }
597            }
598            points.push(point);
599        }
600    }
601    if points.len() < 4 {
602        return None;
603    }
604    let first = points[0];
605    let last = *points.last()?;
606    let uv_tolerance = tolerances.model.max(1e-8);
607    if last.sub(first).length() > uv_tolerance * 100.0 {
608        // Parameter seams may differ by a complete period.  Compare modulo
609        // both domains before reporting a genuine open wire.
610        let du = (last.x - first.x) / u_span;
611        let dv = (last.y - first.y) / v_span;
612        if (du - du.round()).abs() * u_span > uv_tolerance * 100.0
613            || (dv - dv.round()).abs() * v_span > uv_tolerance * 100.0
614        {
615            return Some(format!(
616                "wire is open in parameter space (gap {:.3e})",
617                last.sub(first).length()
618            ));
619        }
620    }
621    for first_index in 0..points.len() - 1 {
622        for second_index in first_index + 2..points.len() - 1 {
623            if first_index == 0 && second_index + 1 == points.len() - 1 {
624                continue;
625            }
626            if segments_cross(
627                points[first_index],
628                points[first_index + 1],
629                points[second_index],
630                points[second_index + 1],
631                1e-10,
632            ) {
633                return Some(format!(
634                    "wire self-intersects near sampled segments {first_index} and {second_index}"
635                ));
636            }
637        }
638    }
639    let area = points
640        .windows(2)
641        .map(|pair| pair[0].x * pair[1].y - pair[1].x * pair[0].y)
642        .sum::<f64>()
643        * 0.5;
644    if area.abs() > uv_tolerance * uv_tolerance {
645        let expected_positive = if outer { same_sense } else { !same_sense };
646        if (area > 0.0) != expected_positive {
647            return Some(format!(
648                "{} wire winding disagrees with face sense (signed UV area {:.6})",
649                if outer { "outer" } else { "inner" },
650                area
651            ));
652        }
653    }
654    None
655}