axiolid_dispatch/
section.rs

1//! Provider registration, validation, fallback, and budget policy for mesh sections.
2
3use std::sync::Arc;
4
5use axiolid_contracts::{
6    BackendId, DevicePreference, ExecutionOptions, ExecutionTarget, GeomError, GeomResult,
7    Operation, ScratchRequirement,
8};
9use axiolid_core::{Frame3, SpaceFrame, Tolerance};
10use axiolid_mesh::{audit_mesh_scratch_bytes, try_audit_mesh, TriMesh};
11use axiolid_mesh_contracts::SolidRequirements;
12use axiolid_mesh_section_contract::{
13    conformance::{ConformanceReport, ConformanceSuite},
14    MeshPlaneSection, SectionLimits, SectionOutcome,
15};
16
17#[derive(Debug, Clone)]
18struct RegisteredSection {
19    priority: i32,
20    provider: Arc<dyn MeshPlaneSection>,
21}
22
23/// Ordered executable mesh plane-section providers.
24///
25/// Only unsupported/unavailable providers permit fallback. Geometry,
26/// degeneracy, budget, cancellation, and contract errors fail immediately.
27#[derive(Debug, Clone, Default)]
28pub struct MeshPlaneSectionRegistry {
29    providers: Vec<RegisteredSection>,
30}
31
32impl MeshPlaneSectionRegistry {
33    /// Empty registry.
34    pub const fn new() -> Self {
35        Self {
36            providers: Vec::new(),
37        }
38    }
39
40    /// Register an implementation. Higher priorities run first.
41    pub fn register<P>(&mut self, priority: i32, provider: P)
42    where
43        P: MeshPlaneSection + 'static,
44    {
45        self.register_arc(priority, Arc::new(provider));
46    }
47
48    /// Validate a provider with the shared conformance suite before registration.
49    pub fn register_conformant<P>(
50        &mut self,
51        priority: i32,
52        provider: P,
53    ) -> Result<(), Box<ConformanceReport>>
54    where
55        P: MeshPlaneSection + 'static,
56    {
57        let report = ConformanceSuite::run(&provider);
58        if !report.is_success() {
59            return Err(Box::new(report));
60        }
61        self.register(priority, provider);
62        Ok(())
63    }
64
65    /// Register a shared implementation. Higher priorities run first.
66    pub fn register_arc(&mut self, priority: i32, provider: Arc<dyn MeshPlaneSection>) {
67        self.providers
68            .push(RegisteredSection { priority, provider });
69        self.providers
70            .sort_by_key(|entry| std::cmp::Reverse(entry.priority));
71    }
72
73    /// Registered providers in dispatch order.
74    pub fn providers(&self) -> impl Iterator<Item = &dyn MeshPlaneSection> {
75        self.providers.iter().map(|entry| entry.provider.as_ref())
76    }
77
78    /// Validate and execute one plane-section request.
79    pub fn section(
80        &self,
81        mesh: &TriMesh,
82        frame: Frame3,
83        limits: SectionLimits,
84        options: &ExecutionOptions,
85    ) -> GeomResult<SectionOutcome> {
86        options.check_cancelled()?;
87        validate_source_shape(mesh, frame, limits, options)?;
88
89        let mut has_matching_provider = false;
90        let mut has_budgeted_provider = false;
91        for entry in &self.providers {
92            let descriptor = entry.provider.descriptor();
93            if matches_device(options.device(), descriptor.id, descriptor.target) {
94                has_matching_provider = true;
95                has_budgeted_provider |=
96                    section_scratch_fits(entry.provider.scratch_requirement(), mesh, options);
97            }
98        }
99        if !has_matching_provider {
100            return Err(GeomError::Unsupported {
101                backend: BackendId::new("mesh-section-registry"),
102                operation: Operation::MeshPlaneSection,
103            });
104        }
105        if !has_budgeted_provider {
106            return Err(GeomError::BudgetExceeded { resource: "memory" });
107        }
108
109        // The allocating topology audit happens only after at least one provider
110        // and the audit itself have been admitted by the memory budget.
111        validate_source_topology(mesh, options)?;
112
113        let mut last_retryable = None;
114        for entry in &self.providers {
115            let descriptor = entry.provider.descriptor();
116            if !matches_device(options.device(), descriptor.id, descriptor.target)
117                || !section_scratch_fits(entry.provider.scratch_requirement(), mesh, options)
118            {
119                continue;
120            }
121            match entry.provider.section(mesh, frame, limits, options) {
122                Ok(outcome) => {
123                    validate_outcome(descriptor.id, mesh, frame, limits, &outcome)?;
124                    return Ok(outcome);
125                }
126                Err(error @ (GeomError::Unsupported { .. } | GeomError::Unavailable { .. })) => {
127                    last_retryable = Some(error);
128                }
129                Err(error) => return Err(error),
130            }
131        }
132
133        Err(last_retryable.unwrap_or(GeomError::Unsupported {
134            backend: BackendId::new("mesh-section-registry"),
135            operation: Operation::MeshPlaneSection,
136        }))
137    }
138}
139
140fn validate_source_shape(
141    mesh: &TriMesh,
142    frame: Frame3,
143    limits: SectionLimits,
144    options: &ExecutionOptions,
145) -> GeomResult<()> {
146    if mesh.positions.len() > limits.max_source_vertices {
147        return Err(GeomError::BudgetExceeded {
148            resource: "section source vertices",
149        });
150    }
151    if mesh.triangle_count() > limits.max_source_triangles {
152        return Err(GeomError::BudgetExceeded {
153            resource: "section source triangles",
154        });
155    }
156    if mesh.indices.is_empty() {
157        return Err(GeomError::InvalidInput(
158            "section source mesh has no triangles".into(),
159        ));
160    }
161    validate_frame(frame, options.tolerance().angular())?;
162
163    Ok(())
164}
165
166fn validate_source_topology(mesh: &TriMesh, options: &ExecutionOptions) -> GeomResult<()> {
167    let health = try_audit_mesh(mesh, options.tolerance())
168        .map_err(|_| GeomError::BudgetExceeded { resource: "memory" })?;
169    if !health.is_closed_two_manifold() {
170        return Err(GeomError::NotManifold(format!(
171            "plane section requires a closed consistently wound two-manifold mesh; \
172             boundary={}, non_manifold={}, inconsistent_winding={}, degenerate={}",
173            health.boundary_edges,
174            health.non_manifold_edges,
175            health.inconsistent_winding_edges,
176            health.degenerate_triangles
177        )));
178    }
179    SolidRequirements::Oriented.validate(mesh, "section source")?;
180    Ok(())
181}
182
183fn section_scratch_fits(
184    provider: ScratchRequirement,
185    mesh: &TriMesh,
186    options: &ExecutionOptions,
187) -> bool {
188    let Some(budget) = options.memory_budget_bytes() else {
189        return true;
190    };
191    let Some(audit_bytes) = audit_mesh_scratch_bytes(mesh.triangle_count()) else {
192        return false;
193    };
194    let elements = mesh.positions.len().max(mesh.triangle_count());
195    let Some(provider_bytes) = provider.upper_bound_bytes(elements) else {
196        return false;
197    };
198    // Audit and provider phases are sequential, so their peak is the maximum,
199    // not their sum. Output storage is outside the scratch budget by contract.
200    audit_bytes.max(provider_bytes) <= budget
201}
202
203fn validate_frame(frame: Frame3, angular_tolerance: f64) -> GeomResult<()> {
204    // Structural frame validity cannot be relaxed into zero/scaled axes by a
205    // coarse geometric tolerance, so the caller's angular tolerance is capped
206    // here before it reaches the shared validator. Tighter caller tolerances
207    // still apply; looser ones do not.
208    let limit = angular_tolerance.clamp(128.0 * f64::EPSILON, 1.0e-6);
209    let tolerance = Tolerance::new(limit, limit)
210        .map_err(|_| GeomError::InvalidInput("section frame tolerance is invalid".into()))?;
211    SpaceFrame::new(frame.origin, frame.x, frame.y, frame.z, tolerance)
212        .map(|_| ())
213        .map_err(|error| GeomError::InvalidInput(format!("section frame is invalid: {error}")))
214}
215
216fn validate_outcome(
217    backend: BackendId,
218    mesh: &TriMesh,
219    frame: Frame3,
220    limits: SectionLimits,
221    outcome: &SectionOutcome,
222) -> GeomResult<()> {
223    let violation = |detail: &str| GeomError::BackendContractViolation {
224        backend,
225        detail: detail.into(),
226    };
227    if outcome.frame != frame {
228        return Err(violation("provider changed the requested section frame"));
229    }
230    if outcome.contours.len() > limits.max_contours {
231        return Err(GeomError::BudgetExceeded {
232            resource: "section contours",
233        });
234    }
235    let mut vertices = 0usize;
236    for contour in &outcome.contours {
237        if contour.points.len() < 3 {
238            return Err(violation(
239                "section contour must be closed with at least three points",
240            ));
241        }
242        if !contour.points.iter().all(|point| point.is_finite()) {
243            return Err(violation("section contour contains a non-finite point"));
244        }
245        for index in 0..contour.points.len() {
246            if contour.points[index] == contour.points[(index + 1) % contour.points.len()] {
247                return Err(violation(
248                    "section contour contains adjacent duplicate points",
249                ));
250            }
251        }
252        vertices = vertices
253            .checked_add(contour.points.len())
254            .ok_or(GeomError::BudgetExceeded {
255                resource: "section output vertices",
256            })?;
257        if vertices > limits.max_output_vertices {
258            return Err(GeomError::BudgetExceeded {
259                resource: "section output vertices",
260            });
261        }
262    }
263    if outcome.evidence.source_triangles != mesh.triangle_count()
264        || outcome.evidence.output_vertices != vertices
265        || outcome.evidence.output_contours != outcome.contours.len()
266        || !outcome.evidence.is_derived_from_input_mesh()
267    {
268        return Err(violation(
269            "section evidence does not match input/output counts",
270        ));
271    }
272    Ok(())
273}
274
275fn matches_device(preference: DevicePreference, id: BackendId, target: ExecutionTarget) -> bool {
276    match preference {
277        DevicePreference::Auto => true,
278        DevicePreference::Cpu => matches!(
279            target,
280            ExecutionTarget::PortableCpu | ExecutionTarget::OptimizedCpu
281        ),
282        DevicePreference::Gpu => matches!(target, ExecutionTarget::Gpu),
283        DevicePreference::Backend(required) => required == id,
284    }
285}