Skip to main content

axioval_engine/
vertical_extent.rs

1//! Vertical extents: the elevations of an object's bottom and top.
2//!
3//! ADR 0004: this seam measures. Whether two stacked slabs are far enough
4//! apart, or equally spaced, is a rule's judgement over these measurements.
5//!
6//! An elevation is an interval. A mesh that is the object's exact shape
7//! measures exactly; one that approximates curved faces measures within its
8//! declared chord deviation, and a rule must decide from the whole interval,
9//! never from its midpoint.
10//!
11//! The same seam measures an object's extent along any direction
12//! ([`DirectionalExtent`]): its lowest and highest positions projected onto
13//! that direction. Along an object's own placement axis this is the body's
14//! depth in that axis, such as a straight wall's thickness. Vertical is the
15//! special case the rest of this module names.
16
17use std::sync::Arc;
18
19use axioval_ir::{Evidence, ObjectId};
20use thiserror::Error;
21
22use crate::MetricDirection;
23
24/// Failure to measure a vertical extent.
25#[derive(Clone, Debug, Error, PartialEq, Eq)]
26pub enum VerticalExtentError {
27    /// The service holds no geometry for this object.
28    #[error("no geometry for `{0}`")]
29    UnknownObject(ObjectId),
30    /// The geometry could not be measured, for example a body the host could
31    /// not mesh, or an object declared to have no body.
32    #[error("vertical extent unavailable: {0}")]
33    Unavailable(String),
34    /// An elevation is non-finite, its bounds are reversed, the bottom lies
35    /// above the top, or the extent names another object.
36    #[error("vertical extent measurement is invalid")]
37    InvalidMeasurement,
38    /// Evidence reported as exact for an interval, or as inexact for points.
39    #[error("vertical extent evidence does not match its exactness")]
40    InexactEvidence,
41}
42
43/// An elevation in metres, known to lie in `[lower, upper]`.
44#[derive(Clone, Copy, Debug, PartialEq)]
45pub struct ElevationInterval {
46    lower: f64,
47    upper: f64,
48}
49
50impl ElevationInterval {
51    /// An elevation interval; bounds must be finite and ordered.
52    pub fn try_new(lower: f64, upper: f64) -> Result<Self, VerticalExtentError> {
53        if !lower.is_finite() || !upper.is_finite() || lower > upper {
54            return Err(VerticalExtentError::InvalidMeasurement);
55        }
56        Ok(Self { lower, upper })
57    }
58
59    /// An elevation known exactly.
60    pub fn exact(metres: f64) -> Result<Self, VerticalExtentError> {
61        Self::try_new(metres, metres)
62    }
63
64    /// Lowest possible elevation, in metres.
65    #[must_use]
66    pub fn lower_metres(&self) -> f64 {
67        self.lower
68    }
69
70    /// Highest possible elevation, in metres.
71    #[must_use]
72    pub fn upper_metres(&self) -> f64 {
73        self.upper
74    }
75
76    /// Whether the elevation is a single value.
77    #[must_use]
78    #[allow(clippy::float_cmp)]
79    pub fn is_exact(&self) -> bool {
80        self.lower == self.upper
81    }
82}
83
84/// The elevations of one object's lowest and highest points, with evidence.
85#[derive(Clone, Debug, PartialEq)]
86pub struct VerticalExtent {
87    object: ObjectId,
88    bottom: ElevationInterval,
89    top: ElevationInterval,
90    evidence: Evidence,
91}
92
93impl VerticalExtent {
94    /// A vertical extent of `object`.
95    ///
96    /// The bottom may not lie above the top at either bound, and the evidence
97    /// is exact exactly when both elevations are: an interval cannot be exact
98    /// evidence, and points cannot be approximate.
99    pub fn try_new(
100        object: ObjectId,
101        bottom: ElevationInterval,
102        top: ElevationInterval,
103        evidence: Evidence,
104    ) -> Result<Self, VerticalExtentError> {
105        if bottom.lower > top.lower || bottom.upper > top.upper {
106            return Err(VerticalExtentError::InvalidMeasurement);
107        }
108        let exact = bottom.is_exact() && top.is_exact();
109        if evidence.exact != exact || evidence.locator.trim().is_empty() {
110            return Err(VerticalExtentError::InexactEvidence);
111        }
112        Ok(Self {
113            object,
114            bottom,
115            top,
116            evidence,
117        })
118    }
119
120    /// The measured object.
121    #[must_use]
122    pub fn object(&self) -> &ObjectId {
123        &self.object
124    }
125
126    /// Elevation of the object's lowest point.
127    #[must_use]
128    pub fn bottom(&self) -> ElevationInterval {
129        self.bottom
130    }
131
132    /// Elevation of the object's highest point.
133    #[must_use]
134    pub fn top(&self) -> ElevationInterval {
135        self.top
136    }
137
138    /// Whether both elevations are known exactly.
139    #[must_use]
140    pub fn is_exact(&self) -> bool {
141        self.evidence.exact
142    }
143
144    /// Reviewable provenance of the measurement.
145    #[must_use]
146    pub fn evidence(&self) -> &Evidence {
147        &self.evidence
148    }
149
150    /// The height, top less bottom, as `(lower, upper)` metres sure to hold
151    /// the exact difference of the two elevations.
152    #[must_use]
153    pub fn height_metres(&self) -> (f64, f64) {
154        let (low, _) = difference(self.top.lower, self.bottom.upper);
155        let (_, high) = difference(self.top.upper, self.bottom.lower);
156        (low.max(0.0), high.max(0.0))
157    }
158
159    /// Bounds `(lower, upper)`, in metres, on the height of this extent that
160    /// no member of `cover` spans once each is grown by `growth_metres` below
161    /// its bottom and above its top: the vertical difference between this
162    /// object and its counterparts.
163    ///
164    /// The uncovered height only grows as this extent widens and as a cover
165    /// narrows, so the upper bound measures this extent at its widest
166    /// against every cover at its narrowest, and the lower bound the reverse;
167    /// both hold the exact value whatever the elevations are within their
168    /// intervals. A cover whose narrowest extent is empty spans nothing. A
169    /// growth that is negative or not finite is refused.
170    pub fn uncovered_height(
171        &self,
172        cover: &[&VerticalExtent],
173        growth_metres: f64,
174    ) -> Result<(f64, f64), VerticalExtentError> {
175        if !growth_metres.is_finite() || growth_metres < 0.0 {
176            return Err(VerticalExtentError::InvalidMeasurement);
177        }
178        let upper = uncovered_length(
179            (self.bottom.lower, self.top.upper),
180            cover.iter().map(|extent| {
181                (
182                    extent.bottom.upper - growth_metres,
183                    extent.top.lower + growth_metres,
184                )
185            }),
186            true,
187        );
188        let lower = uncovered_length(
189            (self.bottom.upper, self.top.lower),
190            cover.iter().map(|extent| {
191                (
192                    extent.bottom.lower - growth_metres,
193                    extent.top.upper + growth_metres,
194                )
195            }),
196            false,
197        );
198        Ok((lower.min(upper), upper))
199    }
200}
201
202/// The length of `span` outside every interval of `cover`, the upper bound
203/// of each gap's rounded length when `upper`, else the lower.
204fn uncovered_length(span: (f64, f64), cover: impl Iterator<Item = (f64, f64)>, upper: bool) -> f64 {
205    let (start, end) = span;
206    if start >= end {
207        return 0.0;
208    }
209    let mut clipped: Vec<(f64, f64)> = cover
210        .map(|(low, high)| (low.max(start), high.min(end)))
211        .filter(|(low, high)| low < high)
212        .collect();
213    clipped.sort_by(|a, b| a.0.total_cmp(&b.0));
214    let gap = |from: f64, to: f64| {
215        let (low, high) = difference(to, from);
216        if upper { high } else { low }.max(0.0)
217    };
218    let mut cursor = start;
219    let mut uncovered = 0.0;
220    for (low, high) in clipped {
221        if low > cursor {
222            uncovered += gap(cursor, low);
223        }
224        cursor = cursor.max(high);
225    }
226    if cursor < end {
227        uncovered += gap(cursor, end);
228    }
229    uncovered
230}
231
232/// The lowest and highest positions of one object's body projected onto a
233/// direction, in metres along it, with evidence.
234///
235/// Positions are coordinates along `direction` from the canonical origin, so
236/// only their difference, the extent, means anything on its own.
237#[derive(Clone, Debug, PartialEq)]
238pub struct DirectionalExtent {
239    object: ObjectId,
240    direction: MetricDirection,
241    lower: ElevationInterval,
242    upper: ElevationInterval,
243    evidence: Evidence,
244}
245
246impl DirectionalExtent {
247    /// The extent of `object` along `direction`.
248    ///
249    /// As for a [`VerticalExtent`], the lowest position may not lie above
250    /// the highest at either bound, and the evidence is exact exactly when
251    /// both positions are points.
252    pub fn try_new(
253        object: ObjectId,
254        direction: MetricDirection,
255        lower: ElevationInterval,
256        upper: ElevationInterval,
257        evidence: Evidence,
258    ) -> Result<Self, VerticalExtentError> {
259        if lower.lower > upper.lower || lower.upper > upper.upper {
260            return Err(VerticalExtentError::InvalidMeasurement);
261        }
262        let exact = lower.is_exact() && upper.is_exact();
263        if evidence.exact != exact || evidence.locator.trim().is_empty() {
264            return Err(VerticalExtentError::InexactEvidence);
265        }
266        Ok(Self {
267            object,
268            direction,
269            lower,
270            upper,
271            evidence,
272        })
273    }
274
275    /// The measured object.
276    #[must_use]
277    pub fn object(&self) -> &ObjectId {
278        &self.object
279    }
280
281    /// The direction the body was projected onto.
282    #[must_use]
283    pub fn direction(&self) -> MetricDirection {
284        self.direction
285    }
286
287    /// Position of the body's lowest point along the direction.
288    #[must_use]
289    pub fn lower(&self) -> ElevationInterval {
290        self.lower
291    }
292
293    /// Position of the body's highest point along the direction.
294    #[must_use]
295    pub fn upper(&self) -> ElevationInterval {
296        self.upper
297    }
298
299    /// The extent, highest less lowest position, as `(lower, upper)` metres
300    /// sure to hold the exact difference of the two positions.
301    #[must_use]
302    pub fn length_metres(&self) -> (f64, f64) {
303        let (low, _) = difference(self.upper.lower, self.lower.upper);
304        let (_, high) = difference(self.upper.upper, self.lower.lower);
305        (low.max(0.0), high.max(0.0))
306    }
307
308    /// Whether both positions are known exactly.
309    #[must_use]
310    pub fn is_exact(&self) -> bool {
311        self.evidence.exact
312    }
313
314    /// Reviewable provenance of the measurement.
315    #[must_use]
316    pub fn evidence(&self) -> &Evidence {
317        &self.evidence
318    }
319}
320
321/// `minuend - subtrahend` as an interval sure to hold the exact difference:
322/// the rounded difference, widened by one step where rounding moved it.
323fn difference(minuend: f64, subtrahend: f64) -> (f64, f64) {
324    let rounded = minuend - subtrahend;
325    // Two-sum: the exact difference is `rounded + error`.
326    let back = rounded - minuend;
327    let error = (minuend - (rounded - back)) + (-subtrahend - back);
328    if error > 0.0 {
329        (rounded, rounded.next_up())
330    } else if error < 0.0 {
331        (rounded.next_down(), rounded)
332    } else {
333        (rounded, rounded)
334    }
335}
336
337/// Measures the vertical extents of model objects.
338pub trait VerticalExtentService: Send + Sync + 'static {
339    /// The elevations of `object`'s lowest and highest points.
340    fn measure_vertical_extent(
341        &self,
342        object: &ObjectId,
343    ) -> Result<VerticalExtent, VerticalExtentError>;
344
345    /// The lowest and highest positions of `object`'s body along
346    /// `direction`.
347    ///
348    /// A service that measures elevations only refuses; it never answers
349    /// with the vertical extent.
350    fn measure_directional_extent(
351        &self,
352        object: &ObjectId,
353        direction: MetricDirection,
354    ) -> Result<DirectionalExtent, VerticalExtentError> {
355        let _ = (object, direction);
356        Err(VerticalExtentError::Unavailable(
357            "this service measures vertical extents only".into(),
358        ))
359    }
360}
361
362/// Registry handle for a [`VerticalExtentService`].
363#[derive(Clone)]
364pub struct VerticalExtentServiceHandle(Arc<dyn VerticalExtentService>);
365
366impl VerticalExtentServiceHandle {
367    /// Wraps a trusted vertical-extent service.
368    #[must_use]
369    pub fn new(service: Arc<dyn VerticalExtentService>) -> Self {
370        Self(service)
371    }
372
373    /// The vertical extent of `object`. An extent naming another object
374    /// answers a different question and is refused.
375    pub fn measure_vertical_extent(
376        &self,
377        object: &ObjectId,
378    ) -> Result<VerticalExtent, VerticalExtentError> {
379        let extent = self.0.measure_vertical_extent(object)?;
380        if extent.object() != object {
381            return Err(VerticalExtentError::InvalidMeasurement);
382        }
383        Ok(extent)
384    }
385
386    /// The extent of `object` along `direction`. An extent naming another
387    /// object or another direction answers a different question and is
388    /// refused.
389    pub fn measure_directional_extent(
390        &self,
391        object: &ObjectId,
392        direction: MetricDirection,
393    ) -> Result<DirectionalExtent, VerticalExtentError> {
394        let extent = self.0.measure_directional_extent(object, direction)?;
395        if extent.object() != object || extent.direction() != direction {
396            return Err(VerticalExtentError::InvalidMeasurement);
397        }
398        Ok(extent)
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use axioval_ir::SourceId;
406
407    fn id(local: &str) -> ObjectId {
408        ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
409    }
410
411    fn exact() -> Evidence {
412        Evidence::exact(SourceId::new("cad", "m").unwrap(), "vertical-extent:a")
413    }
414
415    fn point(value: f64) -> ElevationInterval {
416        ElevationInterval::exact(value).unwrap()
417    }
418
419    #[test]
420    fn exactness_and_intervals_must_agree() {
421        assert!(VerticalExtent::try_new(id("a"), point(0.0), point(0.2), exact()).is_ok());
422        let widened = ElevationInterval::try_new(0.199, 0.201).unwrap();
423        assert_eq!(
424            VerticalExtent::try_new(id("a"), point(0.0), widened, exact()),
425            Err(VerticalExtentError::InexactEvidence)
426        );
427        let mut approximate = exact();
428        approximate.exact = false;
429        assert!(VerticalExtent::try_new(id("a"), point(0.0), widened, approximate.clone()).is_ok());
430        assert_eq!(
431            VerticalExtent::try_new(id("a"), point(0.0), point(0.2), approximate),
432            Err(VerticalExtentError::InexactEvidence)
433        );
434    }
435
436    #[test]
437    fn incoherent_elevations_are_refused() {
438        assert_eq!(
439            ElevationInterval::try_new(1.0, 0.0),
440            Err(VerticalExtentError::InvalidMeasurement)
441        );
442        assert_eq!(
443            ElevationInterval::try_new(f64::NAN, 0.0),
444            Err(VerticalExtentError::InvalidMeasurement)
445        );
446        assert_eq!(
447            VerticalExtent::try_new(id("a"), point(1.0), point(0.0), exact()),
448            Err(VerticalExtentError::InvalidMeasurement)
449        );
450    }
451
452    struct Other;
453    impl VerticalExtentService for Other {
454        fn measure_vertical_extent(
455            &self,
456            _: &ObjectId,
457        ) -> Result<VerticalExtent, VerticalExtentError> {
458            VerticalExtent::try_new(id("b"), point(0.0), point(1.0), exact())
459        }
460    }
461
462    fn extent(bottom: (f64, f64), top: (f64, f64)) -> VerticalExtent {
463        let mut evidence = exact();
464        #[allow(clippy::float_cmp)]
465        {
466            evidence.exact = bottom.0 == bottom.1 && top.0 == top.1;
467        }
468        VerticalExtent::try_new(
469            id("a"),
470            ElevationInterval::try_new(bottom.0, bottom.1).unwrap(),
471            ElevationInterval::try_new(top.0, top.1).unwrap(),
472            evidence,
473        )
474        .unwrap()
475    }
476
477    #[test]
478    #[allow(clippy::float_cmp)]
479    fn the_uncovered_height_is_what_no_grown_cover_spans() {
480        let wall = extent((0.0, 0.0), (3.0, 3.0));
481        // Nothing covers the whole height.
482        assert_eq!(wall.uncovered_height(&[], 0.0).unwrap(), (3.0, 3.0));
483        // Two overlapping covers leave 0.5 m at the bottom and 0.25 m at the top.
484        let low = extent((0.5, 0.5), (2.0, 2.0));
485        let high = extent((1.5, 1.5), (2.75, 2.75));
486        assert_eq!(
487            wall.uncovered_height(&[&high, &low], 0.0).unwrap(),
488            (0.75, 0.75)
489        );
490        // Grown by 0.25 m the covers reach the top and 0.25 m from the bottom.
491        assert_eq!(
492            wall.uncovered_height(&[&high, &low], 0.25).unwrap(),
493            (0.25, 0.25)
494        );
495        // A cover beyond the extent spans none of it.
496        let above = extent((4.0, 4.0), (5.0, 5.0));
497        assert_eq!(wall.uncovered_height(&[&above], 0.5).unwrap(), (3.0, 3.0));
498        assert!(wall.uncovered_height(&[], -0.1).is_err());
499        assert!(wall.uncovered_height(&[], f64::NAN).is_err());
500    }
501
502    #[test]
503    fn an_uncertain_extent_bounds_its_uncovered_height_from_both_sides() {
504        let wall = extent((-0.01, 0.01), (2.99, 3.01));
505        let cover = extent((-0.01, 0.01), (1.99, 2.01));
506        let (lower, upper) = wall.uncovered_height(&[&cover], 0.0).unwrap();
507        // Exactly 1 m at the nominal elevations; the narrowest wall against
508        // the widest cover leaves 0.98 m, the widest against the narrowest 1.04 m.
509        assert!((lower - 0.98).abs() < 1e-9 && (upper - 1.04).abs() < 1e-9);
510        let (short, tall) = wall.height_metres();
511        assert!((short - 2.98).abs() < 1e-9 && (tall - 3.02).abs() < 1e-9);
512    }
513
514    #[test]
515    fn an_extent_of_another_object_is_refused() {
516        let handle = VerticalExtentServiceHandle::new(Arc::new(Other));
517        assert_eq!(
518            handle.measure_vertical_extent(&id("a")),
519            Err(VerticalExtentError::InvalidMeasurement)
520        );
521    }
522
523    fn along(vector: [f64; 3]) -> MetricDirection {
524        MetricDirection::try_new(vector).unwrap()
525    }
526
527    #[test]
528    fn a_service_measuring_elevations_only_refuses_directions() {
529        let handle = VerticalExtentServiceHandle::new(Arc::new(Other));
530        assert!(matches!(
531            handle.measure_directional_extent(&id("a"), along([0.0, 1.0, 0.0])),
532            Err(VerticalExtentError::Unavailable(_))
533        ));
534    }
535
536    #[test]
537    fn directional_exactness_and_order_must_agree() {
538        let y = along([0.0, 1.0, 0.0]);
539        let extent =
540            DirectionalExtent::try_new(id("a"), y, point(0.1), point(0.4), exact()).unwrap();
541        let (low, high) = extent.length_metres();
542        // 0.4 - 0.1 rounds, so the interval holds the exact difference.
543        assert!(low <= 0.3 && 0.3 <= high && high - low <= 2.0 * f64::EPSILON);
544        assert_eq!(
545            DirectionalExtent::try_new(id("a"), y, point(0.4), point(0.1), exact()),
546            Err(VerticalExtentError::InvalidMeasurement)
547        );
548        let widened = ElevationInterval::try_new(0.39, 0.41).unwrap();
549        assert_eq!(
550            DirectionalExtent::try_new(id("a"), y, point(0.1), widened, exact()),
551            Err(VerticalExtentError::InexactEvidence)
552        );
553        let exact_length = DirectionalExtent::try_new(id("a"), y, point(1.0), point(1.5), exact())
554            .unwrap()
555            .length_metres();
556        assert_eq!(exact_length, (0.5, 0.5));
557    }
558
559    struct Sideways;
560    impl VerticalExtentService for Sideways {
561        fn measure_vertical_extent(
562            &self,
563            object: &ObjectId,
564        ) -> Result<VerticalExtent, VerticalExtentError> {
565            VerticalExtent::try_new(object.clone(), point(0.0), point(1.0), exact())
566        }
567        fn measure_directional_extent(
568            &self,
569            object: &ObjectId,
570            _: MetricDirection,
571        ) -> Result<DirectionalExtent, VerticalExtentError> {
572            DirectionalExtent::try_new(
573                object.clone(),
574                along([1.0, 0.0, 0.0]),
575                point(0.0),
576                point(1.0),
577                exact(),
578            )
579        }
580    }
581
582    #[test]
583    fn an_extent_along_another_direction_is_refused() {
584        let handle = VerticalExtentServiceHandle::new(Arc::new(Sideways));
585        assert!(
586            handle
587                .measure_directional_extent(&id("a"), along([1.0, 0.0, 0.0]))
588                .is_ok()
589        );
590        assert_eq!(
591            handle.measure_directional_extent(&id("a"), along([0.0, 1.0, 0.0])),
592            Err(VerticalExtentError::InvalidMeasurement)
593        );
594    }
595}