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