Skip to main content

axioval_engine/
sight.rs

1//! Lines of sight: whether any part of a target is in view from an eye
2//! point, past a stated set of blockers.
3//!
4//! ADR 0004: this seam proves what can be seen. How many targets must be in
5//! view, from which eye and within what radius, is a rule's judgement.
6//!
7//! Each answer is one of three, and only two of them are proofs. **Visible**
8//! names a witness: a point of the target the straight segment from the eye
9//! reaches before it meets any blocker. **Hidden** names the occluders that
10//! together cover every ray from the eye to the target. **Undecided** is
11//! neither, never a guess: a target only grazed, or covered only where
12//! several blockers meet.
13//!
14//! Blockers are the request's (the rule's selection), never the host's: an
15//! answer naming an occluder the request did not send is refused.
16
17use std::sync::Arc;
18
19use axioval_ir::{Evidence, ObjectId};
20use thiserror::Error;
21
22/// Failure to assess a line of sight.
23#[derive(Clone, Debug, Error, PartialEq, Eq)]
24pub enum SightError {
25    /// The service holds no geometry for this object.
26    #[error("no geometry for `{0}`")]
27    UnknownObject(ObjectId),
28    /// The request is malformed: a non-finite eye, a negative range, or the
29    /// target among its own blockers.
30    #[error("invalid sight request: {0}")]
31    InvalidRequest(String),
32    /// The geometry could not be assessed, for example a blocker whose body
33    /// was not measured or a tessellated target.
34    #[error("line of sight unavailable: {0}")]
35    Unavailable(String),
36    /// The answer does not fit the request: another target, an occluder the
37    /// request did not send, a witness that is not finite, or a distance
38    /// whose bounds are reversed.
39    #[error("line-of-sight evidence is invalid: {0}")]
40    InvalidEvidence(String),
41}
42
43/// A question about one target: is any part of it in view from `eye`?
44#[derive(Clone, Debug, PartialEq)]
45pub struct SightRequest {
46    eye: [f64; 3],
47    target: ObjectId,
48    blockers: Vec<ObjectId>,
49    within: Option<f64>,
50}
51
52impl SightRequest {
53    /// A line of sight from `eye` (canonical metres, the source's coordinate
54    /// system) to `target`, past `blockers`, which are sorted and
55    /// deduplicated.
56    ///
57    /// With `within`, the service measures the distance first and does not
58    /// look at a target surely farther than that from the eye.
59    ///
60    /// # Errors
61    ///
62    /// [`SightError::InvalidRequest`] for a non-finite eye, a range that is
63    /// negative or not finite, or a target among its blockers.
64    pub fn try_new(
65        eye: [f64; 3],
66        target: ObjectId,
67        mut blockers: Vec<ObjectId>,
68        within: Option<f64>,
69    ) -> Result<Self, SightError> {
70        if !eye.iter().all(|value| value.is_finite()) {
71            return Err(SightError::InvalidRequest(
72                "the eye is not a finite point".into(),
73            ));
74        }
75        if within.is_some_and(|range| !range.is_finite() || range < 0.0) {
76            return Err(SightError::InvalidRequest(
77                "the range is not a non-negative length".into(),
78            ));
79        }
80        blockers.sort();
81        blockers.dedup();
82        if blockers.binary_search(&target).is_ok() {
83            return Err(SightError::InvalidRequest(format!(
84                "{target} cannot block the view of itself"
85            )));
86        }
87        Ok(Self {
88            eye,
89            target,
90            blockers,
91            within,
92        })
93    }
94
95    /// The eye point, in canonical metres.
96    #[must_use]
97    pub fn eye(&self) -> [f64; 3] {
98        self.eye
99    }
100
101    /// The object looked at.
102    #[must_use]
103    pub fn target(&self) -> &ObjectId {
104        &self.target
105    }
106
107    /// The objects that may block the view, sorted and unique.
108    #[must_use]
109    pub fn blockers(&self) -> &[ObjectId] {
110        &self.blockers
111    }
112
113    /// The range beyond which the target is not looked at.
114    #[must_use]
115    pub fn within_metres(&self) -> Option<f64> {
116        self.within
117    }
118}
119
120/// What was proven about the view of one target.
121#[derive(Clone, Debug, PartialEq)]
122pub enum SightOutcome {
123    /// The segment from the eye to `through`, a point of the target, meets
124    /// no blocker before it reaches the target.
125    Visible {
126        /// The witness point on the target, in canonical metres.
127        through: [f64; 3],
128    },
129    /// Every ray from the eye to the target meets one of `occluders` first.
130    Hidden {
131        /// The blockers the proof used, sorted and unique, never empty.
132        occluders: Vec<ObjectId>,
133    },
134    /// Neither could be proven.
135    Undecided,
136}
137
138/// The answer to a [`SightRequest`].
139#[derive(Clone, Debug, PartialEq)]
140pub struct SightEvidence {
141    target: ObjectId,
142    distance: (f64, f64),
143    outcome: Option<SightOutcome>,
144    evidence: Evidence,
145}
146
147impl SightEvidence {
148    /// The view of `target`: the distance from the eye to its nearest point,
149    /// known to lie in `distance` (metres, inclusive), and the outcome, which
150    /// is `None` only when the target lies surely beyond the request's range.
151    ///
152    /// # Errors
153    ///
154    /// [`SightError::InvalidEvidence`] for reversed, negative or non-finite
155    /// distance bounds, a witness that is not finite, a hidden target with no
156    /// occluders, or evidence without a locator.
157    pub fn try_new(
158        target: ObjectId,
159        distance: (f64, f64),
160        mut outcome: Option<SightOutcome>,
161        evidence: Evidence,
162    ) -> Result<Self, SightError> {
163        let (lower, upper) = distance;
164        if !lower.is_finite() || !upper.is_finite() || lower < 0.0 || lower > upper {
165            return Err(SightError::InvalidEvidence(
166                "the distance bounds are not an interval of lengths".into(),
167            ));
168        }
169        match &mut outcome {
170            Some(SightOutcome::Visible { through }) if !through.iter().all(|v| v.is_finite()) => {
171                return Err(SightError::InvalidEvidence(
172                    "the witness is not a finite point".into(),
173                ));
174            }
175            Some(SightOutcome::Hidden { occluders }) => {
176                occluders.sort();
177                occluders.dedup();
178                if occluders.is_empty() {
179                    return Err(SightError::InvalidEvidence(
180                        "a hidden target names no occluder".into(),
181                    ));
182                }
183            }
184            _ => {}
185        }
186        if evidence.locator.trim().is_empty() {
187            return Err(SightError::InvalidEvidence(
188                "the evidence has no locator".into(),
189            ));
190        }
191        Ok(Self {
192            target,
193            distance,
194            outcome,
195            evidence,
196        })
197    }
198
199    /// The object looked at.
200    #[must_use]
201    pub fn target(&self) -> &ObjectId {
202        &self.target
203    }
204
205    /// Bounds on the distance from the eye to the target's nearest point, in
206    /// metres.
207    #[must_use]
208    pub fn distance_metres(&self) -> (f64, f64) {
209        self.distance
210    }
211
212    /// The outcome, or `None` for a target surely beyond the range.
213    #[must_use]
214    pub fn outcome(&self) -> Option<&SightOutcome> {
215        self.outcome.as_ref()
216    }
217
218    /// Reviewable provenance of the assessment.
219    #[must_use]
220    pub fn evidence(&self) -> &Evidence {
221        &self.evidence
222    }
223}
224
225/// Assesses lines of sight between an eye point and model objects.
226pub trait SightService: Send + Sync + 'static {
227    /// Whether any part of the request's target is in view from its eye.
228    fn assess_sight(&self, request: &SightRequest) -> Result<SightEvidence, SightError>;
229}
230
231/// Registry handle for a [`SightService`].
232#[derive(Clone)]
233pub struct SightServiceHandle(Arc<dyn SightService>);
234
235impl SightServiceHandle {
236    /// Wraps a trusted line-of-sight service.
237    #[must_use]
238    pub fn new(service: Arc<dyn SightService>) -> Self {
239        Self(service)
240    }
241
242    /// The view of the request's target.
243    ///
244    /// An answer about another target, naming an occluder the request did
245    /// not send, or skipping a target that may lie within the range, is
246    /// refused.
247    pub fn assess_sight(&self, request: &SightRequest) -> Result<SightEvidence, SightError> {
248        let answer = self.0.assess_sight(request)?;
249        if answer.target() != request.target() {
250            return Err(SightError::InvalidEvidence(format!(
251                "an answer about {} was returned for {}",
252                answer.target(),
253                request.target()
254            )));
255        }
256        if let Some(SightOutcome::Hidden { occluders }) = answer.outcome() {
257            if let Some(stranger) = occluders
258                .iter()
259                .find(|occluder| request.blockers().binary_search(occluder).is_err())
260            {
261                return Err(SightError::InvalidEvidence(format!(
262                    "{stranger} was not among the requested blockers"
263                )));
264            }
265        }
266        if answer.outcome().is_none()
267            && !request
268                .within_metres()
269                .is_some_and(|range| answer.distance_metres().0 > range)
270        {
271            return Err(SightError::InvalidEvidence(format!(
272                "{} may lie within range but was not looked at",
273                request.target()
274            )));
275        }
276        Ok(answer)
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use axioval_ir::SourceId;
284
285    fn id(local: &str) -> ObjectId {
286        ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
287    }
288
289    fn evidence() -> Evidence {
290        Evidence::exact(SourceId::new("cad", "m").unwrap(), "sight:a")
291    }
292
293    struct Answer(Option<SightOutcome>, (f64, f64), ObjectId);
294
295    impl SightService for Answer {
296        fn assess_sight(&self, _: &SightRequest) -> Result<SightEvidence, SightError> {
297            SightEvidence::try_new(self.2.clone(), self.1, self.0.clone(), evidence())
298        }
299    }
300
301    fn handle(
302        outcome: Option<SightOutcome>,
303        distance: (f64, f64),
304        target: &str,
305    ) -> SightServiceHandle {
306        SightServiceHandle::new(Arc::new(Answer(outcome, distance, id(target))))
307    }
308
309    #[test]
310    fn a_request_refuses_a_bad_eye_range_or_a_self_blocking_target() {
311        assert!(SightRequest::try_new([0.0, f64::NAN, 0.0], id("t"), vec![], None).is_err());
312        assert!(SightRequest::try_new([0.0; 3], id("t"), vec![], Some(-1.0)).is_err());
313        assert!(SightRequest::try_new([0.0; 3], id("t"), vec![id("t")], None).is_err());
314        let request = SightRequest::try_new(
315            [0.0; 3],
316            id("t"),
317            vec![id("b"), id("a"), id("b")],
318            Some(2.0),
319        )
320        .unwrap();
321        assert_eq!(request.blockers(), [id("a"), id("b")]);
322    }
323
324    #[test]
325    fn evidence_needs_an_interval_a_finite_witness_and_occluders() {
326        let hidden = |occluders| Some(SightOutcome::Hidden { occluders });
327        assert!(SightEvidence::try_new(id("t"), (2.0, 1.0), None, evidence()).is_err());
328        assert!(SightEvidence::try_new(id("t"), (1.0, 1.0), hidden(vec![]), evidence()).is_err());
329        let witness = Some(SightOutcome::Visible {
330            through: [f64::INFINITY, 0.0, 0.0],
331        });
332        assert!(SightEvidence::try_new(id("t"), (1.0, 1.0), witness, evidence()).is_err());
333        let sorted = SightEvidence::try_new(
334            id("t"),
335            (1.0, 1.0),
336            hidden(vec![id("b"), id("a")]),
337            evidence(),
338        )
339        .unwrap();
340        assert_eq!(
341            sorted.outcome(),
342            Some(&SightOutcome::Hidden {
343                occluders: vec![id("a"), id("b")]
344            })
345        );
346    }
347
348    #[test]
349    fn the_handle_refuses_strangers_other_targets_and_unlooked_targets_in_range() {
350        let request = SightRequest::try_new([0.0; 3], id("t"), vec![id("a")], Some(2.0)).unwrap();
351        let stranger = Some(SightOutcome::Hidden {
352            occluders: vec![id("z")],
353        });
354        assert!(
355            handle(stranger, (1.0, 1.0), "t")
356                .assess_sight(&request)
357                .is_err()
358        );
359        assert!(
360            handle(Some(SightOutcome::Undecided), (1.0, 1.0), "u")
361                .assess_sight(&request)
362                .is_err()
363        );
364        assert!(
365            handle(None, (1.5, 2.5), "t")
366                .assess_sight(&request)
367                .is_err()
368        );
369        assert!(handle(None, (2.5, 3.0), "t").assess_sight(&request).is_ok());
370        let known = Some(SightOutcome::Hidden {
371            occluders: vec![id("a")],
372        });
373        assert!(
374            handle(known, (1.0, 1.0), "t")
375                .assess_sight(&request)
376                .is_ok()
377        );
378    }
379}