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 coedge_error_floored(
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)
626}
627
628pub(crate) fn coedge_error_floored(
638 surface: &NurbsSurface,
639 pcurve: &NurbsCurve,
640 curve: &NurbsCurve,
641 edge: &EdgeRecord,
642 forward: bool,
643 tolerance: f64,
644) -> Result<f64, String> {
645 let steered = adaptive_coedge_error(surface, pcurve, curve, edge, forward, tolerance)?;
646 Ok(steered.max(knot_span_midpoint_error(surface, pcurve, curve, edge, forward)?))
647}
648
649fn coedge_floor_tracing() -> bool {
653 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
654 *ON.get_or_init(|| std::env::var_os("BREP_DEBUG_COEDGE_FLOOR").is_some())
655}
656
657fn knot_span_midpoint_error(
664 surface: &NurbsSurface,
665 pcurve: &NurbsCurve,
666 curve: &NurbsCurve,
667 edge: &EdgeRecord,
668 forward: bool,
669) -> Result<f64, String> {
670 let mut worst = 0.0_f64;
671 let [q0, q1] = pcurve.domain()?;
672 if q1 > q0 {
673 let mut previous = q0;
674 for &knot in pcurve.knots.iter().chain(std::iter::once(&q1)) {
675 if knot > previous && knot <= q1 {
676 let fraction = ((previous + knot) * 0.5 - q0) / (q1 - q0);
677 let sample = coedge_sample(surface, pcurve, curve, edge, forward, fraction)?;
678 if coedge_floor_tracing() && sample.0 > 1e-3 {
679 let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
680 let ([u0, u1], [v0, v1]) = (surface.domain_u()?, surface.domain_v()?);
681 eprintln!(
682 "COEDGE-FLOOR edge {} pcurve-span midpoint fraction {fraction:.6}: miss {:.4e} at uv ({:.6}, {:.6}) domain u[{u0},{u1}] v[{v0},{v1}] closed {:?} analytic {:?} degree {} knots {} cps {}",
683 edge.id, sample.0, uv.x, uv.y, surface.closed_directions()?, surface.analytic().map(|_| "yes"), pcurve.degree, pcurve.knots.len(), pcurve.control_points.len()
684 );
685 }
686 worst = worst.max(sample.0);
687 previous = knot;
688 }
689 }
690 }
691 let span = edge.t1 - edge.t0;
692 if span.is_finite() && span != 0.0 {
693 let low = edge.t0.min(edge.t1);
694 let high = edge.t0.max(edge.t1);
695 let mut previous = low;
696 for &knot in curve.knots.iter().chain(std::iter::once(&high)) {
697 if knot > previous && knot <= high {
698 let midpoint = (previous + knot) * 0.5;
699 let fraction = if forward {
700 (midpoint - edge.t0) / span
701 } else {
702 (edge.t1 - midpoint) / span
703 };
704 let sample = coedge_sample(surface, pcurve, curve, edge, forward, fraction)?;
705 if coedge_floor_tracing() && sample.0 > 1e-3 {
706 let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
707 eprintln!(
708 "COEDGE-FLOOR edge {} edge-span midpoint fraction {fraction:.6}: miss {:.4e} at uv ({:.6}, {:.6})",
709 edge.id, sample.0, uv.x, uv.y
710 );
711 }
712 worst = worst.max(sample.0);
713 previous = knot;
714 }
715 }
716 }
717 Ok(worst)
718}
719
720fn segments_cross(a: Vec2, b: Vec2, c: Vec2, d: Vec2, tolerance: f64) -> bool {
721 let cross = |first: Vec2, second: Vec2| first.x * second.y - first.y * second.x;
722 let ab = b.sub(a);
723 let cd = d.sub(c);
724 let denominator = cross(ab, cd);
725 if denominator.abs() <= tolerance {
726 return false;
727 }
728 let ac = c.sub(a);
729 let t = cross(ac, cd) / denominator;
730 let u = cross(ac, ab) / denominator;
731 t > tolerance && t < 1.0 - tolerance && u > tolerance && u < 1.0 - tolerance
732}
733
734fn validate_uv_wire(
735 surface: &NurbsSurface,
736 loop_record: &LoopRecord,
737 edges: &HashMap<u64, &EdgeRecord>,
738 tolerances: &KernelTolerances,
739 outer: bool,
740 same_sense: bool,
741) -> Option<String> {
742 let u_domain = crate::KnotVector::new(surface.knots_u.clone(), surface.degree_u)
743 .ok()?
744 .domain();
745 let v_domain = crate::KnotVector::new(surface.knots_v.clone(), surface.degree_v)
746 .ok()?
747 .domain();
748 let u_span = u_domain[1] - u_domain[0];
749 let v_span = v_domain[1] - v_domain[0];
750 let mut points = Vec::<Vec2>::new();
751 for coedge in &loop_record.coedges {
752 let edge = edges.get(&coedge.edge_id)?;
753 if edge.degenerate {
754 continue;
755 }
756 let [q0, q1] = coedge.pcurve.domain().ok()?;
757 for index in 0..=8 {
758 if !points.is_empty() && index == 0 {
759 continue;
760 }
761 let fraction = index as f64 / 8.0;
762 let parameter = q0 + (q1 - q0) * fraction;
763 let value = coedge.pcurve.evaluate(parameter).ok()?;
764 let mut point = Vec2 {
765 x: value.x,
766 y: value.y,
767 };
768 if let Some(previous) = points.last() {
769 while point.x - previous.x > u_span * 0.5 {
770 point.x -= u_span;
771 }
772 while point.x - previous.x < -u_span * 0.5 {
773 point.x += u_span;
774 }
775 while point.y - previous.y > v_span * 0.5 {
776 point.y -= v_span;
777 }
778 while point.y - previous.y < -v_span * 0.5 {
779 point.y += v_span;
780 }
781 }
782 points.push(point);
783 }
784 }
785 if points.len() < 4 {
786 return None;
787 }
788 let first = points[0];
789 let last = *points.last()?;
790 let uv_tolerance = tolerances.model.max(1e-8);
791 if last.sub(first).length() > uv_tolerance * 100.0 {
792 let du = (last.x - first.x) / u_span;
795 let dv = (last.y - first.y) / v_span;
796 if (du - du.round()).abs() * u_span > uv_tolerance * 100.0
797 || (dv - dv.round()).abs() * v_span > uv_tolerance * 100.0
798 {
799 return Some(format!(
800 "wire is open in parameter space (gap {:.3e})",
801 last.sub(first).length()
802 ));
803 }
804 }
805 for first_index in 0..points.len() - 1 {
806 for second_index in first_index + 2..points.len() - 1 {
807 if first_index == 0 && second_index + 1 == points.len() - 1 {
808 continue;
809 }
810 if segments_cross(
811 points[first_index],
812 points[first_index + 1],
813 points[second_index],
814 points[second_index + 1],
815 1e-10,
816 ) {
817 return Some(format!(
818 "wire self-intersects near sampled segments {first_index} and {second_index}"
819 ));
820 }
821 }
822 }
823 let area = points
824 .windows(2)
825 .map(|pair| pair[0].x * pair[1].y - pair[1].x * pair[0].y)
826 .sum::<f64>()
827 * 0.5;
828 if area.abs() > uv_tolerance * uv_tolerance {
829 let expected_positive = if outer { same_sense } else { !same_sense };
830 if (area > 0.0) != expected_positive {
831 return Some(format!(
832 "{} wire winding disagrees with face sense (signed UV area {:.6})",
833 if outer { "outer" } else { "inner" },
834 area
835 ));
836 }
837 }
838 None
839}