Skip to main content

axioval_engine/
side_distance.rs

1//! Side distances: which footprints lie beside each side of an object's
2//! least-area rectangle, and how far from its centre lines.
3//!
4//! ADR 0004: this seam measures. Which side of a washbasin is its back, or
5//! whether a WC's axis lies far enough from the wall beside it, is a rule's
6//! judgement over these measurements.
7//!
8//! A side of the rectangle faces outward along one of its axes. Its *strip*
9//! is the region beyond the rectangle's centre line (the line through the
10//! centre square to the outward direction), out to the request's reach,
11//! whose offset across the outward direction lies strictly within the
12//! rectangle's half extent across it, less the request's inset. A candidate
13//! is listed on a side when its footprint may meet the strip with positive
14//! area, [`SidePresence::Sure`] when it surely does; a candidate not listed
15//! on a side surely does not meet its strip. Its distance is the least
16//! distance from the centre line of its footprint's part in the strip, as
17//! an interval sure to hold the true value (for a possible candidate: should
18//! it meet the strip at all).
19//!
20//! The inset is the rule's allowance for walls that stand flush with a
21//! neighbouring side: the wall a component stands against touches the
22//! strips of the sides beside it along their edge, and only an inset keeps
23//! a measured edge from entering them.
24//!
25//! Only a rectangle of [`RectangleOrientation::Unique`] orientation has
26//! sides of its own; any other refuses.
27
28use std::sync::Arc;
29
30use axioval_ir::{Evidence, ObjectId};
31
32use crate::plan_span::{
33    PlanLength, PlanRectangle, PlanSpanError, PlanSpanService, RectangleOrientation,
34};
35
36/// One side of a [`PlanRectangle`], by the axis it faces along.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
38pub enum RectangleSide {
39    /// Facing along the first axis.
40    AlongFirst,
41    /// Facing along the second axis.
42    AlongSecond,
43    /// Facing against the first axis.
44    AgainstFirst,
45    /// Facing against the second axis.
46    AgainstSecond,
47}
48
49impl RectangleSide {
50    /// Every side, counter-clockwise from the first axis.
51    pub const ALL: [Self; 4] = [
52        Self::AlongFirst,
53        Self::AlongSecond,
54        Self::AgainstFirst,
55        Self::AgainstSecond,
56    ];
57
58    /// The index of the axis the side faces along or against.
59    #[must_use]
60    pub fn axis(self) -> usize {
61        match self {
62            Self::AlongFirst | Self::AgainstFirst => 0,
63            Self::AlongSecond | Self::AgainstSecond => 1,
64        }
65    }
66
67    /// `1` along the axis, `-1` against it.
68    #[must_use]
69    pub fn sign(self) -> f64 {
70        match self {
71            Self::AlongFirst | Self::AlongSecond => 1.0,
72            Self::AgainstFirst | Self::AgainstSecond => -1.0,
73        }
74    }
75
76    /// The side facing the other way.
77    #[must_use]
78    pub fn opposite(self) -> Self {
79        match self {
80            Self::AlongFirst => Self::AgainstFirst,
81            Self::AlongSecond => Self::AgainstSecond,
82            Self::AgainstFirst => Self::AlongFirst,
83            Self::AgainstSecond => Self::AlongSecond,
84        }
85    }
86
87    /// The side's outward unit direction in plan.
88    #[must_use]
89    pub fn outward(self, rectangle: &PlanRectangle) -> [f64; 2] {
90        rectangle.axes()[self.axis()].map(|value| self.sign() * value)
91    }
92
93    /// The side's stable name, as evidence locators cite it.
94    #[must_use]
95    pub fn name(self) -> &'static str {
96        match self {
97            Self::AlongFirst => "+first",
98            Self::AlongSecond => "+second",
99            Self::AgainstFirst => "-first",
100            Self::AgainstSecond => "-second",
101        }
102    }
103}
104
105/// The object whose rectangle's sides are measured, the candidates to find
106/// beside them, how far out to look and how far to narrow each strip.
107#[derive(Clone, Debug, PartialEq)]
108pub struct SideDistanceRequest {
109    object: ObjectId,
110    candidates: Vec<ObjectId>,
111    reach: f64,
112    inset: f64,
113}
114
115impl SideDistanceRequest {
116    /// A request for the candidates beside `object`'s sides within `reach`
117    /// metres of its centre lines, each strip narrowed by `inset` metres on
118    /// both edges. Candidates are sorted and deduplicated; the object
119    /// itself is refused as one, and so are a reach that is not positive
120    /// and finite and an inset that is negative or not finite.
121    pub fn try_new(
122        object: ObjectId,
123        candidates: impl IntoIterator<Item = ObjectId>,
124        reach: f64,
125        inset: f64,
126    ) -> Result<Self, PlanSpanError> {
127        let mut candidates: Vec<ObjectId> = candidates.into_iter().collect();
128        candidates.sort();
129        candidates.dedup();
130        if candidates.contains(&object) {
131            return Err(PlanSpanError::Unavailable(format!(
132                "{object} cannot lie beside itself"
133            )));
134        }
135        if !(reach.is_finite() && reach > 0.0 && inset.is_finite() && inset >= 0.0) {
136            return Err(PlanSpanError::Unavailable(
137                "a side-distance reach must be positive and its inset non-negative".into(),
138            ));
139        }
140        Ok(Self {
141            object,
142            candidates,
143            reach,
144            inset,
145        })
146    }
147
148    /// The object.
149    #[must_use]
150    pub fn object(&self) -> &ObjectId {
151        &self.object
152    }
153
154    /// The candidates, sorted without repeats.
155    #[must_use]
156    pub fn candidates(&self) -> &[ObjectId] {
157        &self.candidates
158    }
159
160    /// How far beyond the centre lines to look, in metres.
161    #[must_use]
162    pub fn reach_metres(&self) -> f64 {
163        self.reach
164    }
165
166    /// How far each strip is narrowed on both edges, in metres.
167    #[must_use]
168    pub fn inset_metres(&self) -> f64 {
169        self.inset
170    }
171}
172
173/// Whether a listed candidate meets a side's strip.
174#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
175pub enum SidePresence {
176    /// Its footprint surely meets the strip with positive area.
177    Sure,
178    /// It may: the measurement cannot tell.
179    Possible,
180}
181
182/// One candidate beside one side.
183#[derive(Clone, Debug, PartialEq)]
184pub struct SideDistance {
185    candidate: ObjectId,
186    side: RectangleSide,
187    presence: SidePresence,
188    distance: PlanLength,
189}
190
191impl SideDistance {
192    /// `candidate` meets `side`'s strip (surely or possibly), the nearest
193    /// point of its part there `distance` from the centre line.
194    #[must_use]
195    pub fn new(
196        candidate: ObjectId,
197        side: RectangleSide,
198        presence: SidePresence,
199        distance: PlanLength,
200    ) -> Self {
201        Self {
202            candidate,
203            side,
204            presence,
205            distance,
206        }
207    }
208
209    /// The candidate.
210    #[must_use]
211    pub fn candidate(&self) -> &ObjectId {
212        &self.candidate
213    }
214
215    /// The side it lies beside.
216    #[must_use]
217    pub fn side(&self) -> RectangleSide {
218        self.side
219    }
220
221    /// Whether it surely meets the side's strip.
222    #[must_use]
223    pub fn presence(&self) -> SidePresence {
224        self.presence
225    }
226
227    /// Its least distance from the side's centre line within the strip.
228    #[must_use]
229    pub fn distance(&self) -> &PlanLength {
230        &self.distance
231    }
232}
233
234/// The candidates beside an object's sides, with the rectangle they were
235/// measured from.
236#[derive(Clone, Debug, PartialEq)]
237pub struct SideDistances {
238    request: SideDistanceRequest,
239    rectangle: PlanRectangle,
240    distances: Vec<SideDistance>,
241    evidence: Evidence,
242}
243
244impl SideDistances {
245    /// The answer to `request` from `rectangle`.
246    ///
247    /// The rectangle must be the request object's, with a unique
248    /// orientation; each candidate appears at most once per side and only
249    /// if requested; the evidence is exact only when the rectangle and
250    /// every distance are, and every candidate is surely present.
251    pub fn try_new(
252        request: SideDistanceRequest,
253        rectangle: PlanRectangle,
254        mut distances: Vec<SideDistance>,
255        evidence: Evidence,
256    ) -> Result<Self, PlanSpanError> {
257        if rectangle.object() != request.object() {
258            return Err(PlanSpanError::Unavailable(format!(
259                "side distances from a rectangle of {} were returned for {}",
260                rectangle.object(),
261                request.object()
262            )));
263        }
264        if rectangle.orientation() != RectangleOrientation::Unique {
265            return Err(PlanSpanError::Unavailable(format!(
266                "the least-area rectangle of {} is {}, so its sides are not the footprint's own",
267                request.object(),
268                rectangle.orientation().name()
269            )));
270        }
271        distances.sort_by(|a, b| (a.side, &a.candidate).cmp(&(b.side, &b.candidate)));
272        let repeated = distances
273            .windows(2)
274            .any(|pair| pair[0].side == pair[1].side && pair[0].candidate == pair[1].candidate);
275        let unrequested = distances.iter().any(|distance| {
276            request
277                .candidates
278                .binary_search(&distance.candidate)
279                .is_err()
280        });
281        if repeated || unrequested {
282            return Err(PlanSpanError::Unavailable(format!(
283                "side distances of {} name a candidate twice or one not requested",
284                request.object()
285            )));
286        }
287        let exact = rectangle.is_exact()
288            && distances.iter().all(|distance| {
289                distance.distance.is_exact() && distance.presence == SidePresence::Sure
290            });
291        if (evidence.exact && !exact) || evidence.locator.trim().is_empty() {
292            return Err(PlanSpanError::InexactEvidence);
293        }
294        Ok(Self {
295            request,
296            rectangle,
297            distances,
298            evidence,
299        })
300    }
301
302    /// The request answered.
303    #[must_use]
304    pub fn request(&self) -> &SideDistanceRequest {
305        &self.request
306    }
307
308    /// The object's least-area rectangle.
309    #[must_use]
310    pub fn rectangle(&self) -> &PlanRectangle {
311        &self.rectangle
312    }
313
314    /// Every listed candidate, by side and then candidate.
315    #[must_use]
316    pub fn distances(&self) -> &[SideDistance] {
317        &self.distances
318    }
319
320    /// The candidates listed beside `side`.
321    pub fn beside(&self, side: RectangleSide) -> impl Iterator<Item = &SideDistance> {
322        self.distances
323            .iter()
324            .filter(move |distance| distance.side == side)
325    }
326
327    /// Reviewable provenance of the measurement.
328    #[must_use]
329    pub fn evidence(&self) -> &Evidence {
330        &self.evidence
331    }
332}
333
334/// Measures and checks that the answer is the request's.
335pub(crate) fn measure(
336    service: &Arc<dyn PlanSpanService>,
337    request: &SideDistanceRequest,
338) -> Result<SideDistances, PlanSpanError> {
339    let answer = service.measure_side_distances(request)?;
340    if answer.request() != request {
341        return Err(PlanSpanError::Unavailable(format!(
342            "side distances for another request were returned for {}",
343            request.object()
344        )));
345    }
346    Ok(answer)
347}
348
349#[cfg(test)]
350mod tests {
351    use std::sync::Arc;
352
353    use axioval_ir::{Evidence, ObjectId, SourceId};
354
355    use super::{RectangleSide, SideDistance, SideDistanceRequest, SideDistances, SidePresence};
356    use crate::plan_span::{
357        PlanLength, PlanRectangle, PlanSpanError, PlanSpanService, PlanSpanServiceHandle,
358        RectangleOrientation,
359    };
360
361    fn id(local: &str) -> ObjectId {
362        ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
363    }
364
365    fn evidence(exact: bool) -> Evidence {
366        Evidence {
367            source: SourceId::new("cad", "m").unwrap(),
368            locator: "test".into(),
369            exact,
370        }
371    }
372
373    fn rectangle(orientation: RectangleOrientation) -> PlanRectangle {
374        PlanRectangle::try_new(
375            id("wc"),
376            [1.2, 0.35],
377            0.0,
378            [[1.0, 0.0], [0.0, 1.0]],
379            0.0,
380            [(0.2, 0.2), (0.35, 0.35)],
381            orientation,
382            evidence(orientation == RectangleOrientation::Unique),
383        )
384        .unwrap()
385    }
386
387    fn beside(candidate: &str) -> SideDistance {
388        SideDistance::new(
389            id(candidate),
390            RectangleSide::AgainstSecond,
391            SidePresence::Sure,
392            PlanLength::try_new(0.34, 0.36, evidence(false)).unwrap(),
393        )
394    }
395
396    fn request() -> SideDistanceRequest {
397        SideDistanceRequest::try_new(id("wc"), [id("wall")], 1.0, 0.0).unwrap()
398    }
399
400    #[test]
401    fn requests_refuse_the_object_itself_and_bad_lengths() {
402        assert!(SideDistanceRequest::try_new(id("wc"), [id("wc")], 1.0, 0.0).is_err());
403        assert!(SideDistanceRequest::try_new(id("wc"), [], 0.0, 0.0).is_err());
404        assert!(SideDistanceRequest::try_new(id("wc"), [], 1.0, -0.1).is_err());
405    }
406
407    #[test]
408    fn answers_need_a_unique_rectangle_and_requested_candidates_once() {
409        let unique = rectangle(RectangleOrientation::Unique);
410        assert!(
411            SideDistances::try_new(
412                request(),
413                unique.clone(),
414                vec![beside("wall")],
415                evidence(false)
416            )
417            .is_ok()
418        );
419        assert!(
420            SideDistances::try_new(
421                request(),
422                rectangle(RectangleOrientation::Tied),
423                vec![beside("wall")],
424                evidence(false)
425            )
426            .is_err()
427        );
428        assert!(
429            SideDistances::try_new(
430                request(),
431                unique.clone(),
432                vec![beside("door")],
433                evidence(false)
434            )
435            .is_err()
436        );
437        assert!(
438            SideDistances::try_new(
439                request(),
440                unique.clone(),
441                vec![beside("wall"), beside("wall")],
442                evidence(false)
443            )
444            .is_err()
445        );
446        // An interval is never exact evidence.
447        assert!(
448            SideDistances::try_new(request(), unique, vec![beside("wall")], evidence(true))
449                .is_err()
450        );
451        let out = RectangleSide::AgainstSecond.outward(&rectangle(RectangleOrientation::Unique));
452        assert!(
453            out[0].abs() < 1e-12 && (out[1] + 1.0).abs() < 1e-12,
454            "{out:?}"
455        );
456    }
457
458    /// Answers every request as if it had asked for a longer reach.
459    struct Farther;
460
461    impl PlanSpanService for Farther {
462        fn measure_diameter(&self, _: &ObjectId) -> Result<PlanLength, PlanSpanError> {
463            Err(PlanSpanError::Unavailable("unused".into()))
464        }
465        fn measure_span(
466            &self,
467            _: &ObjectId,
468            _: &ObjectId,
469            _: crate::plan_span::PlanSpan,
470        ) -> Result<PlanLength, PlanSpanError> {
471            Err(PlanSpanError::Unavailable("unused".into()))
472        }
473        fn measure_side_distances(
474            &self,
475            request: &SideDistanceRequest,
476        ) -> Result<SideDistances, PlanSpanError> {
477            let farther = SideDistanceRequest::try_new(
478                request.object().clone(),
479                request.candidates().to_vec(),
480                request.reach_metres() + 1.0,
481                request.inset_metres(),
482            )?;
483            SideDistances::try_new(
484                farther,
485                rectangle(RectangleOrientation::Unique),
486                Vec::new(),
487                evidence(false),
488            )
489        }
490    }
491
492    #[test]
493    fn the_handle_refuses_an_answer_to_another_request() {
494        let handle = PlanSpanServiceHandle::new(Arc::new(Farther));
495        assert!(matches!(
496            handle.measure_side_distances(&request()),
497            Err(PlanSpanError::Unavailable(message)) if message.contains("another request")
498        ));
499    }
500}