1use crate::classification::{PointClass, SolidClassifier};
34use crate::spatial::Aabb;
35use crate::topology::BrepSolid;
36use crate::{BooleanOperation, KernelTolerances, Vec3};
37use serde::Serialize;
38
39pub const DISAGREEMENT_THRESHOLD: f64 = 0.03;
50
51const SKIP_FRACTION: f64 = 1e-4;
55
56const JITTER_FRACTION: f64 = 4e-3;
62
63const BULK_NUMERATOR: usize = 45;
66const BULK_DENOMINATOR: usize = 100;
67
68struct Rng {
72 state: u64,
73}
74
75impl Rng {
76 fn new(seed: u64) -> Self {
77 Self { state: seed }
78 }
79
80 fn next_u64(&mut self) -> u64 {
81 self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
82 let mut z = self.state;
83 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
84 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
85 z ^ (z >> 31)
86 }
87
88 fn unit(&mut self) -> f64 {
90 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
91 }
92
93 fn range(&mut self, low: f64, high: f64) -> f64 {
95 low + (high - low) * self.unit()
96 }
97
98 fn unit_vector(&mut self) -> Vec3 {
100 let z = self.range(-1.0, 1.0);
101 let angle = self.range(0.0, std::f64::consts::TAU);
102 let radius = (1.0 - z * z).max(0.0).sqrt();
103 Vec3::new(radius * angle.cos(), radius * angle.sin(), z)
104 }
105}
106
107#[derive(Clone, Copy, Debug, Serialize)]
109pub struct SemanticDisagreement {
110 pub point: Vec3,
111 pub expected_in: bool,
113 pub result_in: bool,
115 pub in_a: bool,
116 pub in_b: bool,
117}
118
119#[derive(Clone, Debug, Serialize)]
121pub struct OracleReport {
122 pub sampled: usize,
124 pub on_skipped: usize,
127 pub considered: usize,
129 pub disagreements: Vec<SemanticDisagreement>,
131 pub disagreement_rate: f64,
133}
134
135impl OracleReport {
136 pub fn is_flagged(&self) -> bool {
138 self.disagreement_rate > DISAGREEMENT_THRESHOLD
139 }
140
141 pub fn sample_disagreement(&self) -> Option<SemanticDisagreement> {
143 self.disagreements.first().copied()
144 }
145}
146
147fn faces_of(solid: &BrepSolid) -> impl Iterator<Item = &crate::topology::FaceRecord> {
148 solid.shells.iter().flat_map(|shell| shell.faces.iter())
149}
150
151fn combined_bounds(first: &BrepSolid, second: &BrepSolid) -> Result<Aabb, String> {
152 let mut bounds = Aabb::empty();
153 for face in faces_of(first).chain(faces_of(second)) {
154 bounds.include(Aabb::from_surface_controls(&face.surface)?);
155 }
156 Ok(bounds)
157}
158
159fn expected_membership(operation: BooleanOperation, in_a: bool, in_b: bool) -> bool {
161 match operation {
162 BooleanOperation::Union => in_a || in_b,
163 BooleanOperation::Intersect => in_a && in_b,
164 BooleanOperation::Subtract => in_a && !in_b,
165 }
166}
167
168pub fn boolean_semantic_disagreement(
174 first: &BrepSolid,
175 second: &BrepSolid,
176 operation: BooleanOperation,
177 result: &BrepSolid,
178 samples: usize,
179) -> Result<OracleReport, String> {
180 let policy = KernelTolerances::for_pair(first, second, 1e-7);
181 let model = policy.model;
182
183 let bounds = combined_bounds(first, second)?;
184 let diagonal = bounds.diagonal();
185 if !diagonal.is_finite() || diagonal <= 0.0 || samples == 0 {
186 return Ok(OracleReport {
187 sampled: 0,
188 on_skipped: 0,
189 considered: 0,
190 disagreements: Vec::new(),
191 disagreement_rate: 0.0,
192 });
193 }
194
195 let classifier_a = SolidClassifier::new(first, model)?;
196 let classifier_b = SolidClassifier::new(second, model)?;
197 let classifier_r = SolidClassifier::new(result, model)?;
198
199 let skip_band = (model * 10.0).max(diagonal * SKIP_FRACTION);
200 let jitter = diagonal * JITTER_FRACTION;
201
202 let boundary_faces: Vec<&crate::topology::FaceRecord> = faces_of(first)
206 .chain(faces_of(second))
207 .chain(faces_of(result))
208 .collect();
209
210 let bulk_count = samples * BULK_NUMERATOR / BULK_DENOMINATOR;
211
212 let mut rng = Rng::new(0x0B00_00AC_1E00_5EED);
213 let mut report = OracleReport {
214 sampled: 0,
215 on_skipped: 0,
216 considered: 0,
217 disagreements: Vec::new(),
218 disagreement_rate: 0.0,
219 };
220
221 for index in 0..samples {
222 let point = if index < bulk_count || boundary_faces.is_empty() {
223 Vec3::new(
224 rng.range(bounds.minimum.x, bounds.maximum.x),
225 rng.range(bounds.minimum.y, bounds.maximum.y),
226 rng.range(bounds.minimum.z, bounds.maximum.z),
227 )
228 } else {
229 boundary_sample(&mut rng, &boundary_faces, jitter)
230 };
231 report.sampled += 1;
232
233 let near = classifier_a.within_band(point, skip_band)?
237 || classifier_b.within_band(point, skip_band)?
238 || classifier_r.within_band(point, skip_band)?;
239 if near {
240 report.on_skipped += 1;
241 continue;
242 }
243
244 let (Ok(ca), Ok(cb), Ok(cr)) = (
245 classifier_a.classify(point),
246 classifier_b.classify(point),
247 classifier_r.classify(point),
248 ) else {
249 report.on_skipped += 1;
252 continue;
253 };
254 if ca.class == PointClass::On || cb.class == PointClass::On || cr.class == PointClass::On {
257 report.on_skipped += 1;
258 continue;
259 }
260
261 let in_a = ca.class == PointClass::In;
262 let in_b = cb.class == PointClass::In;
263 let result_in = cr.class == PointClass::In;
264 let expected_in = expected_membership(operation, in_a, in_b);
265 report.considered += 1;
266 if expected_in != result_in {
267 report.disagreements.push(SemanticDisagreement {
268 point,
269 expected_in,
270 result_in,
271 in_a,
272 in_b,
273 });
274 }
275 }
276
277 report.disagreement_rate = if report.considered == 0 {
278 0.0
279 } else {
280 report.disagreements.len() as f64 / report.considered as f64
281 };
282 Ok(report)
283}
284
285fn expected_membership_nary(operation: BooleanOperation, in_operands: &[bool]) -> bool {
291 match operation {
292 BooleanOperation::Union => in_operands.iter().any(|&inside| inside),
293 BooleanOperation::Intersect => in_operands.iter().all(|&inside| inside),
294 BooleanOperation::Subtract => {
295 in_operands[0] && in_operands[1..].iter().all(|&inside| !inside)
296 }
297 }
298}
299
300pub fn boolean_semantic_disagreement_nary(
308 operands: &[BrepSolid],
309 operation: BooleanOperation,
310 result: &BrepSolid,
311 samples: usize,
312) -> Result<OracleReport, String> {
313 if operands.is_empty() {
314 return Err("boolean_semantic_disagreement_nary: no operands".into());
315 }
316 let model = KernelTolerances::for_solid(&operands[0], 1e-7).model;
317
318 let mut bounds = Aabb::empty();
319 for operand in operands {
320 for face in faces_of(operand) {
321 bounds.include(Aabb::from_surface_controls(&face.surface)?);
322 }
323 }
324 let diagonal = bounds.diagonal();
325 if !diagonal.is_finite() || diagonal <= 0.0 || samples == 0 {
326 return Ok(OracleReport {
327 sampled: 0,
328 on_skipped: 0,
329 considered: 0,
330 disagreements: Vec::new(),
331 disagreement_rate: 0.0,
332 });
333 }
334
335 let classifiers = operands
336 .iter()
337 .map(|operand| SolidClassifier::new(operand, model))
338 .collect::<Result<Vec<_>, _>>()?;
339 let classifier_r = SolidClassifier::new(result, model)?;
340
341 let skip_band = (model * 10.0).max(diagonal * SKIP_FRACTION);
342 let jitter = diagonal * JITTER_FRACTION;
343
344 let boundary_faces: Vec<&crate::topology::FaceRecord> = operands
345 .iter()
346 .flat_map(faces_of)
347 .chain(faces_of(result))
348 .collect();
349
350 let bulk_count = samples * BULK_NUMERATOR / BULK_DENOMINATOR;
351
352 let mut rng = Rng::new(0x0B00_00AC_1E00_5EED);
353 let mut report = OracleReport {
354 sampled: 0,
355 on_skipped: 0,
356 considered: 0,
357 disagreements: Vec::new(),
358 disagreement_rate: 0.0,
359 };
360
361 for index in 0..samples {
362 let point = if index < bulk_count || boundary_faces.is_empty() {
363 Vec3::new(
364 rng.range(bounds.minimum.x, bounds.maximum.x),
365 rng.range(bounds.minimum.y, bounds.maximum.y),
366 rng.range(bounds.minimum.z, bounds.maximum.z),
367 )
368 } else {
369 boundary_sample(&mut rng, &boundary_faces, jitter)
370 };
371 report.sampled += 1;
372
373 let mut near = classifier_r.within_band(point, skip_band)?;
375 for classifier in &classifiers {
376 near = near || classifier.within_band(point, skip_band)?;
377 }
378 if near {
379 report.on_skipped += 1;
380 continue;
381 }
382
383 let Ok(cr) = classifier_r.classify(point) else {
384 report.on_skipped += 1;
385 continue;
386 };
387 if cr.class == PointClass::On {
388 report.on_skipped += 1;
389 continue;
390 }
391 let mut in_operands = Vec::with_capacity(classifiers.len());
392 let mut ambiguous = false;
393 for classifier in &classifiers {
394 match classifier.classify(point) {
395 Ok(classification) if classification.class != PointClass::On => {
396 in_operands.push(classification.class == PointClass::In);
397 }
398 _ => {
399 ambiguous = true;
400 break;
401 }
402 }
403 }
404 if ambiguous {
405 report.on_skipped += 1;
406 continue;
407 }
408
409 let result_in = cr.class == PointClass::In;
410 let expected_in = expected_membership_nary(operation, &in_operands);
411 report.considered += 1;
412 if expected_in != result_in {
413 report.disagreements.push(SemanticDisagreement {
414 point,
415 expected_in,
416 result_in,
417 in_a: in_operands[0],
420 in_b: *in_operands.get(1).unwrap_or(&false),
421 });
422 }
423 }
424
425 report.disagreement_rate = if report.considered == 0 {
426 0.0
427 } else {
428 report.disagreements.len() as f64 / report.considered as f64
429 };
430 Ok(report)
431}
432
433fn boundary_sample(rng: &mut Rng, faces: &[&crate::topology::FaceRecord], jitter: f64) -> Vec3 {
436 let face = faces[(rng.next_u64() as usize) % faces.len()];
437 let (Ok([u0, u1]), Ok([v0, v1])) = (face.surface.domain_u(), face.surface.domain_v()) else {
438 return Vec3::default();
439 };
440 let u = rng.range(u0, u1);
441 let v = rng.range(v0, v1);
442 let base = match face.surface.evaluate(u, v) {
443 Ok(point) => point,
444 Err(_) => return Vec3::default(),
445 };
446 let direction = match face.surface.normal(u, v) {
447 Ok(normal) if normal.length() > 1e-9 => normal,
448 _ => rng.unit_vector(),
449 };
450 let sign = if rng.unit() < 0.5 { -1.0 } else { 1.0 };
451 base.add(direction.scale(sign * jitter))
452}
453
454#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
456#[serde(rename_all = "snake_case")]
457pub enum ResidualKind {
458 CoincidentVertices,
460 DuplicateEdge,
463}
464
465#[derive(Clone, Debug, Serialize)]
467pub struct ResidualFusable {
468 pub kind: ResidualKind,
469 pub detail: String,
470 pub point: Vec3,
471}
472
473pub fn boolean_residual_fusables(result: &BrepSolid, tol: f64) -> Vec<ResidualFusable> {
478 let mut findings = Vec::new();
479
480 let vertices = &result.vertices;
482 for i in 0..vertices.len() {
483 for j in (i + 1)..vertices.len() {
484 let gap = vertices[i].point.sub(vertices[j].point).length();
485 if gap <= tol {
486 findings.push(ResidualFusable {
487 kind: ResidualKind::CoincidentVertices,
488 detail: format!(
489 "vertices {} and {} coincide within {gap:.3e}",
490 vertices[i].id, vertices[j].id
491 ),
492 point: vertices[i].point,
493 });
494 }
495 }
496 }
497
498 let edges = &result.edges;
503 let endpoint = |edge: &crate::topology::EdgeRecord| -> Result<(Vec3, Vec3, Vec3), String> {
504 let start = edge.curve.evaluate(edge.t0)?;
505 let end = edge.curve.evaluate(edge.t1)?;
506 let mid = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5)?;
507 Ok((start, mid, end))
508 };
509 for i in 0..edges.len() {
510 let Ok((si, mi, ei)) = endpoint(&edges[i]) else {
511 continue;
512 };
513 for j in (i + 1)..edges.len() {
514 let Ok((sj, mj, ej)) = endpoint(&edges[j]) else {
515 continue;
516 };
517 let endpoints_match = (si.sub(sj).length() <= tol && ei.sub(ej).length() <= tol)
518 || (si.sub(ej).length() <= tol && ei.sub(sj).length() <= tol);
519 if endpoints_match && mi.sub(mj).length() <= tol {
520 findings.push(ResidualFusable {
521 kind: ResidualKind::DuplicateEdge,
522 detail: format!(
523 "edges {} and {} trace the same curve within {tol:.3e}",
524 edges[i].id, edges[j].id
525 ),
526 point: mi,
527 });
528 }
529 }
530 }
531
532 findings
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::topology::VertexRecord;
539 use crate::{boolean_operation, make_box_brep, make_cylinder_brep, BooleanOptions};
540
541 fn cube() -> BrepSolid {
542 make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap()
543 }
544
545 fn cylinder() -> BrepSolid {
546 make_cylinder_brep(
550 Vec3::new(0.0, 0.0, -8.0),
551 Vec3::new(0.0, 0.0, 1.0),
552 3.0,
553 16.0,
554 )
555 .unwrap()
556 }
557
558 #[test]
559 fn union_of_cube_and_cylinder_agrees() {
560 let a = cube();
561 let b = cylinder();
562 let result =
563 boolean_operation(&a, &b, BooleanOperation::Union, &BooleanOptions::default()).unwrap();
564 let report =
565 boolean_semantic_disagreement(&a, &b, BooleanOperation::Union, &result, 300).unwrap();
566 assert!(
567 report.considered > 50,
568 "too few decidable points: {report:?}"
569 );
570 assert!(
571 !report.is_flagged(),
572 "valid union flagged: rate {} sample {:?}",
573 report.disagreement_rate,
574 report.sample_disagreement()
575 );
576 }
577
578 #[test]
579 fn subtract_of_cube_and_cylinder_agrees() {
580 let a = cube();
581 let b = cylinder();
582 let result = boolean_operation(
583 &a,
584 &b,
585 BooleanOperation::Subtract,
586 &BooleanOptions::default(),
587 )
588 .unwrap();
589 let report =
590 boolean_semantic_disagreement(&a, &b, BooleanOperation::Subtract, &result, 300)
591 .unwrap();
592 assert!(!report.is_flagged(), "valid subtract flagged: {report:?}");
593 }
594
595 #[test]
596 fn clean_result_has_no_residual_fusables() {
597 let cube = cube();
598 assert!(boolean_residual_fusables(&cube, 1e-5).is_empty());
599 }
600
601 #[test]
602 fn duplicated_vertex_is_flagged_as_residual() {
603 let mut cube = cube();
604 let seed = cube.vertices[0].clone();
605 cube.vertices.push(VertexRecord {
606 id: 9999,
607 point: seed.point,
608 });
609 let fusables = boolean_residual_fusables(&cube, 1e-5);
610 assert!(
611 fusables
612 .iter()
613 .any(|f| f.kind == ResidualKind::CoincidentVertices),
614 "duplicated vertex not detected: {fusables:?}"
615 );
616 }
617}