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: 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 if actual != expected && !self.edges.iter().any(|edge| edge.degenerate) {
339 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 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 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 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
450pub(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 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}