Skip to main content

axioval_engine/
contact.rs

1//! Source-neutral surface-contact evidence.
2//!
3//! ADR 0004: a service returns what was *measured*; a capability decides what
4//! it means. This is the measurement half of the slab-contact decomposition.
5//!
6//! Two things deliberately do **not** cross this seam:
7//!
8//! - **No verdict state.** The source provider returned a four-state enum in
9//!   which `IgnoredSmall` was a threshold decision and `Skipped` a scope
10//!   decision. Both are policy. Scope belongs to the selector, and a small
11//!   contact area is just a small measured number.
12//! - **No rounding.** The source rounded the contact ratio to two decimals
13//!   before the rule compared it, so a value could round *up* across the
14//!   declared minimum and pass. Rounding is a presentation choice and cannot
15//!   be allowed to change a verdict, so the measurement stays exact.
16
17use std::sync::Arc;
18
19use axioval_ir::{Evidence, ObjectId};
20
21use crate::services::reviewable_exact_evidence;
22
23/// Why a contact measurement could not be produced.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
25pub enum ContactError {
26    /// Areas are negative, non-finite, or the contact exceeds the whole.
27    #[error("contact areas must be finite, non-negative and contained")]
28    InvalidAreas,
29    /// The evidence backing the measurement was not exact and reviewable.
30    #[error("contact evidence must be exact and reviewable")]
31    InexactEvidence,
32    /// The adapter cannot measure contact for this object.
33    #[error("contact measurement is unavailable for the requested scope")]
34    Unavailable,
35    /// The object's body has no direction the adapter can orient against.
36    #[error("object body has no checkable orientation")]
37    UncheckableOrientation,
38}
39
40/// Which side of the subject the contacting surface must lie on.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum ContactSide {
43    Above,
44    Below,
45}
46
47/// Tolerances describing what counts as touching.
48///
49/// These are measurement inputs, not thresholds: they define contact, rather
50/// than judging whether enough of it exists.
51#[derive(Clone, Copy, Debug, PartialEq)]
52// The shared unit suffix is the point: these are lengths and an area in fixed
53// units, and naming the unit on each field is what stops a millimetre or a
54// square metre being passed where metres are meant.
55#[allow(clippy::struct_field_names)]
56pub struct ContactTolerance {
57    maximum_gap_metres: f64,
58    maximum_intersection_metres: f64,
59    minimum_polygon_area_square_metres: f64,
60}
61
62impl ContactTolerance {
63    pub fn try_new(
64        maximum_gap_metres: f64,
65        maximum_intersection_metres: f64,
66        minimum_polygon_area_square_metres: f64,
67    ) -> Result<Self, ContactError> {
68        let ok = |v: f64| v.is_finite() && v >= 0.0;
69        if !ok(maximum_gap_metres)
70            || !ok(maximum_intersection_metres)
71            || !ok(minimum_polygon_area_square_metres)
72        {
73            return Err(ContactError::InvalidAreas);
74        }
75        Ok(Self {
76            maximum_gap_metres,
77            maximum_intersection_metres,
78            minimum_polygon_area_square_metres,
79        })
80    }
81    pub fn maximum_gap_metres(&self) -> f64 {
82        self.maximum_gap_metres
83    }
84    pub fn maximum_intersection_metres(&self) -> f64 {
85        self.maximum_intersection_metres
86    }
87    pub fn minimum_polygon_area_square_metres(&self) -> f64 {
88        self.minimum_polygon_area_square_metres
89    }
90}
91
92/// A request for the contact measurement of one object.
93#[derive(Clone, Debug, PartialEq)]
94pub struct ContactRequest {
95    subject: ObjectId,
96    side: ContactSide,
97    tolerance: ContactTolerance,
98}
99
100impl ContactRequest {
101    pub fn new(subject: ObjectId, side: ContactSide, tolerance: ContactTolerance) -> Self {
102        Self {
103            subject,
104            side,
105            tolerance,
106        }
107    }
108    pub fn subject(&self) -> &ObjectId {
109        &self.subject
110    }
111    pub fn side(&self) -> ContactSide {
112        self.side
113    }
114    pub fn tolerance(&self) -> ContactTolerance {
115        self.tolerance
116    }
117}
118
119/// How much of a subject's face is in contact, and with what.
120#[derive(Clone, Debug, PartialEq)]
121pub struct ContactEvidence {
122    request: ContactRequest,
123    whole_area_square_metres: f64,
124    contact_area_square_metres: f64,
125    nearest_distance_metres: Option<f64>,
126    touching: Vec<ObjectId>,
127    evidence: Evidence,
128}
129
130impl ContactEvidence {
131    /// Rejects incoherent areas and unreviewable evidence, so an adapter
132    /// cannot launder an estimate into the engine as fact.
133    pub fn try_new(
134        request: ContactRequest,
135        whole_area_square_metres: f64,
136        contact_area_square_metres: f64,
137        nearest_distance_metres: Option<f64>,
138        mut touching: Vec<ObjectId>,
139        evidence: Evidence,
140    ) -> Result<Self, ContactError> {
141        let finite_non_negative = |v: f64| v.is_finite() && v >= 0.0;
142        if !finite_non_negative(whole_area_square_metres)
143            || !finite_non_negative(contact_area_square_metres)
144            || whole_area_square_metres <= 0.0
145            // A face cannot touch over more than its own area; if it appears
146            // to, the measurement is wrong and must not reach a rule.
147            || contact_area_square_metres > whole_area_square_metres
148        {
149            return Err(ContactError::InvalidAreas);
150        }
151        if nearest_distance_metres.is_some_and(|d| !finite_non_negative(d)) {
152            return Err(ContactError::InvalidAreas);
153        }
154        if !reviewable_exact_evidence(&evidence) {
155            return Err(ContactError::InexactEvidence);
156        }
157        touching.sort();
158        touching.dedup();
159        Ok(Self {
160            request,
161            whole_area_square_metres,
162            contact_area_square_metres,
163            nearest_distance_metres,
164            touching,
165            evidence,
166        })
167    }
168
169    pub fn request(&self) -> &ContactRequest {
170        &self.request
171    }
172    pub fn whole_area_square_metres(&self) -> f64 {
173        self.whole_area_square_metres
174    }
175    pub fn contact_area_square_metres(&self) -> f64 {
176        self.contact_area_square_metres
177    }
178    /// Distance to the nearest candidate when nothing is touching.
179    pub fn nearest_distance_metres(&self) -> Option<f64> {
180        self.nearest_distance_metres
181    }
182    /// The objects found in contact, sorted and deduplicated.
183    pub fn touching(&self) -> &[ObjectId] {
184        &self.touching
185    }
186    pub fn evidence(&self) -> &Evidence {
187        &self.evidence
188    }
189
190    /// Fraction of the face in contact, computed exactly.
191    ///
192    /// The divisor is validated positive in [`Self::try_new`], so this cannot
193    /// divide by zero.
194    pub fn contact_ratio(&self) -> f64 {
195        self.contact_area_square_metres / self.whole_area_square_metres
196    }
197}
198
199/// Measures surface contact between model objects.
200///
201/// ADR 0004: every method returns a measurement. None returns a finding.
202pub trait ContactService: Send + Sync + 'static {
203    fn measure_contact(&self, request: &ContactRequest) -> Result<ContactEvidence, ContactError>;
204}
205
206/// Registry handle for a [`ContactService`].
207#[derive(Clone)]
208pub struct ContactServiceHandle(Arc<dyn ContactService>);
209
210impl ContactServiceHandle {
211    pub fn new(service: Arc<dyn ContactService>) -> Self {
212        Self(service)
213    }
214    pub fn measure_contact(
215        &self,
216        request: &ContactRequest,
217    ) -> Result<ContactEvidence, ContactError> {
218        self.0.measure_contact(request)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use axioval_ir::SourceId;
226
227    fn id(local: &str) -> ObjectId {
228        ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
229    }
230    fn request() -> ContactRequest {
231        ContactRequest::new(
232            id("wall"),
233            ContactSide::Above,
234            ContactTolerance::try_new(0.01, 0.01, 0.001).unwrap(),
235        )
236    }
237    fn evidence() -> Evidence {
238        Evidence::exact(SourceId::new("cad", "m").unwrap(), "contact:wall")
239    }
240
241    /// Contact larger than the face is physically impossible; accepting it
242    /// would let a ratio above 1.0 satisfy any minimum.
243    #[test]
244    fn contact_exceeding_the_whole_face_is_refused() {
245        assert_eq!(
246            ContactEvidence::try_new(request(), 10.0, 11.0, None, Vec::new(), evidence()),
247            Err(ContactError::InvalidAreas)
248        );
249    }
250
251    #[test]
252    fn zero_or_non_finite_whole_area_is_refused() {
253        for whole in [0.0, f64::NAN, f64::INFINITY, -1.0] {
254            assert_eq!(
255                ContactEvidence::try_new(request(), whole, 0.0, None, Vec::new(), evidence()),
256                Err(ContactError::InvalidAreas)
257            );
258        }
259    }
260
261    /// The ratio is exact. Rounding it here would let a value below a declared
262    /// minimum round up and silently pass.
263    #[test]
264    fn contact_ratio_is_exact_and_unrounded() {
265        let measured =
266            ContactEvidence::try_new(request(), 3.0, 1.0, None, Vec::new(), evidence()).unwrap();
267        assert!((measured.contact_ratio() - 1.0 / 3.0).abs() < f64::EPSILON);
268    }
269
270    #[test]
271    fn touching_objects_are_sorted_and_deduplicated() {
272        let measured = ContactEvidence::try_new(
273            request(),
274            4.0,
275            2.0,
276            None,
277            vec![id("slab-b"), id("slab-a"), id("slab-b")],
278            evidence(),
279        )
280        .unwrap();
281        assert_eq!(measured.touching(), &[id("slab-a"), id("slab-b")]);
282    }
283}