Skip to main content

axiolid_dispatch/
boolean.rs

1//! Provider registration, ordering, fallback, and budget policy for mesh booleans.
2
3use 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/// Ordered executable providers for one narrow operation.
20///
21/// Fallback happens only for `Unsupported` or `Unavailable`; numerical and data
22/// failures are returned immediately rather than hidden by another algorithm.
23#[derive(Debug, Clone, Default)]
24pub struct MeshBooleanRegistry {
25    providers: Vec<RegisteredBoolean>,
26    /// Caller-owned CPU context. Every dispatched provider call runs inside
27    /// its pool, so a provider's internal rayon work is bounded by the
28    /// embedding application's policy rather than the process-global pool.
29    #[cfg(feature = "parallel")]
30    execution: Option<axiolid_backend_cpu::CpuExecution>,
31}
32
33impl MeshBooleanRegistry {
34    /// Empty registry.
35    pub const fn new() -> Self {
36        Self {
37            providers: Vec::new(),
38            #[cfg(feature = "parallel")]
39            execution: None,
40        }
41    }
42
43    /// Scope every dispatched provider call to `execution`'s local pool.
44    ///
45    /// This bounds whatever parallelism a provider already does; it does not
46    /// make a single-threaded provider concurrent. With no provider threading
47    /// it costs nothing but the `install` call.
48    #[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    /// Register an implementation. Higher priorities run first.
56    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    /// Register only if the provider passes the shared conformance suite.
64    ///
65    /// ADR 0017 ยง6 makes conformance a *precondition* of registration rather
66    /// than a test someone might remember to run. A provider that violates the
67    /// contract is rejected here, with the failing report, instead of being
68    /// discovered later by a caller receiving wrong geometry.
69    ///
70    /// [`Self::register`] remains available for tests and for deliberately
71    /// partial providers; this is the door production code should use.
72    ///
73    /// # Errors
74    ///
75    /// Returns the report when the provider violates any obligation.
76    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    /// Register a shared trait object.
93    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    /// Registered providers in dispatch order.
101    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            // Budget is checked before dispatch, not after: a provider that
119            // cannot fit the caller's memory bound must never get the chance to
120            // allocate. Treated as retryable so a leaner provider can still run.
121            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    /// Whether `provider`'s declared scratch fits `options`' memory budget.
142    ///
143    /// A per-worker scratch term is charged for the width the call will run
144    /// on: the configured pool's when there is one, since every provider call
145    /// runs inside it, and otherwise the width `options`' parallelism allows
146    /// (#226).
147    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            // Every width runs on a pool of exactly that width, one
157            // included (`CpuExecution::install`, #259).
158            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    /// Run one provider call, inside the configured pool when there is one.
165    ///
166    /// A one-thread context has a pool of one, so the provider sees the
167    /// width the budget was charged for, not the caller's pool.
168    #[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    /// Execute according to device policy with narrow fallback semantics.
190    pub fn boolean(
191        &self,
192        subject: &TriMesh,
193        tool: &TriMesh,
194        operation: BooleanOperator,
195        options: &ExecutionOptions,
196    ) -> GeomResult<BooleanOutcome> {
197        // Admissibility is contract-level and checked before any provider sees
198        // the operands, so dispatch cannot change which inputs are legal.
199        SolidRequirements::Oriented.validate_operands(subject, &[tool])?;
200        // Both operands are input: a provider's scratch grows with the tool
201        // as much as with the subject, so budgeting the subject alone
202        // admitted work up to twice the declared bound (#226).
203        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    /// Subtract many tools through one provider dispatch.
210    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    /// Union many solids through one provider dispatch.
226    ///
227    /// An empty batch is answered without consulting a provider: the union
228    /// of nothing is nothing, and there is no operand to validate or size a
229    /// budget against.
230    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        // Union has no privileged operand, but validation is expressed as
239        // subject-plus-tools. Naming the first solid the subject validates
240        // exactly the same set, in the same way the provider reports it.
241        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    /// Outward-oriented unit cube: the minimal admissible operand.
254    ///
255    /// Dispatch tests need a mesh that passes contract validation, because
256    /// validation now runs before any provider is consulted.
257    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    /// Records the rayon pool width it observes while running.
548    ///
549    /// `current_num_threads` reports the pool the call is INSIDE, so a
550    /// provider dispatched through a scoped registry must see the
551    /// configured width rather than the process-global one.
552    #[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    /// The configured pool must actually wrap the provider call.
589    ///
590    /// Asserting the OBSERVED width, not just that a context was stored:
591    /// a registry that accepted the context and ignored it would pass any
592    /// weaker check. 3 is chosen to differ from this machine core count.
593    #[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    /// A one-thread context runs the provider on a pool of one, not on the
623    /// pool the caller happens to be in (#259). Called from inside a
624    /// 3-worker pool, so falling through to the caller would show.
625    #[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    /// Negative control: without `with_execution` the provider sees the
654    /// ambient pool, so the assertion above is testing the scoping and not
655    /// some constant rayon happens to return.
656    #[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}