1use std::sync::Arc;
18
19use axioval_ir::{Evidence, ObjectId};
20
21use crate::services::reviewable_exact_evidence;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
25pub enum ContactError {
26 #[error("contact areas must be finite, non-negative and contained")]
28 InvalidAreas,
29 #[error("contact evidence must be exact and reviewable")]
31 InexactEvidence,
32 #[error("contact measurement is unavailable for the requested scope")]
34 Unavailable,
35 #[error("object body has no checkable orientation")]
37 UncheckableOrientation,
38 #[error("contact evidence names an object that is not a requested candidate")]
41 UnrequestedCandidate,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum ContactSide {
47 Above,
48 Below,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq)]
56#[allow(clippy::struct_field_names)]
60pub struct ContactTolerance {
61 maximum_gap_metres: f64,
62 maximum_intersection_metres: f64,
63 minimum_polygon_area_square_metres: f64,
64}
65
66impl ContactTolerance {
67 pub fn try_new(
68 maximum_gap_metres: f64,
69 maximum_intersection_metres: f64,
70 minimum_polygon_area_square_metres: f64,
71 ) -> Result<Self, ContactError> {
72 let ok = |v: f64| v.is_finite() && v >= 0.0;
73 if !ok(maximum_gap_metres)
74 || !ok(maximum_intersection_metres)
75 || !ok(minimum_polygon_area_square_metres)
76 {
77 return Err(ContactError::InvalidAreas);
78 }
79 Ok(Self {
80 maximum_gap_metres,
81 maximum_intersection_metres,
82 minimum_polygon_area_square_metres,
83 })
84 }
85 pub fn maximum_gap_metres(&self) -> f64 {
86 self.maximum_gap_metres
87 }
88 pub fn maximum_intersection_metres(&self) -> f64 {
89 self.maximum_intersection_metres
90 }
91 pub fn minimum_polygon_area_square_metres(&self) -> f64 {
92 self.minimum_polygon_area_square_metres
93 }
94}
95
96#[derive(Clone, Debug, PartialEq)]
103pub struct ContactRequest {
104 subject: ObjectId,
105 candidates: Vec<ObjectId>,
106 side: ContactSide,
107 tolerance: ContactTolerance,
108}
109
110impl ContactRequest {
111 pub fn new(
114 subject: ObjectId,
115 mut candidates: Vec<ObjectId>,
116 side: ContactSide,
117 tolerance: ContactTolerance,
118 ) -> Self {
119 candidates.sort();
120 candidates.dedup();
121 candidates.retain(|candidate| *candidate != subject);
122 Self {
123 subject,
124 candidates,
125 side,
126 tolerance,
127 }
128 }
129 pub fn subject(&self) -> &ObjectId {
130 &self.subject
131 }
132 pub fn candidates(&self) -> &[ObjectId] {
134 &self.candidates
135 }
136 pub fn is_candidate(&self, object: &ObjectId) -> bool {
138 self.candidates.binary_search(object).is_ok()
139 }
140 pub fn side(&self) -> ContactSide {
141 self.side
142 }
143 pub fn tolerance(&self) -> ContactTolerance {
144 self.tolerance
145 }
146}
147
148#[derive(Clone, Debug, PartialEq)]
150pub struct ContactEvidence {
151 request: ContactRequest,
152 whole_area_square_metres: f64,
153 contact_area_square_metres: f64,
154 nearest_distance_metres: Option<f64>,
155 touching: Vec<ObjectId>,
156 evidence: Evidence,
157}
158
159impl ContactEvidence {
160 pub fn try_new(
163 request: ContactRequest,
164 whole_area_square_metres: f64,
165 contact_area_square_metres: f64,
166 nearest_distance_metres: Option<f64>,
167 mut touching: Vec<ObjectId>,
168 evidence: Evidence,
169 ) -> Result<Self, ContactError> {
170 let finite_non_negative = |v: f64| v.is_finite() && v >= 0.0;
171 if !finite_non_negative(whole_area_square_metres)
172 || !finite_non_negative(contact_area_square_metres)
173 || whole_area_square_metres <= 0.0
174 || contact_area_square_metres > whole_area_square_metres
177 {
178 return Err(ContactError::InvalidAreas);
179 }
180 if nearest_distance_metres.is_some_and(|d| !finite_non_negative(d)) {
181 return Err(ContactError::InvalidAreas);
182 }
183 if !reviewable_exact_evidence(&evidence) {
184 return Err(ContactError::InexactEvidence);
185 }
186 touching.sort();
187 touching.dedup();
188 if touching.iter().any(|object| !request.is_candidate(object)) {
191 return Err(ContactError::UnrequestedCandidate);
192 }
193 Ok(Self {
194 request,
195 whole_area_square_metres,
196 contact_area_square_metres,
197 nearest_distance_metres,
198 touching,
199 evidence,
200 })
201 }
202
203 pub fn request(&self) -> &ContactRequest {
204 &self.request
205 }
206 pub fn whole_area_square_metres(&self) -> f64 {
207 self.whole_area_square_metres
208 }
209 pub fn contact_area_square_metres(&self) -> f64 {
210 self.contact_area_square_metres
211 }
212 pub fn nearest_distance_metres(&self) -> Option<f64> {
214 self.nearest_distance_metres
215 }
216 pub fn touching(&self) -> &[ObjectId] {
218 &self.touching
219 }
220 pub fn evidence(&self) -> &Evidence {
221 &self.evidence
222 }
223
224 pub fn contact_ratio(&self) -> f64 {
229 self.contact_area_square_metres / self.whole_area_square_metres
230 }
231}
232
233pub trait ContactService: Send + Sync + 'static {
237 fn measure_contact(&self, request: &ContactRequest) -> Result<ContactEvidence, ContactError>;
238}
239
240#[derive(Clone)]
242pub struct ContactServiceHandle(Arc<dyn ContactService>);
243
244impl ContactServiceHandle {
245 pub fn new(service: Arc<dyn ContactService>) -> Self {
246 Self(service)
247 }
248 pub fn measure_contact(
249 &self,
250 request: &ContactRequest,
251 ) -> Result<ContactEvidence, ContactError> {
252 self.0.measure_contact(request)
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use axioval_ir::SourceId;
260
261 fn id(local: &str) -> ObjectId {
262 ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
263 }
264 fn request() -> ContactRequest {
265 ContactRequest::new(
266 id("wall"),
267 vec![id("slab-b"), id("slab-a")],
268 ContactSide::Above,
269 ContactTolerance::try_new(0.01, 0.01, 0.001).unwrap(),
270 )
271 }
272 fn evidence() -> Evidence {
273 Evidence::exact(SourceId::new("cad", "m").unwrap(), "contact:wall")
274 }
275
276 #[test]
279 fn contact_exceeding_the_whole_face_is_refused() {
280 assert_eq!(
281 ContactEvidence::try_new(request(), 10.0, 11.0, None, Vec::new(), evidence()),
282 Err(ContactError::InvalidAreas)
283 );
284 }
285
286 #[test]
287 fn zero_or_non_finite_whole_area_is_refused() {
288 for whole in [0.0, f64::NAN, f64::INFINITY, -1.0] {
289 assert_eq!(
290 ContactEvidence::try_new(request(), whole, 0.0, None, Vec::new(), evidence()),
291 Err(ContactError::InvalidAreas)
292 );
293 }
294 }
295
296 #[test]
299 fn contact_ratio_is_exact_and_unrounded() {
300 let measured =
301 ContactEvidence::try_new(request(), 3.0, 1.0, None, Vec::new(), evidence()).unwrap();
302 assert!((measured.contact_ratio() - 1.0 / 3.0).abs() < f64::EPSILON);
303 }
304
305 #[test]
306 fn touching_objects_are_sorted_and_deduplicated() {
307 let measured = ContactEvidence::try_new(
308 request(),
309 4.0,
310 2.0,
311 None,
312 vec![id("slab-b"), id("slab-a"), id("slab-b")],
313 evidence(),
314 )
315 .unwrap();
316 assert_eq!(measured.touching(), &[id("slab-a"), id("slab-b")]);
317 }
318
319 #[test]
320 fn candidates_are_sorted_deduplicated_and_exclude_the_subject() {
321 let request = ContactRequest::new(
322 id("wall"),
323 vec![id("slab-b"), id("wall"), id("slab-a"), id("slab-b")],
324 ContactSide::Above,
325 ContactTolerance::try_new(0.01, 0.01, 0.001).unwrap(),
326 );
327 assert_eq!(request.candidates(), &[id("slab-a"), id("slab-b")]);
328 assert!(!request.is_candidate(&id("wall")));
329 }
330
331 #[test]
334 fn touching_an_unrequested_object_is_refused() {
335 for stray in ["slab-c", "wall"] {
336 assert_eq!(
337 ContactEvidence::try_new(request(), 4.0, 2.0, None, vec![id(stray)], evidence()),
338 Err(ContactError::UnrequestedCandidate)
339 );
340 }
341 }
342}