Skip to main content

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, ExecutionOptions, GeomError, GeomResult, Operation, ScratchRequirement,
7};
8use axiolid_core::Frame3;
9use axiolid_mesh::{audit_mesh_scratch_bytes, try_audit_mesh, TriMesh};
10use axiolid_mesh_contracts::SolidRequirements;
11use axiolid_mesh_section_contract::{
12    conformance::{
13        ConformanceReport, ConformanceSuite, RegionConformanceFailure, RegionConformanceReport,
14        RegionConformanceSuite,
15    },
16    MeshPlaneSection, PlaneSection, SectionLimits, SectionOutcome, SectionRequest,
17};
18
19use crate::device::matches_device;
20use crate::frame::validate_frame;
21use crate::section_request::{
22    validate_cut_request, validate_plane_section, validate_request_topology,
23};
24
25#[derive(Debug, Clone)]
26struct RegisteredSection {
27    priority: i32,
28    provider: Arc<dyn MeshPlaneSection>,
29}
30
31/// Ordered executable mesh plane-section providers.
32///
33/// Only unsupported/unavailable providers permit fallback. Geometry,
34/// degeneracy, budget, cancellation, and contract errors fail immediately.
35#[derive(Debug, Clone, Default)]
36pub struct MeshPlaneSectionRegistry {
37    providers: Vec<RegisteredSection>,
38}
39
40impl MeshPlaneSectionRegistry {
41    /// Empty registry.
42    pub const fn new() -> Self {
43        Self {
44            providers: Vec::new(),
45        }
46    }
47
48    /// Register an implementation. Higher priorities run first.
49    pub fn register<P>(&mut self, priority: i32, provider: P)
50    where
51        P: MeshPlaneSection + 'static,
52    {
53        self.register_arc(priority, Arc::new(provider));
54    }
55
56    /// Validate a provider with the shared conformance suite before registration.
57    pub fn register_conformant<P>(
58        &mut self,
59        priority: i32,
60        provider: P,
61    ) -> Result<(), Box<ConformanceReport>>
62    where
63        P: MeshPlaneSection + 'static,
64    {
65        let report = ConformanceSuite::run(&provider);
66        if !report.is_success() {
67            return Err(Box::new(report));
68        }
69        self.register(priority, provider);
70        Ok(())
71    }
72
73    /// Validate a provider with both conformance suites before registration:
74    /// the base suite, then, when the provider claims `section_request`, the
75    /// region suite. A provider that does not claim the method passes on the
76    /// base suite alone. Failures of either suite are in the report.
77    pub fn register_request_conformant<P>(
78        &mut self,
79        priority: i32,
80        provider: P,
81    ) -> Result<(), Box<RegionConformanceReport>>
82    where
83        P: MeshPlaneSection + 'static,
84    {
85        let mut report = RegionConformanceSuite::run(&provider);
86        // The region suite runs the base suite itself once the method is
87        // claimed; otherwise the base failures are added here.
88        if !report.claimed {
89            report.failures.extend(
90                ConformanceSuite::run(&provider)
91                    .failures
92                    .into_iter()
93                    .map(RegionConformanceFailure::Base),
94            );
95        }
96        if !report.is_success() {
97            return Err(Box::new(report));
98        }
99        self.register(priority, provider);
100        Ok(())
101    }
102
103    /// Register a shared implementation. Higher priorities run first.
104    pub fn register_arc(&mut self, priority: i32, provider: Arc<dyn MeshPlaneSection>) {
105        self.providers
106            .push(RegisteredSection { priority, provider });
107        self.providers
108            .sort_by_key(|entry| std::cmp::Reverse(entry.priority));
109    }
110
111    /// Registered providers in dispatch order.
112    pub fn providers(&self) -> impl Iterator<Item = &dyn MeshPlaneSection> {
113        self.providers.iter().map(|entry| entry.provider.as_ref())
114    }
115
116    /// Validate and execute one plane-section request.
117    pub fn section(
118        &self,
119        mesh: &TriMesh,
120        frame: Frame3,
121        limits: SectionLimits,
122        options: &ExecutionOptions,
123    ) -> GeomResult<SectionOutcome> {
124        options.check_cancelled()?;
125        validate_source_shape(mesh, frame, limits, options)?;
126
127        let mut has_matching_provider = false;
128        let mut has_budgeted_provider = false;
129        for entry in &self.providers {
130            let descriptor = entry.provider.descriptor();
131            if matches_device(options.device(), descriptor.id, descriptor.target) {
132                has_matching_provider = true;
133                has_budgeted_provider |=
134                    section_scratch_fits(entry.provider.scratch_requirement(), mesh, options);
135            }
136        }
137        if !has_matching_provider {
138            return Err(GeomError::Unsupported {
139                backend: BackendId::new("mesh-section-registry"),
140                operation: Operation::MeshPlaneSection,
141            });
142        }
143        if !has_budgeted_provider {
144            return Err(GeomError::BudgetExceeded { resource: "memory" });
145        }
146
147        // The allocating topology audit happens only after at least one provider
148        // and the audit itself have been admitted by the memory budget.
149        validate_source_topology(mesh, options)?;
150
151        let mut last_retryable = None;
152        for entry in &self.providers {
153            let descriptor = entry.provider.descriptor();
154            if !matches_device(options.device(), descriptor.id, descriptor.target)
155                || !section_scratch_fits(entry.provider.scratch_requirement(), mesh, options)
156            {
157                continue;
158            }
159            match entry.provider.section(mesh, frame, limits, options) {
160                Ok(outcome) => {
161                    validate_outcome(descriptor.id, mesh, frame, limits, &outcome)?;
162                    return Ok(outcome);
163                }
164                Err(error @ (GeomError::Unsupported { .. } | GeomError::Unavailable { .. })) => {
165                    last_retryable = Some(error);
166                }
167                Err(error) => return Err(error),
168            }
169        }
170
171        Err(last_retryable.unwrap_or(GeomError::Unsupported {
172            backend: BackendId::new("mesh-section-registry"),
173            operation: Operation::MeshPlaneSection,
174        }))
175    }
176
177    /// Validate and execute one request that reports faces lying in the
178    /// plane as regions (ADR 0091).
179    ///
180    /// The source must be a closed, consistently wound, outward oriented
181    /// two-manifold, as for [`section`](Self::section), when the request's
182    /// role is `SectionRole::Solid`. For a `SectionRole::Sheet` it may be
183    /// open, but must be consistently wound, non-degenerate and
184    /// two-manifold. A provider without
185    /// `section_request` refuses with `Unsupported`, which falls back to the
186    /// next provider; every other error fails immediately.
187    pub fn section_request(
188        &self,
189        mesh: &TriMesh,
190        request: &SectionRequest,
191        options: &ExecutionOptions,
192    ) -> GeomResult<PlaneSection> {
193        options.check_cancelled()?;
194        validate_source_shape(mesh, request.frame, request.limits, options)?;
195        validate_cut_request(request)?;
196
197        let mut has_matching_provider = false;
198        let mut has_budgeted_provider = false;
199        for entry in &self.providers {
200            let descriptor = entry.provider.descriptor();
201            if matches_device(options.device(), descriptor.id, descriptor.target) {
202                has_matching_provider = true;
203                has_budgeted_provider |= section_scratch_fits(
204                    entry.provider.request_scratch_requirement(),
205                    mesh,
206                    options,
207                );
208            }
209        }
210        if !has_matching_provider {
211            return Err(GeomError::Unsupported {
212                backend: BackendId::new("mesh-section-registry"),
213                operation: Operation::MeshPlaneSection,
214            });
215        }
216        if !has_budgeted_provider {
217            return Err(GeomError::BudgetExceeded { resource: "memory" });
218        }
219
220        let boundary_edges = validate_request_topology(mesh, request.role, options)?;
221
222        let mut last_retryable = None;
223        for entry in &self.providers {
224            let descriptor = entry.provider.descriptor();
225            if !matches_device(options.device(), descriptor.id, descriptor.target)
226                || !section_scratch_fits(
227                    entry.provider.request_scratch_requirement(),
228                    mesh,
229                    options,
230                )
231            {
232                continue;
233            }
234            match entry.provider.section_request(mesh, request, options) {
235                // A provider from before cut regions ignores the request and
236                // answers without them: it does not support the request.
237                Ok(section) if request.cut_regions.is_some() && section.cut_regions.is_none() => {
238                    last_retryable = Some(GeomError::Unsupported {
239                        backend: descriptor.id,
240                        operation: Operation::MeshPlaneSection,
241                    });
242                }
243                Ok(section) => {
244                    validate_plane_section(descriptor.id, mesh, request, boundary_edges, &section)?;
245                    return Ok(section);
246                }
247                Err(error @ (GeomError::Unsupported { .. } | GeomError::Unavailable { .. })) => {
248                    last_retryable = Some(error);
249                }
250                Err(error) => return Err(error),
251            }
252        }
253
254        Err(last_retryable.unwrap_or(GeomError::Unsupported {
255            backend: BackendId::new("mesh-section-registry"),
256            operation: Operation::MeshPlaneSection,
257        }))
258    }
259}
260
261fn validate_source_shape(
262    mesh: &TriMesh,
263    frame: Frame3,
264    limits: SectionLimits,
265    options: &ExecutionOptions,
266) -> GeomResult<()> {
267    if mesh.positions.len() > limits.max_source_vertices {
268        return Err(GeomError::BudgetExceeded {
269            resource: "section source vertices",
270        });
271    }
272    if mesh.triangle_count() > limits.max_source_triangles {
273        return Err(GeomError::BudgetExceeded {
274            resource: "section source triangles",
275        });
276    }
277    if mesh.indices.is_empty() {
278        return Err(GeomError::InvalidInput(
279            "section source mesh has no triangles".into(),
280        ));
281    }
282    if !mesh.indices.len().is_multiple_of(3) {
283        return Err(GeomError::InvalidInput(
284            "section source mesh index count is not a multiple of three".into(),
285        ));
286    }
287    validate_frame(frame, options.tolerance().angular(), "section frame")?;
288
289    Ok(())
290}
291
292pub(crate) fn validate_source_topology(
293    mesh: &TriMesh,
294    options: &ExecutionOptions,
295) -> GeomResult<()> {
296    let health = try_audit_mesh(mesh, options.tolerance())
297        .map_err(|_| GeomError::BudgetExceeded { resource: "memory" })?;
298    if !health.is_closed_two_manifold() {
299        return Err(GeomError::NotManifold(format!(
300            "plane section requires a closed consistently wound two-manifold mesh; \
301             boundary={}, non_manifold={}, inconsistent_winding={}, degenerate={}",
302            health.boundary_edges,
303            health.non_manifold_edges,
304            health.inconsistent_winding_edges,
305            health.degenerate_triangles
306        )));
307    }
308    SolidRequirements::Oriented.validate(mesh, "section source")?;
309    Ok(())
310}
311
312fn section_scratch_fits(
313    provider: ScratchRequirement,
314    mesh: &TriMesh,
315    options: &ExecutionOptions,
316) -> bool {
317    let Some(budget) = options.memory_budget_bytes() else {
318        return true;
319    };
320    let Some(audit_bytes) = audit_mesh_scratch_bytes(mesh.triangle_count()) else {
321        return false;
322    };
323    let elements = mesh.positions.len().max(mesh.triangle_count());
324    let Some(provider_bytes) = provider.upper_bound_bytes(elements) else {
325        return false;
326    };
327    // Audit and provider phases are sequential, so their peak is the maximum,
328    // not their sum. Output storage is outside the scratch budget by contract.
329    audit_bytes.max(provider_bytes) <= budget
330}
331
332fn validate_outcome(
333    backend: BackendId,
334    mesh: &TriMesh,
335    frame: Frame3,
336    limits: SectionLimits,
337    outcome: &SectionOutcome,
338) -> GeomResult<()> {
339    let violation = |detail: &str| GeomError::BackendContractViolation {
340        backend,
341        detail: detail.into(),
342    };
343    if outcome.frame != frame {
344        return Err(violation("provider changed the requested section frame"));
345    }
346    if outcome.contours.len() > limits.max_contours {
347        return Err(GeomError::BudgetExceeded {
348            resource: "section contours",
349        });
350    }
351    let mut vertices = 0usize;
352    for contour in &outcome.contours {
353        if contour.points.len() < 3 {
354            return Err(violation(
355                "section contour must be closed with at least three points",
356            ));
357        }
358        if !contour.points.iter().all(|point| point.is_finite()) {
359            return Err(violation("section contour contains a non-finite point"));
360        }
361        for index in 0..contour.points.len() {
362            if contour.points[index] == contour.points[(index + 1) % contour.points.len()] {
363                return Err(violation(
364                    "section contour contains adjacent duplicate points",
365                ));
366            }
367        }
368        vertices = vertices
369            .checked_add(contour.points.len())
370            .ok_or(GeomError::BudgetExceeded {
371                resource: "section output vertices",
372            })?;
373        if vertices > limits.max_output_vertices {
374            return Err(GeomError::BudgetExceeded {
375                resource: "section output vertices",
376            });
377        }
378    }
379    if outcome.evidence.source_triangles != mesh.triangle_count()
380        || outcome.evidence.output_vertices != vertices
381        || outcome.evidence.output_contours != outcome.contours.len()
382        || !outcome.evidence.is_derived_from_input_mesh()
383    {
384        return Err(violation(
385            "section evidence does not match input/output counts",
386        ));
387    }
388    Ok(())
389}