Skip to main content

axiolid_dispatch/
hidden_lines.rs

1//! Provider registration, validation, fallback, and budget policy for
2//! hidden-line drawings, orthographic and perspective.
3
4use std::sync::Arc;
5
6use axiolid_contracts::{BackendId, ExecutionOptions, GeomError, Operation};
7use axiolid_mesh::audit_mesh_scratch_bytes;
8use axiolid_mesh_hidden_lines_contract::{
9    conformance::{
10        perspective::{
11            PerspectiveConformanceFailure, PerspectiveConformanceReport,
12            PerspectiveConformanceSuite,
13        },
14        ConformanceFailure, ConformanceReport, ConformanceSuite,
15    },
16    EdgeKind, HiddenLineError, HiddenLineOutcome, HiddenLineRequest, MeshHiddenLines, RefusedMesh,
17    ViewProjection, Visibility,
18};
19
20use crate::device::matches_device;
21use crate::view_checks::{
22    invalid, mesh_verdict, scratch_fits, source_totals, validate_meshes, validate_region,
23    validate_topology, validate_view, SharedLimits,
24};
25
26#[derive(Debug, Clone)]
27struct RegisteredHiddenLines {
28    priority: i32,
29    provider: Arc<dyn MeshHiddenLines>,
30}
31
32/// Ordered executable hidden-line providers.
33///
34/// Only unsupported/unavailable providers permit fallback. Geometry,
35/// degeneracy, budget, cancellation, and contract errors fail immediately.
36/// A refusal caused by one input mesh names it
37/// ([`HiddenLineError::mesh`]); a refusal of the whole request does not.
38/// [`hidden_lines`](Self::hidden_lines) names the first refused mesh only; a
39/// caller that drops refused meshes and draws the rest learns all of them in
40/// one call from [`audit`](Self::audit).
41#[derive(Debug, Clone, Default)]
42pub struct MeshHiddenLinesRegistry {
43    providers: Vec<RegisteredHiddenLines>,
44}
45
46impl MeshHiddenLinesRegistry {
47    /// Empty registry.
48    pub const fn new() -> Self {
49        Self {
50            providers: Vec::new(),
51        }
52    }
53
54    /// Register an implementation. Higher priorities run first.
55    pub fn register<P>(&mut self, priority: i32, provider: P)
56    where
57        P: MeshHiddenLines + 'static,
58    {
59        self.register_arc(priority, Arc::new(provider));
60    }
61
62    /// Validate a provider with the shared conformance suite before registration.
63    ///
64    /// A provider that claims perspective views (`supports_projection`) is
65    /// also run through the perspective suite, and a failure of it is
66    /// reported as a `ReturnedError` of the case `perspective claim`, so a
67    /// claim is never routed to unverified (ADR 0107).
68    pub fn register_conformant<P>(
69        &mut self,
70        priority: i32,
71        provider: P,
72    ) -> Result<(), Box<ConformanceReport>>
73    where
74        P: MeshHiddenLines + 'static,
75    {
76        let mut report = ConformanceSuite::run(&provider);
77        if provider.supports_projection(ViewProjection::Perspective) {
78            let perspective = PerspectiveConformanceSuite::run(&provider);
79            if !perspective.is_success() {
80                report.failures.push(ConformanceFailure::ReturnedError {
81                    case: "perspective claim",
82                    error: HiddenLineError::request(GeomError::InvalidInput(format!(
83                        "perspective suite: {perspective}"
84                    ))),
85                });
86            }
87        }
88        if !report.is_success() {
89            return Err(Box::new(report));
90        }
91        self.register(priority, provider);
92        Ok(())
93    }
94
95    /// Validate a provider with the shared suite and the perspective suite
96    /// before registration (ADR 0107). The claim is the provider's
97    /// `supports_projection(ViewProjection::Perspective)`: a provider that does
98    /// not claim it must refuse perspective views by name, one that does must
99    /// pass the perspective fixtures. Failures of either suite are in the
100    /// report.
101    pub fn register_perspective_conformant<P>(
102        &mut self,
103        priority: i32,
104        provider: P,
105    ) -> Result<(), Box<PerspectiveConformanceReport>>
106    where
107        P: MeshHiddenLines + 'static,
108    {
109        let mut report = PerspectiveConformanceSuite::run(&provider);
110        report.failures.extend(
111            ConformanceSuite::run(&provider)
112                .failures
113                .into_iter()
114                .map(PerspectiveConformanceFailure::HiddenLinesBase),
115        );
116        if !report.is_success() {
117            return Err(Box::new(report));
118        }
119        self.register(priority, provider);
120        Ok(())
121    }
122
123    /// Register a shared implementation. Higher priorities run first.
124    pub fn register_arc(&mut self, priority: i32, provider: Arc<dyn MeshHiddenLines>) {
125        self.providers
126            .push(RegisteredHiddenLines { priority, provider });
127        self.providers
128            .sort_by_key(|entry| std::cmp::Reverse(entry.priority));
129    }
130
131    /// Registered providers in dispatch order.
132    pub fn providers(&self) -> impl Iterator<Item = &dyn MeshHiddenLines> {
133        self.providers.iter().map(|entry| entry.provider.as_ref())
134    }
135
136    /// Every input mesh that [`hidden_lines`](Self::hidden_lines) would
137    /// refuse, in request order, without searching.
138    ///
139    /// Runs only the per-mesh audit of shape and topology, through the code
140    /// `hidden_lines` uses, so a mesh is listed exactly when `hidden_lines`
141    /// would refuse a request holding it. An empty list means every mesh
142    /// passes the audit (a provider can still refuse). A caller that leaves
143    /// the listed meshes out and calls `hidden_lines` again draws the rest;
144    /// no substitute drawing is made. A refusal of the whole request (limits,
145    /// frame, memory, cancellation, an empty request, a duplicate id or a mesh
146    /// without triangles) is returned as `Err`, as it is by `hidden_lines`.
147    pub fn audit(
148        &self,
149        request: &HiddenLineRequest<'_>,
150        options: &ExecutionOptions,
151    ) -> Result<Vec<RefusedMesh>, HiddenLineError> {
152        options.check_cancelled()?;
153        validate_request_shape(request, options)?;
154        if let Some(budget) = options.memory_budget_bytes() {
155            let fits = request.meshes.iter().all(|entry| {
156                audit_mesh_scratch_bytes(entry.mesh.triangle_count())
157                    .is_some_and(|bytes| bytes <= budget)
158            });
159            if !fits {
160                return Err(GeomError::BudgetExceeded { resource: "memory" }.into());
161            }
162        }
163        let groups = request.edges.group_boundary.then_some(LABEL);
164        let mut refused = Vec::new();
165        for entry in request.meshes {
166            options.check_cancelled()?;
167            if let Some(error) = mesh_verdict(entry, groups, options)? {
168                refused.push(RefusedMesh::new(entry.id, error));
169            }
170        }
171        Ok(refused)
172    }
173
174    /// Validate and execute one hidden-line request.
175    pub fn hidden_lines(
176        &self,
177        request: &HiddenLineRequest<'_>,
178        options: &ExecutionOptions,
179    ) -> Result<HiddenLineOutcome, HiddenLineError> {
180        options.check_cancelled()?;
181        validate_request_shape(request, options)?;
182
183        let mut has_matching_provider = false;
184        let mut has_budgeted_provider = false;
185        for entry in &self.providers {
186            let descriptor = entry.provider.descriptor();
187            if matches_device(options.device(), descriptor.id, descriptor.target)
188                && entry.provider.supports_projection(request.view.projection)
189            {
190                has_matching_provider = true;
191                has_budgeted_provider |= scratch_fits(
192                    entry.provider.scratch_requirement(),
193                    request.meshes,
194                    options,
195                );
196            }
197        }
198        if !has_matching_provider {
199            return Err(unsupported());
200        }
201        if !has_budgeted_provider {
202            return Err(GeomError::BudgetExceeded { resource: "memory" }.into());
203        }
204
205        // The allocating topology audit happens only after at least one provider
206        // and the audit itself have been admitted by the memory budget.
207        validate_topology(
208            request.meshes,
209            request.edges.group_boundary.then_some(LABEL),
210            options,
211        )?;
212
213        let mut last_retryable = None;
214        for entry in &self.providers {
215            let descriptor = entry.provider.descriptor();
216            if !matches_device(options.device(), descriptor.id, descriptor.target)
217                || !entry.provider.supports_projection(request.view.projection)
218                || !scratch_fits(
219                    entry.provider.scratch_requirement(),
220                    request.meshes,
221                    options,
222                )
223            {
224                continue;
225            }
226            let result = entry.provider.hidden_lines(request, options);
227            if let Err(HiddenLineError { mesh: Some(id), .. }) = &result {
228                if !request.meshes.iter().any(|entry| entry.id == *id) {
229                    return Err(contract_violation(
230                        descriptor.id,
231                        "refusal names a mesh that is not in the request",
232                    ));
233                }
234            }
235            match result {
236                Ok(outcome) => {
237                    validate_outcome(descriptor.id, request, &outcome)?;
238                    return Ok(outcome);
239                }
240                Err(
241                    error @ HiddenLineError {
242                        error: GeomError::Unsupported { .. } | GeomError::Unavailable { .. },
243                        ..
244                    },
245                ) => {
246                    last_retryable = Some(error);
247                }
248                Err(error) => return Err(error),
249            }
250        }
251
252        Err(last_retryable.unwrap_or_else(unsupported))
253    }
254}
255
256fn unsupported() -> HiddenLineError {
257    GeomError::Unsupported {
258        backend: BackendId::new("mesh-hidden-lines-registry"),
259        operation: Operation::MeshHiddenLines,
260    }
261    .into()
262}
263
264fn contract_violation(backend: BackendId, detail: &str) -> HiddenLineError {
265    GeomError::BackendContractViolation {
266        backend,
267        detail: detail.into(),
268    }
269    .into()
270}
271
272const LABEL: &str = "hidden-line";
273
274fn validate_request_shape(
275    request: &HiddenLineRequest<'_>,
276    options: &ExecutionOptions,
277) -> Result<(), HiddenLineError> {
278    let limits = request.limits;
279    validate_meshes(
280        request.meshes,
281        &SharedLimits {
282            label: LABEL,
283            max_meshes: limits.max_meshes,
284            max_source_vertices: limits.max_source_vertices,
285            max_source_triangles: limits.max_source_triangles,
286            meshes_resource: "hidden-line meshes",
287            vertices_resource: "hidden-line source vertices",
288            triangles_resource: "hidden-line source triangles",
289        },
290    )?;
291    validate_view(&request.view, options.tolerance().angular(), LABEL)?;
292    if request
293        .edges
294        .crease_cosine
295        .is_some_and(|cosine| !(-1.0..=1.0).contains(&cosine))
296    {
297        return Err(invalid("hidden-line crease cosine must lie in [-1, 1]"));
298    }
299    validate_region(request.region, LABEL)
300}
301
302fn validate_outcome(
303    backend: BackendId,
304    request: &HiddenLineRequest<'_>,
305    outcome: &HiddenLineOutcome,
306) -> Result<(), HiddenLineError> {
307    let violation = |detail: &str| contract_violation(backend, detail);
308    if outcome.view != request.view {
309        return Err(violation("provider changed the requested view"));
310    }
311    if outcome.segments.len() > request.limits.max_output_segments {
312        return Err(GeomError::BudgetExceeded {
313            resource: "hidden-line output segments",
314        }
315        .into());
316    }
317    let selected = |kind: EdgeKind| match kind {
318        EdgeKind::Boundary => request.edges.boundary,
319        EdgeKind::Silhouette => request.edges.silhouette,
320        EdgeKind::Crease => request.edges.crease_cosine.is_some(),
321        EdgeKind::GroupBoundary => request.edges.group_boundary,
322        _ => false,
323    };
324    let mut previous: Option<(usize, [u32; 2])> = None;
325    let mut previous_kind = None;
326    let mut previous_visibility = None;
327    let mut visible = 0usize;
328    let mut hidden = 0usize;
329    for segment in &outcome.segments {
330        let Some(position) = request
331            .meshes
332            .iter()
333            .position(|entry| entry.id == segment.source.mesh)
334        else {
335            return Err(violation("segment names a mesh that is not in the request"));
336        };
337        let [a, b] = segment.source.vertices;
338        let positions = request.meshes[position].mesh.positions.len();
339        if a >= b || b as usize >= positions {
340            return Err(violation(
341                "segment source edge must be ascending and within the mesh",
342            ));
343        }
344        if !segment.start.is_finite() || !segment.end.is_finite() {
345            return Err(violation("segment contains a non-finite point"));
346        }
347        if segment.start == segment.end {
348            return Err(violation("segment has zero length"));
349        }
350        if !selected(segment.kind) {
351            return Err(violation("segment has an edge kind that was not selected"));
352        }
353        match segment.visibility {
354            Visibility::Visible => visible += 1,
355            Visibility::Hidden if request.edges.hidden => hidden += 1,
356            _ => return Err(violation("hidden segment returned although not requested")),
357        }
358        let key = (position, segment.source.vertices);
359        if previous.is_some_and(|last| last > key) {
360            return Err(violation(
361                "segments are not ordered by mesh and source edge",
362            ));
363        }
364        if previous == Some(key) && previous_kind != Some(segment.kind) {
365            return Err(violation("pieces of one source edge differ in edge kind"));
366        }
367        // With hidden pieces omitted, two visible pieces may be neighbours
368        // along the edge with a hidden one between; otherwise a piece is
369        // maximal, so neighbours differ.
370        if request.edges.hidden
371            && previous == Some(key)
372            && previous_visibility == Some(segment.visibility)
373        {
374            return Err(violation(
375                "adjacent pieces of one source edge have the same visibility",
376            ));
377        }
378        previous = Some(key);
379        previous_kind = Some(segment.kind);
380        previous_visibility = Some(segment.visibility);
381    }
382    let triangles = source_totals(request.meshes).1;
383    let evidence = &outcome.evidence;
384    if !evidence.is_derived_from_input_mesh()
385        || evidence.source_meshes != request.meshes.len()
386        || Some(evidence.source_triangles) != triangles
387        || evidence.visible_segments != visible
388        || evidence.hidden_segments != hidden
389    {
390        return Err(violation(
391            "hidden-line evidence does not match input/output counts",
392        ));
393    }
394    Ok(())
395}