1use std::sync::Arc;
18
19use axioval_ir::{Evidence, ObjectId};
20use thiserror::Error;
21
22#[derive(Clone, Debug, Error, PartialEq, Eq)]
24pub enum SightError {
25 #[error("no geometry for `{0}`")]
27 UnknownObject(ObjectId),
28 #[error("invalid sight request: {0}")]
31 InvalidRequest(String),
32 #[error("line of sight unavailable: {0}")]
35 Unavailable(String),
36 #[error("line-of-sight evidence is invalid: {0}")]
40 InvalidEvidence(String),
41}
42
43#[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 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 #[must_use]
97 pub fn eye(&self) -> [f64; 3] {
98 self.eye
99 }
100
101 #[must_use]
103 pub fn target(&self) -> &ObjectId {
104 &self.target
105 }
106
107 #[must_use]
109 pub fn blockers(&self) -> &[ObjectId] {
110 &self.blockers
111 }
112
113 #[must_use]
115 pub fn within_metres(&self) -> Option<f64> {
116 self.within
117 }
118}
119
120#[derive(Clone, Debug, PartialEq)]
122pub enum SightOutcome {
123 Visible {
126 through: [f64; 3],
128 },
129 Hidden {
131 occluders: Vec<ObjectId>,
133 },
134 Undecided,
136}
137
138#[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 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 #[must_use]
201 pub fn target(&self) -> &ObjectId {
202 &self.target
203 }
204
205 #[must_use]
208 pub fn distance_metres(&self) -> (f64, f64) {
209 self.distance
210 }
211
212 #[must_use]
214 pub fn outcome(&self) -> Option<&SightOutcome> {
215 self.outcome.as_ref()
216 }
217
218 #[must_use]
220 pub fn evidence(&self) -> &Evidence {
221 &self.evidence
222 }
223}
224
225pub trait SightService: Send + Sync + 'static {
227 fn assess_sight(&self, request: &SightRequest) -> Result<SightEvidence, SightError>;
229}
230
231#[derive(Clone)]
233pub struct SightServiceHandle(Arc<dyn SightService>);
234
235impl SightServiceHandle {
236 #[must_use]
238 pub fn new(service: Arc<dyn SightService>) -> Self {
239 Self(service)
240 }
241
242 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}