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.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(¤t.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 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 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 if actual != expected && !self.edges.iter().any(|edge| edge.degenerate) {
336 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 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 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 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
447pub(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 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}