1use std::sync::Arc;
4
5use axiolid_contracts::{BackendId, ExecutionOptions, GeomError, GeomResult, Operation};
6
7use crate::device::matches_device;
8use axiolid_core::BooleanOperator;
9use axiolid_mesh::TriMesh;
10use axiolid_mesh_boolean_contract::{conformance, BooleanOutcome, MeshBoolean};
11use axiolid_mesh_contracts::SolidRequirements;
12
13#[derive(Debug, Clone)]
14struct RegisteredBoolean {
15 priority: i32,
16 provider: Arc<dyn MeshBoolean>,
17}
18
19#[derive(Debug, Clone, Default)]
24pub struct MeshBooleanRegistry {
25 providers: Vec<RegisteredBoolean>,
26 #[cfg(feature = "parallel")]
30 execution: Option<axiolid_backend_cpu::CpuExecution>,
31}
32
33impl MeshBooleanRegistry {
34 pub const fn new() -> Self {
36 Self {
37 providers: Vec::new(),
38 #[cfg(feature = "parallel")]
39 execution: None,
40 }
41 }
42
43 #[cfg(feature = "parallel")]
49 #[must_use]
50 pub fn with_execution(mut self, execution: axiolid_backend_cpu::CpuExecution) -> Self {
51 self.execution = Some(execution);
52 self
53 }
54
55 pub fn register<B>(&mut self, priority: i32, provider: B)
57 where
58 B: MeshBoolean + 'static,
59 {
60 self.register_arc(priority, Arc::new(provider));
61 }
62
63 pub fn register_conformant<B>(
77 &mut self,
78 priority: i32,
79 provider: B,
80 ) -> Result<(), Box<conformance::ConformanceReport>>
81 where
82 B: MeshBoolean + 'static,
83 {
84 let report = conformance::run(&provider);
85 if !report.is_conformant() {
86 return Err(Box::new(report));
87 }
88 self.register_arc(priority, Arc::new(provider));
89 Ok(())
90 }
91
92 pub fn register_arc(&mut self, priority: i32, provider: Arc<dyn MeshBoolean>) {
94 self.providers
95 .push(RegisteredBoolean { priority, provider });
96 self.providers
97 .sort_by_key(|entry| std::cmp::Reverse(entry.priority));
98 }
99
100 pub fn providers(&self) -> impl Iterator<Item = &dyn MeshBoolean> {
102 self.providers.iter().map(|entry| entry.provider.as_ref())
103 }
104
105 fn dispatch(
106 &self,
107 options: &ExecutionOptions,
108 elements: usize,
109 execute: impl Fn(&dyn MeshBoolean) -> GeomResult<BooleanOutcome> + Sync,
110 ) -> GeomResult<BooleanOutcome> {
111 let mut last_retryable = None;
112 let mut over_budget = None;
113 for entry in &self.providers {
114 let descriptor = entry.provider.descriptor();
115 if !matches_device(options.device(), descriptor.id, descriptor.target) {
116 continue;
117 }
118 if !self.fits_budget(entry.provider.as_ref(), options, elements) {
122 over_budget = Some(GeomError::BudgetExceeded { resource: "memory" });
123 continue;
124 }
125 match self.run_scoped(&execute, entry.provider.as_ref()) {
126 Ok(outcome) => return Ok(outcome),
127 Err(error @ (GeomError::Unsupported { .. } | GeomError::Unavailable { .. })) => {
128 last_retryable = Some(error);
129 }
130 Err(error) => return Err(error),
131 }
132 }
133 Err(last_retryable
134 .or(over_budget)
135 .unwrap_or(GeomError::Unsupported {
136 backend: BackendId::new("mesh-boolean-registry"),
137 operation: Operation::MeshBoolean,
138 }))
139 }
140
141 fn fits_budget(
148 &self,
149 provider: &dyn MeshBoolean,
150 options: &ExecutionOptions,
151 elements: usize,
152 ) -> bool {
153 let scratch = provider.scratch_requirement();
154 #[cfg(feature = "parallel")]
155 if let Some(execution) = &self.execution {
156 let workers = execution.thread_count().get();
159 return scratch.fits_budget_on(options, elements, workers);
160 }
161 scratch.fits_budget(options, elements)
162 }
163
164 #[cfg(feature = "parallel")]
169 fn run_scoped(
170 &self,
171 execute: &(impl Fn(&dyn MeshBoolean) -> GeomResult<BooleanOutcome> + Sync),
172 provider: &dyn MeshBoolean,
173 ) -> GeomResult<BooleanOutcome> {
174 match &self.execution {
175 Some(execution) => execution.install(|| execute(provider)),
176 None => execute(provider),
177 }
178 }
179
180 #[cfg(not(feature = "parallel"))]
181 fn run_scoped(
182 &self,
183 execute: &impl Fn(&dyn MeshBoolean) -> GeomResult<BooleanOutcome>,
184 provider: &dyn MeshBoolean,
185 ) -> GeomResult<BooleanOutcome> {
186 execute(provider)
187 }
188
189 pub fn boolean(
191 &self,
192 subject: &TriMesh,
193 tool: &TriMesh,
194 operation: BooleanOperator,
195 options: &ExecutionOptions,
196 ) -> GeomResult<BooleanOutcome> {
197 SolidRequirements::Oriented.validate_operands(subject, &[tool])?;
200 let elements = subject.triangle_count() + tool.triangle_count();
204 self.dispatch(options, elements, |provider| {
205 provider.boolean(subject, tool, operation, options)
206 })
207 }
208
209 pub fn subtract_many(
211 &self,
212 subject: &TriMesh,
213 tools: &[TriMesh],
214 options: &ExecutionOptions,
215 ) -> GeomResult<BooleanOutcome> {
216 let borrowed: Vec<&TriMesh> = tools.iter().collect();
217 SolidRequirements::Oriented.validate_operands(subject, &borrowed)?;
218 let elements =
219 subject.triangle_count() + tools.iter().map(TriMesh::triangle_count).sum::<usize>();
220 self.dispatch(options, elements, |provider| {
221 provider.subtract_many(subject, tools, options)
222 })
223 }
224
225 pub fn union_many(
231 &self,
232 solids: &[TriMesh],
233 options: &ExecutionOptions,
234 ) -> GeomResult<BooleanOutcome> {
235 let Some((first, rest)) = solids.split_first() else {
236 return Ok(BooleanOutcome::new(TriMesh::default(), Default::default()));
237 };
238 let borrowed: Vec<&TriMesh> = rest.iter().collect();
242 SolidRequirements::Oriented.validate_operands(first, &borrowed)?;
243 let elements = solids.iter().map(TriMesh::triangle_count).sum::<usize>();
244 self.dispatch(options, elements, |provider| {
245 provider.union_many(solids, options)
246 })
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use axiolid_contracts::{DevicePreference, ExecutionTarget};
253 fn admissible_cube() -> TriMesh {
258 let positions = vec![
259 [0.0, 0.0, 0.0].into(),
260 [1.0, 0.0, 0.0].into(),
261 [1.0, 1.0, 0.0].into(),
262 [0.0, 1.0, 0.0].into(),
263 [0.0, 0.0, 1.0].into(),
264 [1.0, 0.0, 1.0].into(),
265 [1.0, 1.0, 1.0].into(),
266 [0.0, 1.0, 1.0].into(),
267 ];
268 let indices = vec![
269 0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7,
270 6, 3, 0, 4, 3, 4, 7,
271 ];
272 TriMesh::new(positions, indices)
273 }
274
275 use std::sync::{
276 atomic::{AtomicUsize, Ordering},
277 Arc,
278 };
279
280 use axiolid_contracts::{Backend, BackendDescriptor};
281 use axiolid_core::Tolerance;
282 use axiolid_mesh_boolean_contract::BooleanEvidence;
283
284 use super::*;
285
286 #[derive(Debug)]
287 struct EchoBoolean {
288 id: BackendId,
289 target: ExecutionTarget,
290 }
291
292 impl Backend for EchoBoolean {
293 fn descriptor(&self) -> BackendDescriptor {
294 BackendDescriptor {
295 id: self.id,
296 target: self.target,
297 }
298 }
299 }
300
301 impl MeshBoolean for EchoBoolean {
302 fn boolean(
303 &self,
304 subject: &TriMesh,
305 _tool: &TriMesh,
306 _operation: BooleanOperator,
307 _options: &ExecutionOptions,
308 ) -> GeomResult<BooleanOutcome> {
309 Ok(BooleanOutcome::new(
310 subject.clone(),
311 BooleanEvidence::default(),
312 ))
313 }
314 }
315
316 #[derive(Debug, Clone, Copy)]
317 enum ProbeResult {
318 Success,
319 Unsupported,
320 Unavailable,
321 Invalid,
322 }
323
324 #[derive(Debug)]
325 struct ProbeBoolean {
326 id: BackendId,
327 target: ExecutionTarget,
328 result: ProbeResult,
329 calls: Arc<AtomicUsize>,
330 }
331
332 impl Backend for ProbeBoolean {
333 fn descriptor(&self) -> BackendDescriptor {
334 BackendDescriptor::new(self.id, self.target)
335 }
336 }
337
338 impl MeshBoolean for ProbeBoolean {
339 fn boolean(
340 &self,
341 subject: &TriMesh,
342 _tool: &TriMesh,
343 _operation: BooleanOperator,
344 _options: &ExecutionOptions,
345 ) -> GeomResult<BooleanOutcome> {
346 self.calls.fetch_add(1, Ordering::Relaxed);
347 match self.result {
348 ProbeResult::Success => Ok(BooleanOutcome::new(
349 subject.clone(),
350 BooleanEvidence::default(),
351 )),
352 ProbeResult::Unsupported => Err(GeomError::Unsupported {
353 backend: self.id,
354 operation: Operation::MeshBoolean,
355 }),
356 ProbeResult::Unavailable => Err(GeomError::Unavailable {
357 backend: self.id,
358 reason: "probe unavailable".to_owned(),
359 }),
360 ProbeResult::Invalid => {
361 Err(GeomError::InvalidInput("probe rejected input".to_owned()))
362 }
363 }
364 }
365 }
366
367 #[derive(Debug)]
368 struct BatchBoolean {
369 calls: Arc<AtomicUsize>,
370 }
371
372 impl Backend for BatchBoolean {
373 fn descriptor(&self) -> BackendDescriptor {
374 BackendDescriptor::new(BackendId::new("batch"), ExecutionTarget::OptimizedCpu)
375 }
376 }
377
378 impl MeshBoolean for BatchBoolean {
379 fn boolean(
380 &self,
381 _subject: &TriMesh,
382 _tool: &TriMesh,
383 _operation: BooleanOperator,
384 _options: &ExecutionOptions,
385 ) -> GeomResult<BooleanOutcome> {
386 Err(GeomError::InvalidInput(
387 "batch provider must use its batch override".to_owned(),
388 ))
389 }
390
391 fn subtract_many(
392 &self,
393 subject: &TriMesh,
394 _tools: &[TriMesh],
395 _options: &ExecutionOptions,
396 ) -> GeomResult<BooleanOutcome> {
397 self.calls.fetch_add(1, Ordering::Relaxed);
398 Ok(BooleanOutcome::new(
399 subject.clone(),
400 BooleanEvidence::default(),
401 ))
402 }
403 }
404
405 #[test]
406 fn registry_stores_executable_traits_not_capability_flags() {
407 let mut registry = MeshBooleanRegistry::new();
408 registry.register(
409 10,
410 EchoBoolean {
411 id: BackendId::new("echo"),
412 target: ExecutionTarget::PortableCpu,
413 },
414 );
415 let options = ExecutionOptions::new(Tolerance::METRE);
416 let mesh = admissible_cube();
417 assert_eq!(
418 registry
419 .boolean(&mesh, &mesh, BooleanOperator::Difference, &options)
420 .expect("registered provider executes"),
421 BooleanOutcome::new(mesh, BooleanEvidence::default())
422 );
423 }
424
425 #[test]
426 fn registry_dispatches_batch_subtraction_to_the_provider_override() {
427 let calls = Arc::new(AtomicUsize::new(0));
428 let mut registry = MeshBooleanRegistry::new();
429 registry.register(
430 10,
431 BatchBoolean {
432 calls: calls.clone(),
433 },
434 );
435 let mesh = admissible_cube();
436 let options = ExecutionOptions::new(Tolerance::METRE);
437
438 assert_eq!(
439 registry
440 .subtract_many(&mesh, &[mesh.clone(), mesh.clone()], &options)
441 .expect("batch provider executes"),
442 BooleanOutcome::new(mesh, BooleanEvidence::default())
443 );
444 assert_eq!(calls.load(Ordering::Relaxed), 1);
445 }
446
447 #[test]
448 fn registry_falls_back_only_for_retryable_errors_and_honors_device_policy() {
449 let high_calls = Arc::new(AtomicUsize::new(0));
450 let unsupported_calls = Arc::new(AtomicUsize::new(0));
451 let low_calls = Arc::new(AtomicUsize::new(0));
452 let mut registry = MeshBooleanRegistry::new();
453 registry.register(
454 100,
455 ProbeBoolean {
456 id: BackendId::new("unavailable-gpu"),
457 target: ExecutionTarget::Gpu,
458 result: ProbeResult::Unavailable,
459 calls: high_calls.clone(),
460 },
461 );
462 registry.register(
463 50,
464 ProbeBoolean {
465 id: BackendId::new("unsupported-cpu"),
466 target: ExecutionTarget::OptimizedCpu,
467 result: ProbeResult::Unsupported,
468 calls: unsupported_calls.clone(),
469 },
470 );
471 registry.register(
472 10,
473 ProbeBoolean {
474 id: BackendId::new("portable-fallback"),
475 target: ExecutionTarget::PortableCpu,
476 result: ProbeResult::Success,
477 calls: low_calls.clone(),
478 },
479 );
480 let mesh = admissible_cube();
481 let auto = ExecutionOptions::new(Tolerance::METRE);
482 assert!(registry
483 .boolean(&mesh, &mesh, BooleanOperator::Union, &auto)
484 .is_ok());
485 assert_eq!(high_calls.load(Ordering::Relaxed), 1);
486 assert_eq!(unsupported_calls.load(Ordering::Relaxed), 1);
487 assert_eq!(low_calls.load(Ordering::Relaxed), 1);
488
489 let cpu = auto.clone().with_device(DevicePreference::Cpu);
490 assert!(registry
491 .boolean(&mesh, &mesh, BooleanOperator::Union, &cpu)
492 .is_ok());
493 assert_eq!(high_calls.load(Ordering::Relaxed), 1);
494 assert_eq!(unsupported_calls.load(Ordering::Relaxed), 2);
495 assert_eq!(low_calls.load(Ordering::Relaxed), 2);
496
497 let invalid_calls = Arc::new(AtomicUsize::new(0));
498 let skipped_calls = Arc::new(AtomicUsize::new(0));
499 let mut fail_fast = MeshBooleanRegistry::new();
500 fail_fast.register(
501 100,
502 ProbeBoolean {
503 id: BackendId::new("invalid-input"),
504 target: ExecutionTarget::PortableCpu,
505 result: ProbeResult::Invalid,
506 calls: invalid_calls.clone(),
507 },
508 );
509 fail_fast.register(
510 10,
511 ProbeBoolean {
512 id: BackendId::new("must-not-run"),
513 target: ExecutionTarget::PortableCpu,
514 result: ProbeResult::Success,
515 calls: skipped_calls.clone(),
516 },
517 );
518 assert!(matches!(
519 fail_fast.boolean(&mesh, &mesh, BooleanOperator::Union, &auto),
520 Err(GeomError::InvalidInput(_))
521 ));
522 assert_eq!(invalid_calls.load(Ordering::Relaxed), 1);
523 assert_eq!(skipped_calls.load(Ordering::Relaxed), 0);
524 }
525
526 #[test]
527 fn empty_registry_returns_structured_unsupported_error() {
528 let registry = MeshBooleanRegistry::new();
529 let mesh = admissible_cube();
530 let error = registry
531 .boolean(
532 &mesh,
533 &mesh,
534 BooleanOperator::Union,
535 &ExecutionOptions::new(Tolerance::METRE),
536 )
537 .unwrap_err();
538 assert!(matches!(
539 error,
540 GeomError::Unsupported {
541 backend,
542 operation: Operation::MeshBoolean,
543 } if backend == BackendId::new("mesh-boolean-registry")
544 ));
545 }
546
547 #[cfg(feature = "parallel")]
553 #[derive(Debug, Default)]
554 struct PoolWidthBoolean {
555 seen: std::sync::Mutex<Vec<usize>>,
556 }
557
558 #[cfg(feature = "parallel")]
559 impl Backend for PoolWidthBoolean {
560 fn descriptor(&self) -> BackendDescriptor {
561 BackendDescriptor {
562 id: BackendId::new("pool-width"),
563 target: ExecutionTarget::PortableCpu,
564 }
565 }
566 }
567
568 #[cfg(feature = "parallel")]
569 impl MeshBoolean for PoolWidthBoolean {
570 fn boolean(
571 &self,
572 subject: &TriMesh,
573 _tool: &TriMesh,
574 _operation: BooleanOperator,
575 _options: &ExecutionOptions,
576 ) -> GeomResult<BooleanOutcome> {
577 self.seen
578 .lock()
579 .expect("poisoned")
580 .push(rayon::current_num_threads());
581 Ok(BooleanOutcome::new(
582 subject.clone(),
583 BooleanEvidence::default(),
584 ))
585 }
586 }
587
588 #[test]
594 #[cfg(feature = "parallel")]
595 fn dispatch_runs_inside_the_configured_pool() {
596 use axiolid_backend_cpu::CpuExecutionBuilder;
597 use std::num::NonZeroUsize;
598 use std::sync::Arc;
599
600 let execution = CpuExecutionBuilder::new()
601 .threads(NonZeroUsize::new(3).expect("nonzero"))
602 .build()
603 .expect("cpu execution");
604 let provider = Arc::new(PoolWidthBoolean::default());
605 let mut registry = MeshBooleanRegistry::new().with_execution(execution);
606 registry.register_arc(0, provider.clone());
607
608 let cube = admissible_cube();
609 let options = ExecutionOptions::new(Tolerance::MILLIMETRE);
610 registry
611 .boolean(&cube, &cube, BooleanOperator::Union, &options)
612 .expect("dispatch");
613
614 let widths = provider.seen.lock().expect("poisoned").clone();
615 assert_eq!(
616 widths,
617 vec![3],
618 "provider must run inside the 3-worker pool"
619 );
620 }
621
622 #[test]
626 #[cfg(feature = "parallel")]
627 fn a_one_thread_context_runs_the_provider_on_one_worker() {
628 use axiolid_backend_cpu::CpuExecutionBuilder;
629 use std::num::NonZeroUsize;
630 use std::sync::Arc;
631
632 let execution = CpuExecutionBuilder::new()
633 .threads(NonZeroUsize::MIN)
634 .build()
635 .expect("cpu execution");
636 let provider = Arc::new(PoolWidthBoolean::default());
637 let mut registry = MeshBooleanRegistry::new().with_execution(execution);
638 registry.register_arc(0, provider.clone());
639
640 let cube = admissible_cube();
641 let options = ExecutionOptions::new(Tolerance::MILLIMETRE);
642 rayon::ThreadPoolBuilder::new()
643 .num_threads(3)
644 .build()
645 .expect("caller pool")
646 .install(|| registry.boolean(&cube, &cube, BooleanOperator::Union, &options))
647 .expect("dispatch");
648
649 let widths = provider.seen.lock().expect("poisoned").clone();
650 assert_eq!(widths, vec![1], "provider must run on one worker");
651 }
652
653 #[test]
657 #[cfg(feature = "parallel")]
658 fn an_unscoped_registry_does_not_see_the_configured_width() {
659 use std::sync::Arc;
660
661 let provider = Arc::new(PoolWidthBoolean::default());
662 let mut registry = MeshBooleanRegistry::new();
663 registry.register_arc(0, provider.clone());
664
665 let cube = admissible_cube();
666 let options = ExecutionOptions::new(Tolerance::MILLIMETRE);
667 registry
668 .boolean(&cube, &cube, BooleanOperator::Union, &options)
669 .expect("dispatch");
670
671 let widths = provider.seen.lock().expect("poisoned").clone();
672 assert_eq!(widths.len(), 1, "one dispatch");
673 assert_eq!(
674 widths[0],
675 rayon::current_num_threads(),
676 "unscoped dispatch must observe the ambient pool"
677 );
678 }
679}