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 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 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(¤t.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 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 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 = self.bounding_hole_count();
327 let shell_count = self.shells.len() as i64;
328 let actual = vertex_count - edge_count + face_count - hole_count;
329 let expected = 2 * (shell_count - self.genus);
330 let closed = self
381 .edges
382 .iter()
383 .filter(|edge| !edge.degenerate)
384 .all(|edge| edge_uses.get(&edge.id).map(Vec::len) == Some(2));
385 if closed && actual != expected {
386 let pairs = self.coincident_parallel_edge_pairs() as i64;
395 if actual + pairs != expected {
396 issues.push(ValidationIssue::error_kind(IssueKind::GenusMismatch, format!(
397 "Euler formula: V-E+F-H = {}, expected {} ({} coincident parallel edge pair(s) credited)",
398 actual + pairs,
399 expected,
400 pairs
401 )));
402 }
403 }
404 ValidationReport {
405 issues,
406 wire_warnings,
407 max_pcurve_error,
408 }
409 }
410
411 pub(crate) fn bounding_hole_count(&self) -> i64 {
425 let degenerate: HashSet<u64> = self
426 .edges
427 .iter()
428 .filter(|edge| edge.degenerate)
429 .map(|edge| edge.id)
430 .collect();
431 self.shells
432 .iter()
433 .flat_map(|shell| &shell.faces)
434 .map(|face| {
435 face.loops
436 .iter()
437 .filter(|record| {
438 !record.coedges.is_empty()
439 && !record
440 .coedges
441 .iter()
442 .all(|coedge| degenerate.contains(&coedge.edge_id))
443 })
444 .count()
445 .saturating_sub(1) as i64
446 })
447 .sum()
448 }
449
450 pub(crate) fn coincident_parallel_edge_pairs(&self) -> usize {
455 let candidates: Vec<&EdgeRecord> =
456 self.edges.iter().filter(|edge| !edge.degenerate).collect();
457 let mut consumed = vec![false; candidates.len()];
458 let mut pairs = 0usize;
459 for first_index in 0..candidates.len() {
460 if consumed[first_index] {
461 continue;
462 }
463 let first = candidates[first_index];
464 for second_index in first_index + 1..candidates.len() {
465 if consumed[second_index] {
466 continue;
467 }
468 let second = candidates[second_index];
469 let endpoints_match = (first.start_vertex_id == second.start_vertex_id
470 && first.end_vertex_id == second.end_vertex_id)
471 || (first.start_vertex_id == second.end_vertex_id
472 && first.end_vertex_id == second.start_vertex_id);
473 if !endpoints_match {
474 continue;
475 }
476 let coincident = (1..4).all(|sample| {
478 let fraction = sample as f64 / 4.0;
479 let Ok(on_first) = first
480 .curve
481 .evaluate(first.t0 + (first.t1 - first.t0) * fraction)
482 else {
483 return false;
484 };
485 let forward = second.t0 + (second.t1 - second.t0) * fraction;
486 let backward = second.t1 - (second.t1 - second.t0) * fraction;
487 let tolerance = 1e-6 * (1.0 + on_first.length());
488 let matches_forward = second
489 .curve
490 .evaluate(forward)
491 .map(|point| point.sub(on_first).length() <= tolerance)
492 .unwrap_or(false);
493 let matches_backward = second
494 .curve
495 .evaluate(backward)
496 .map(|point| point.sub(on_first).length() <= tolerance)
497 .unwrap_or(false);
498 matches_forward || matches_backward
499 });
500 if coincident {
501 consumed[first_index] = true;
502 consumed[second_index] = true;
503 pairs += 1;
504 break;
505 }
506 }
507 }
508 pairs
509 }
510}
511
512fn coedge_sample(
513 surface: &NurbsSurface,
514 pcurve: &NurbsCurve,
515 curve: &NurbsCurve,
516 edge: &EdgeRecord,
517 forward: bool,
518 fraction: f64,
519) -> Result<(f64, Vec3, Vec3), String> {
520 let [q0, q1] = pcurve.domain()?;
521 let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
522 let on_surface = surface.evaluate_extended(uv.x, uv.y)?;
527 let t = if forward {
528 edge.t0 + (edge.t1 - edge.t0) * fraction
529 } else {
530 edge.t1 - (edge.t1 - edge.t0) * fraction
531 };
532 let on_curve = curve.evaluate(t)?;
533 Ok((on_surface.sub(on_curve).length(), on_surface, on_curve))
534}
535
536pub(crate) fn adaptive_coedge_error(
541 surface: &NurbsSurface,
542 pcurve: &NurbsCurve,
543 curve: &NurbsCurve,
544 edge: &EdgeRecord,
545 forward: bool,
546 tolerance: f64,
547) -> Result<f64, String> {
548 fn interval(
549 surface: &NurbsSurface,
550 pcurve: &NurbsCurve,
551 curve: &NurbsCurve,
552 edge: &EdgeRecord,
553 forward: bool,
554 a: f64,
555 b: f64,
556 sample_a: (f64, Vec3, Vec3),
557 sample_b: (f64, Vec3, Vec3),
558 tolerance: f64,
559 depth: usize,
560 ) -> Result<f64, String> {
561 let mid = (a + b) * 0.5;
562 let sample_mid = coedge_sample(surface, pcurve, curve, edge, forward, mid)?;
563 let error_nonlinearity = (sample_mid.0 - (sample_a.0 + sample_b.0) * 0.5).abs();
564 let surface_bend = sample_mid
565 .1
566 .sub(sample_a.1.add(sample_b.1).scale(0.5))
567 .length();
568 let curve_bend = sample_mid
569 .2
570 .sub(sample_a.2.add(sample_b.2).scale(0.5))
571 .length();
572 let local_max = sample_a.0.max(sample_mid.0).max(sample_b.0);
573 if depth >= 3
574 || (error_nonlinearity <= tolerance * 0.05
575 && surface_bend.max(curve_bend) <= tolerance * 0.25)
576 {
577 return Ok(local_max);
578 }
579 Ok(interval(
580 surface,
581 pcurve,
582 curve,
583 edge,
584 forward,
585 a,
586 mid,
587 sample_a,
588 sample_mid,
589 tolerance,
590 depth + 1,
591 )?
592 .max(interval(
593 surface,
594 pcurve,
595 curve,
596 edge,
597 forward,
598 mid,
599 b,
600 sample_mid,
601 sample_b,
602 tolerance,
603 depth + 1,
604 )?))
605 }
606
607 let mut maximum = 0.0f64;
608 let mut previous = coedge_sample(surface, pcurve, curve, edge, forward, 0.0)?;
609 maximum = maximum.max(previous.0);
610 for index in 1..=32 {
611 let a = (index - 1) as f64 / 32.0;
612 let b = index as f64 / 32.0;
613 let next = coedge_sample(surface, pcurve, curve, edge, forward, b)?;
614 maximum = maximum.max(interval(
615 surface, pcurve, curve, edge, forward, a, b, previous, next, tolerance, 0,
616 )?);
617 previous = next;
618 }
619 Ok(maximum)
620}
621
622fn segments_cross(a: Vec2, b: Vec2, c: Vec2, d: Vec2, tolerance: f64) -> bool {
623 let cross = |first: Vec2, second: Vec2| first.x * second.y - first.y * second.x;
624 let ab = b.sub(a);
625 let cd = d.sub(c);
626 let denominator = cross(ab, cd);
627 if denominator.abs() <= tolerance {
628 return false;
629 }
630 let ac = c.sub(a);
631 let t = cross(ac, cd) / denominator;
632 let u = cross(ac, ab) / denominator;
633 t > tolerance && t < 1.0 - tolerance && u > tolerance && u < 1.0 - tolerance
634}
635
636fn validate_uv_wire(
637 surface: &NurbsSurface,
638 loop_record: &LoopRecord,
639 edges: &HashMap<u64, &EdgeRecord>,
640 tolerances: &KernelTolerances,
641 outer: bool,
642 same_sense: bool,
643) -> Option<String> {
644 let u_domain = crate::KnotVector::new(surface.knots_u.clone(), surface.degree_u)
645 .ok()?
646 .domain();
647 let v_domain = crate::KnotVector::new(surface.knots_v.clone(), surface.degree_v)
648 .ok()?
649 .domain();
650 let u_span = u_domain[1] - u_domain[0];
651 let v_span = v_domain[1] - v_domain[0];
652 let mut points = Vec::<Vec2>::new();
653 for coedge in &loop_record.coedges {
654 let edge = edges.get(&coedge.edge_id)?;
655 if edge.degenerate {
656 continue;
657 }
658 let [q0, q1] = coedge.pcurve.domain().ok()?;
659 for index in 0..=8 {
660 if !points.is_empty() && index == 0 {
661 continue;
662 }
663 let fraction = index as f64 / 8.0;
664 let parameter = q0 + (q1 - q0) * fraction;
665 let value = coedge.pcurve.evaluate(parameter).ok()?;
666 let mut point = Vec2 {
667 x: value.x,
668 y: value.y,
669 };
670 if let Some(previous) = points.last() {
671 while point.x - previous.x > u_span * 0.5 {
672 point.x -= u_span;
673 }
674 while point.x - previous.x < -u_span * 0.5 {
675 point.x += u_span;
676 }
677 while point.y - previous.y > v_span * 0.5 {
678 point.y -= v_span;
679 }
680 while point.y - previous.y < -v_span * 0.5 {
681 point.y += v_span;
682 }
683 }
684 points.push(point);
685 }
686 }
687 if points.len() < 4 {
688 return None;
689 }
690 let first = points[0];
691 let last = *points.last()?;
692 let uv_tolerance = tolerances.model.max(1e-8);
693 if last.sub(first).length() > uv_tolerance * 100.0 {
694 let du = (last.x - first.x) / u_span;
697 let dv = (last.y - first.y) / v_span;
698 if (du - du.round()).abs() * u_span > uv_tolerance * 100.0
699 || (dv - dv.round()).abs() * v_span > uv_tolerance * 100.0
700 {
701 return Some(format!(
702 "wire is open in parameter space (gap {:.3e})",
703 last.sub(first).length()
704 ));
705 }
706 }
707 for first_index in 0..points.len() - 1 {
708 for second_index in first_index + 2..points.len() - 1 {
709 if first_index == 0 && second_index + 1 == points.len() - 1 {
710 continue;
711 }
712 if segments_cross(
713 points[first_index],
714 points[first_index + 1],
715 points[second_index],
716 points[second_index + 1],
717 1e-10,
718 ) {
719 return Some(format!(
720 "wire self-intersects near sampled segments {first_index} and {second_index}"
721 ));
722 }
723 }
724 }
725 let area = points
726 .windows(2)
727 .map(|pair| pair[0].x * pair[1].y - pair[1].x * pair[0].y)
728 .sum::<f64>()
729 * 0.5;
730 if area.abs() > uv_tolerance * uv_tolerance {
731 let expected_positive = if outer { same_sense } else { !same_sense };
732 if (area > 0.0) != expected_positive {
733 return Some(format!(
734 "{} wire winding disagrees with face sense (signed UV area {:.6})",
735 if outer { "outer" } else { "inner" },
736 area
737 ));
738 }
739 }
740 None
741}