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(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(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 issues.push(ValidationIssue::error(format!(
289 "edge {} used {} times (expected 2)",
290 edge.id,
291 uses.len()
292 )));
293 } else if uses[0] == uses[1] {
294 issues.push(ValidationIssue::error(format!(
295 "edge {} has coedges with the same sense",
296 edge.id
297 )));
298 }
299 }
300
301 for edge_id in edge_uses.keys() {
302 if !edges.contains_key(edge_id) {
303 issues.push(ValidationIssue::error(format!(
304 "topology references missing edge {}",
305 edge_id
306 )));
307 }
308 }
309
310 let non_degenerate_vertex_ids = self
314 .edges
315 .iter()
316 .filter(|edge| !edge.degenerate)
317 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
318 .collect::<HashSet<_>>();
319 let vertex_count = vertices
320 .keys()
321 .filter(|id| non_degenerate_vertex_ids.contains(id))
322 .count() as i64;
323 let edge_count = self.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
324 let face_count = face_ids.len() as i64;
325 let hole_count: i64 = self
326 .shells
327 .iter()
328 .flat_map(|shell| &shell.faces)
329 .map(|face| face.loops.len().saturating_sub(1) as i64)
330 .sum();
331 let shell_count = self.shells.len() as i64;
332 let actual = vertex_count - edge_count + face_count - hole_count;
333 let expected = 2 * (shell_count - self.genus);
334 if actual != expected && !self.edges.iter().any(|edge| edge.degenerate) {
338 let pairs = self.coincident_parallel_edge_pairs() as i64;
347 if actual + pairs != expected {
348 issues.push(ValidationIssue::error(format!(
349 "Euler formula: V-E+F-H = {}, expected {} ({} coincident parallel edge pair(s) credited)",
350 actual + pairs,
351 expected,
352 pairs
353 )));
354 }
355 }
356 ValidationReport {
357 issues,
358 wire_warnings,
359 max_pcurve_error,
360 }
361 }
362
363 pub(crate) fn coincident_parallel_edge_pairs(&self) -> usize {
368 let candidates: Vec<&EdgeRecord> =
369 self.edges.iter().filter(|edge| !edge.degenerate).collect();
370 let mut consumed = vec![false; candidates.len()];
371 let mut pairs = 0usize;
372 for first_index in 0..candidates.len() {
373 if consumed[first_index] {
374 continue;
375 }
376 let first = candidates[first_index];
377 for second_index in first_index + 1..candidates.len() {
378 if consumed[second_index] {
379 continue;
380 }
381 let second = candidates[second_index];
382 let endpoints_match = (first.start_vertex_id == second.start_vertex_id
383 && first.end_vertex_id == second.end_vertex_id)
384 || (first.start_vertex_id == second.end_vertex_id
385 && first.end_vertex_id == second.start_vertex_id);
386 if !endpoints_match {
387 continue;
388 }
389 let coincident = (1..4).all(|sample| {
391 let fraction = sample as f64 / 4.0;
392 let Ok(on_first) = first
393 .curve
394 .evaluate(first.t0 + (first.t1 - first.t0) * fraction)
395 else {
396 return false;
397 };
398 let forward = second.t0 + (second.t1 - second.t0) * fraction;
399 let backward = second.t1 - (second.t1 - second.t0) * fraction;
400 let tolerance = 1e-6 * (1.0 + on_first.length());
401 let matches_forward = second
402 .curve
403 .evaluate(forward)
404 .map(|point| point.sub(on_first).length() <= tolerance)
405 .unwrap_or(false);
406 let matches_backward = second
407 .curve
408 .evaluate(backward)
409 .map(|point| point.sub(on_first).length() <= tolerance)
410 .unwrap_or(false);
411 matches_forward || matches_backward
412 });
413 if coincident {
414 consumed[first_index] = true;
415 consumed[second_index] = true;
416 pairs += 1;
417 break;
418 }
419 }
420 }
421 pairs
422 }
423}
424
425fn coedge_sample(
426 surface: &NurbsSurface,
427 pcurve: &NurbsCurve,
428 curve: &NurbsCurve,
429 edge: &EdgeRecord,
430 forward: bool,
431 fraction: f64,
432) -> Result<(f64, Vec3, Vec3), String> {
433 let [q0, q1] = pcurve.domain()?;
434 let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
435 let on_surface = surface.evaluate_extended(uv.x, uv.y)?;
440 let t = if forward {
441 edge.t0 + (edge.t1 - edge.t0) * fraction
442 } else {
443 edge.t1 - (edge.t1 - edge.t0) * fraction
444 };
445 let on_curve = curve.evaluate(t)?;
446 Ok((on_surface.sub(on_curve).length(), on_surface, on_curve))
447}
448
449pub(crate) fn adaptive_coedge_error(
454 surface: &NurbsSurface,
455 pcurve: &NurbsCurve,
456 curve: &NurbsCurve,
457 edge: &EdgeRecord,
458 forward: bool,
459 tolerance: f64,
460) -> Result<f64, String> {
461 fn interval(
462 surface: &NurbsSurface,
463 pcurve: &NurbsCurve,
464 curve: &NurbsCurve,
465 edge: &EdgeRecord,
466 forward: bool,
467 a: f64,
468 b: f64,
469 sample_a: (f64, Vec3, Vec3),
470 sample_b: (f64, Vec3, Vec3),
471 tolerance: f64,
472 depth: usize,
473 ) -> Result<f64, String> {
474 let mid = (a + b) * 0.5;
475 let sample_mid = coedge_sample(surface, pcurve, curve, edge, forward, mid)?;
476 let error_nonlinearity = (sample_mid.0 - (sample_a.0 + sample_b.0) * 0.5).abs();
477 let surface_bend = sample_mid
478 .1
479 .sub(sample_a.1.add(sample_b.1).scale(0.5))
480 .length();
481 let curve_bend = sample_mid
482 .2
483 .sub(sample_a.2.add(sample_b.2).scale(0.5))
484 .length();
485 let local_max = sample_a.0.max(sample_mid.0).max(sample_b.0);
486 if depth >= 3
487 || (error_nonlinearity <= tolerance * 0.05
488 && surface_bend.max(curve_bend) <= tolerance * 0.25)
489 {
490 return Ok(local_max);
491 }
492 Ok(interval(
493 surface,
494 pcurve,
495 curve,
496 edge,
497 forward,
498 a,
499 mid,
500 sample_a,
501 sample_mid,
502 tolerance,
503 depth + 1,
504 )?
505 .max(interval(
506 surface,
507 pcurve,
508 curve,
509 edge,
510 forward,
511 mid,
512 b,
513 sample_mid,
514 sample_b,
515 tolerance,
516 depth + 1,
517 )?))
518 }
519
520 let mut maximum = 0.0f64;
521 let mut previous = coedge_sample(surface, pcurve, curve, edge, forward, 0.0)?;
522 maximum = maximum.max(previous.0);
523 for index in 1..=32 {
524 let a = (index - 1) as f64 / 32.0;
525 let b = index as f64 / 32.0;
526 let next = coedge_sample(surface, pcurve, curve, edge, forward, b)?;
527 maximum = maximum.max(interval(
528 surface, pcurve, curve, edge, forward, a, b, previous, next, tolerance, 0,
529 )?);
530 previous = next;
531 }
532 Ok(maximum)
533}
534
535fn segments_cross(a: Vec2, b: Vec2, c: Vec2, d: Vec2, tolerance: f64) -> bool {
536 let cross = |first: Vec2, second: Vec2| first.x * second.y - first.y * second.x;
537 let ab = b.sub(a);
538 let cd = d.sub(c);
539 let denominator = cross(ab, cd);
540 if denominator.abs() <= tolerance {
541 return false;
542 }
543 let ac = c.sub(a);
544 let t = cross(ac, cd) / denominator;
545 let u = cross(ac, ab) / denominator;
546 t > tolerance && t < 1.0 - tolerance && u > tolerance && u < 1.0 - tolerance
547}
548
549fn validate_uv_wire(
550 surface: &NurbsSurface,
551 loop_record: &LoopRecord,
552 edges: &HashMap<u64, &EdgeRecord>,
553 tolerances: &KernelTolerances,
554 outer: bool,
555 same_sense: bool,
556) -> Option<String> {
557 let u_domain = crate::KnotVector::new(surface.knots_u.clone(), surface.degree_u)
558 .ok()?
559 .domain();
560 let v_domain = crate::KnotVector::new(surface.knots_v.clone(), surface.degree_v)
561 .ok()?
562 .domain();
563 let u_span = u_domain[1] - u_domain[0];
564 let v_span = v_domain[1] - v_domain[0];
565 let mut points = Vec::<Vec2>::new();
566 for coedge in &loop_record.coedges {
567 let edge = edges.get(&coedge.edge_id)?;
568 if edge.degenerate {
569 continue;
570 }
571 let [q0, q1] = coedge.pcurve.domain().ok()?;
572 for index in 0..=8 {
573 if !points.is_empty() && index == 0 {
574 continue;
575 }
576 let fraction = index as f64 / 8.0;
577 let parameter = q0 + (q1 - q0) * fraction;
578 let value = coedge.pcurve.evaluate(parameter).ok()?;
579 let mut point = Vec2 {
580 x: value.x,
581 y: value.y,
582 };
583 if let Some(previous) = points.last() {
584 while point.x - previous.x > u_span * 0.5 {
585 point.x -= u_span;
586 }
587 while point.x - previous.x < -u_span * 0.5 {
588 point.x += u_span;
589 }
590 while point.y - previous.y > v_span * 0.5 {
591 point.y -= v_span;
592 }
593 while point.y - previous.y < -v_span * 0.5 {
594 point.y += v_span;
595 }
596 }
597 points.push(point);
598 }
599 }
600 if points.len() < 4 {
601 return None;
602 }
603 let first = points[0];
604 let last = *points.last()?;
605 let uv_tolerance = tolerances.model.max(1e-8);
606 if last.sub(first).length() > uv_tolerance * 100.0 {
607 let du = (last.x - first.x) / u_span;
610 let dv = (last.y - first.y) / v_span;
611 if (du - du.round()).abs() * u_span > uv_tolerance * 100.0
612 || (dv - dv.round()).abs() * v_span > uv_tolerance * 100.0
613 {
614 return Some(format!(
615 "wire is open in parameter space (gap {:.3e})",
616 last.sub(first).length()
617 ));
618 }
619 }
620 for first_index in 0..points.len() - 1 {
621 for second_index in first_index + 2..points.len() - 1 {
622 if first_index == 0 && second_index + 1 == points.len() - 1 {
623 continue;
624 }
625 if segments_cross(
626 points[first_index],
627 points[first_index + 1],
628 points[second_index],
629 points[second_index + 1],
630 1e-10,
631 ) {
632 return Some(format!(
633 "wire self-intersects near sampled segments {first_index} and {second_index}"
634 ));
635 }
636 }
637 }
638 let area = points
639 .windows(2)
640 .map(|pair| pair[0].x * pair[1].y - pair[1].x * pair[0].y)
641 .sum::<f64>()
642 * 0.5;
643 if area.abs() > uv_tolerance * uv_tolerance {
644 let expected_positive = if outer { same_sense } else { !same_sense };
645 if (area > 0.0) != expected_positive {
646 return Some(format!(
647 "{} wire winding disagrees with face sense (signed UV area {:.6})",
648 if outer { "outer" } else { "inner" },
649 area
650 ));
651 }
652 }
653 None
654}