Skip to main content

axiolid_dispatch/
visible_faces.rs

1//! Provider registration, validation, fallback, and budget policy for
2//! visible face regions of meshes in an orthographic view.
3//!
4//! The shape, topology and memory checks are those of the hidden-line
5//! registry (`view_checks`). The outcome check has no geometric overlap test:
6//! ring points are rounded, so only the structure of the answer is checked.
7
8use core::cmp::Ordering;
9use std::sync::Arc;
10
11use axiolid_contracts::{BackendId, ExecutionOptions, GeomError, Operation};
12use axiolid_core::Point2;
13use axiolid_mesh_hidden_lines_contract::{
14    conformance::{
15        faces::{FaceConformanceFailure, FaceConformanceReport, FaceConformanceSuite},
16        perspective::{
17            PerspectiveConformanceFailure, PerspectiveConformanceReport,
18            PerspectiveConformanceSuite,
19        },
20    },
21    FaceGroups, FaceSource, HiddenLineError, HiddenLineMesh, MeshRole, MeshVisibleFaces,
22    ViewProjection, VisibleFaceOutcome, VisibleFaceRequest,
23};
24
25use crate::device::matches_device;
26use crate::view_checks::{
27    scratch_fits, validate_group_lengths, validate_meshes, validate_region, validate_topology,
28    validate_view, SharedLimits,
29};
30
31const LABEL: &str = "visible-face";
32
33#[derive(Debug, Clone)]
34struct RegisteredVisibleFaces {
35    priority: i32,
36    provider: Arc<dyn MeshVisibleFaces>,
37}
38
39/// Ordered executable visible-face providers.
40///
41/// Only unsupported/unavailable providers permit fallback. Geometry,
42/// degeneracy, budget, cancellation, and contract errors fail immediately.
43/// A refusal caused by one input mesh names it
44/// ([`HiddenLineError::mesh`]); a refusal of the whole request does not.
45#[derive(Debug, Clone, Default)]
46pub struct MeshVisibleFacesRegistry {
47    providers: Vec<RegisteredVisibleFaces>,
48}
49
50impl MeshVisibleFacesRegistry {
51    /// Empty registry.
52    pub const fn new() -> Self {
53        Self {
54            providers: Vec::new(),
55        }
56    }
57
58    /// Register an implementation. Higher priorities run first.
59    pub fn register<P>(&mut self, priority: i32, provider: P)
60    where
61        P: MeshVisibleFaces + 'static,
62    {
63        self.register_arc(priority, Arc::new(provider));
64    }
65
66    /// Validate a provider with the shared conformance suite before registration.
67    ///
68    /// A provider that claims perspective views (`supports_projection`) is
69    /// also run through the perspective suite, and a failure of it is
70    /// reported as a `ReturnedError` of the case `perspective claim`, so a
71    /// claim is never routed to unverified (ADR 0107).
72    pub fn register_conformant<P>(
73        &mut self,
74        priority: i32,
75        provider: P,
76    ) -> Result<(), Box<FaceConformanceReport>>
77    where
78        P: MeshVisibleFaces + 'static,
79    {
80        let mut report = FaceConformanceSuite::run(&provider);
81        if provider.supports_projection(ViewProjection::Perspective) {
82            let perspective = PerspectiveConformanceSuite::run_faces(&provider);
83            if !perspective.is_success() {
84                report.failures.push(FaceConformanceFailure::ReturnedError {
85                    case: "perspective claim",
86                    error: HiddenLineError::request(GeomError::InvalidInput(format!(
87                        "perspective suite: {perspective}"
88                    ))),
89                });
90            }
91        }
92        if !report.is_success() {
93            return Err(Box::new(report));
94        }
95        self.register(priority, provider);
96        Ok(())
97    }
98
99    /// Validate a provider with the shared suite and the perspective suite
100    /// before registration (ADR 0107); see
101    /// [`MeshHiddenLinesRegistry::register_perspective_conformant`](crate::MeshHiddenLinesRegistry::register_perspective_conformant).
102    pub fn register_perspective_conformant<P>(
103        &mut self,
104        priority: i32,
105        provider: P,
106    ) -> Result<(), Box<PerspectiveConformanceReport>>
107    where
108        P: MeshVisibleFaces + 'static,
109    {
110        let mut report = PerspectiveConformanceSuite::run_faces(&provider);
111        report.failures.extend(
112            FaceConformanceSuite::run(&provider)
113                .failures
114                .into_iter()
115                .map(PerspectiveConformanceFailure::VisibleFacesBase),
116        );
117        if !report.is_success() {
118            return Err(Box::new(report));
119        }
120        self.register(priority, provider);
121        Ok(())
122    }
123
124    /// Register a shared implementation. Higher priorities run first.
125    pub fn register_arc(&mut self, priority: i32, provider: Arc<dyn MeshVisibleFaces>) {
126        self.providers
127            .push(RegisteredVisibleFaces { priority, provider });
128        self.providers
129            .sort_by_key(|entry| std::cmp::Reverse(entry.priority));
130    }
131
132    /// Registered providers in dispatch order.
133    pub fn providers(&self) -> impl Iterator<Item = &dyn MeshVisibleFaces> {
134        self.providers.iter().map(|entry| entry.provider.as_ref())
135    }
136
137    /// Validate and execute one visible-face request.
138    pub fn visible_faces(
139        &self,
140        request: &VisibleFaceRequest<'_>,
141        options: &ExecutionOptions,
142    ) -> Result<VisibleFaceOutcome, HiddenLineError> {
143        options.check_cancelled()?;
144        validate_request_shape(request, options)?;
145
146        let mut has_matching_provider = false;
147        let mut has_budgeted_provider = false;
148        for entry in &self.providers {
149            let descriptor = entry.provider.descriptor();
150            if matches_device(options.device(), descriptor.id, descriptor.target)
151                && entry.provider.supports_projection(request.view.projection)
152            {
153                has_matching_provider = true;
154                has_budgeted_provider |= scratch_fits(
155                    entry.provider.scratch_requirement(),
156                    request.meshes,
157                    options,
158                );
159            }
160        }
161        if !has_matching_provider {
162            return Err(unsupported());
163        }
164        if !has_budgeted_provider {
165            return Err(GeomError::BudgetExceeded { resource: "memory" }.into());
166        }
167
168        // The allocating topology audit happens only after at least one provider
169        // and the audit itself have been admitted by the memory budget.
170        validate_topology(request.meshes, None, options)?;
171
172        let mut last_retryable = None;
173        for entry in &self.providers {
174            let descriptor = entry.provider.descriptor();
175            if !matches_device(options.device(), descriptor.id, descriptor.target)
176                || !entry.provider.supports_projection(request.view.projection)
177                || !scratch_fits(
178                    entry.provider.scratch_requirement(),
179                    request.meshes,
180                    options,
181                )
182            {
183                continue;
184            }
185            let result = entry.provider.visible_faces(request, options);
186            if let Err(HiddenLineError { mesh: Some(id), .. }) = &result {
187                if !request.meshes.iter().any(|entry| entry.id == *id) {
188                    return Err(contract_violation(
189                        descriptor.id,
190                        "refusal names a mesh that is not in the request",
191                    ));
192                }
193            }
194            match result {
195                Ok(outcome) => {
196                    validate_outcome(descriptor.id, request, &outcome)?;
197                    return Ok(outcome);
198                }
199                Err(
200                    error @ HiddenLineError {
201                        error: GeomError::Unsupported { .. } | GeomError::Unavailable { .. },
202                        ..
203                    },
204                ) => {
205                    last_retryable = Some(error);
206                }
207                Err(error) => return Err(error),
208            }
209        }
210
211        Err(last_retryable.unwrap_or_else(unsupported))
212    }
213}
214
215fn unsupported() -> HiddenLineError {
216    GeomError::Unsupported {
217        backend: BackendId::new("mesh-visible-faces-registry"),
218        operation: Operation::MeshVisibleFaces,
219    }
220    .into()
221}
222
223fn contract_violation(backend: BackendId, detail: &str) -> HiddenLineError {
224    GeomError::BackendContractViolation {
225        backend,
226        detail: detail.into(),
227    }
228    .into()
229}
230
231fn validate_request_shape(
232    request: &VisibleFaceRequest<'_>,
233    options: &ExecutionOptions,
234) -> Result<(), HiddenLineError> {
235    let limits = request.limits;
236    validate_meshes(
237        request.meshes,
238        &SharedLimits {
239            label: LABEL,
240            max_meshes: limits.max_meshes,
241            max_source_vertices: limits.max_source_vertices,
242            max_source_triangles: limits.max_source_triangles,
243            meshes_resource: "visible-face meshes",
244            vertices_resource: "visible-face source vertices",
245            triangles_resource: "visible-face source triangles",
246        },
247    )?;
248    validate_view(&request.view, options.tolerance().angular(), LABEL)?;
249    validate_region(request.region, LABEL)?;
250    validate_group_lengths(request.meshes, LABEL)?;
251    Ok(())
252}
253
254fn point_order(a: &Point2, b: &Point2) -> Ordering {
255    a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))
256}
257
258fn sequence_order(a: &[Point2], b: &[Point2]) -> Ordering {
259    a.iter()
260        .zip(b)
261        .map(|(p, q)| point_order(p, q))
262        .find(|o| o.is_ne())
263        .unwrap_or_else(|| a.len().cmp(&b.len()))
264}
265
266fn validate_outcome(
267    backend: BackendId,
268    request: &VisibleFaceRequest<'_>,
269    outcome: &VisibleFaceOutcome,
270) -> Result<(), HiddenLineError> {
271    let violation = |detail: &str| contract_violation(backend, detail);
272    let limits = request.limits;
273    if outcome.view != request.view {
274        return Err(violation("provider changed the requested view"));
275    }
276    if outcome.regions.len() > limits.max_regions {
277        return Err(GeomError::BudgetExceeded {
278            resource: "visible-face regions",
279        }
280        .into());
281    }
282    let mut vertices = 0usize;
283    let mut caps = 0usize;
284    let mut previous: Option<(usize, FaceSource, &[Point2])> = None;
285    for region in &outcome.regions {
286        let Some(position) = request
287            .meshes
288            .iter()
289            .position(|entry| entry.id == region.mesh)
290        else {
291            return Err(violation("region names a mesh that is not in the request"));
292        };
293        let entry: &HiddenLineMesh<'_> = &request.meshes[position];
294        for ring in std::iter::once(&region.outer).chain(&region.holes) {
295            if ring.len() < 3 {
296                return Err(violation("region ring has fewer than three points"));
297            }
298            if ring.iter().any(|p| !p.is_finite()) {
299                return Err(violation("region ring contains a non-finite point"));
300            }
301            if ring
302                .iter()
303                .zip(ring.iter().cycle().skip(1))
304                .any(|(a, b)| a == b)
305            {
306                return Err(violation("region ring repeats a point in a row"));
307            }
308            vertices = vertices.saturating_add(ring.len());
309        }
310        let count = entry.mesh.triangle_count();
311        if region.triangles.windows(2).any(|w| w[0] >= w[1]) {
312            return Err(violation("region triangles are not strictly ascending"));
313        }
314        if region.triangles.iter().any(|&t| t as usize >= count) {
315            return Err(violation("region triangle is not in its mesh"));
316        }
317        match region.source {
318            FaceSource::Group(group) => {
319                // A planar group depends on the geometry, which the registry
320                // does not recompute: it checks the structure only. The id
321                // is the smallest triangle of the group, so it is a triangle
322                // of the mesh and no later than any triangle it lists.
323                let outside = if entry.groups == FaceGroups::Planar {
324                    group.0 as usize >= count || region.triangles.iter().any(|&t| group.0 > t)
325                } else {
326                    region
327                        .triangles
328                        .iter()
329                        .any(|&t| entry.groups.of(t as usize) != group)
330                };
331                if outside {
332                    return Err(violation("region triangle is not in the region's group"));
333                }
334            }
335            FaceSource::Cap => {
336                if entry.role != MeshRole::Solid || request.view.near.is_none() {
337                    return Err(violation(
338                        "cap region of a mesh that is not a solid cut by a near plane",
339                    ));
340                }
341                if !region.triangles.is_empty() {
342                    return Err(violation("cap region lists triangles"));
343                }
344                caps += 1;
345            }
346            _ => return Err(violation("region has an unknown face source")),
347        }
348        if let Some((last_position, last_source, last_outer)) = previous {
349            let order = last_position
350                .cmp(&position)
351                .then(last_source.cmp(&region.source))
352                .then_with(|| sequence_order(last_outer, &region.outer));
353            if order.is_ge() {
354                return Err(violation(
355                    "regions are not ordered by mesh, source and outer ring",
356                ));
357            }
358        }
359        previous = Some((position, region.source, &region.outer));
360    }
361    if vertices > limits.max_region_vertices {
362        return Err(GeomError::BudgetExceeded {
363            resource: "visible-face region vertices",
364        }
365        .into());
366    }
367    let evidence = &outcome.evidence;
368    if evidence.candidate_pairs > limits.max_candidate_pairs {
369        return Err(GeomError::BudgetExceeded {
370            resource: "visible-face candidate pairs",
371        }
372        .into());
373    }
374    let triangles = request
375        .meshes
376        .iter()
377        .try_fold(0usize, |t, m| t.checked_add(m.mesh.triangle_count()));
378    if !evidence.is_derived_from_input_mesh()
379        || evidence.source_meshes != request.meshes.len()
380        || Some(evidence.source_triangles) != triangles
381        || evidence.regions != outcome.regions.len()
382        || evidence.cap_regions != caps
383    {
384        return Err(violation(
385            "visible-face evidence does not match input/output counts",
386        ));
387    }
388    Ok(())
389}