Skip to main content

axioval_engine/
facade_area.rs

1//! Facade areas: how much of an object's surface faces the outside.
2//!
3//! ADR 0004: this seam measures. Whether a storey has enough facade, or too
4//! much of it glazed, is a rule's judgement over these measurements.
5//!
6//! An object's facade area is the area of its steep faces that face away
7//! from the interior: neither against another body nor looking into a
8//! space. Which objects are spaces is semantic, so the host declares them;
9//! whether an object is external is the rule's selection, never this seam's.
10//!
11//! An area is an interval. A mesh that is the object's exact shape measures
12//! exactly; one that approximates curved faces measures within a bound the
13//! adapter derives from its declared chord deviation, and a rule must decide
14//! from the whole interval, never from its midpoint.
15
16use std::sync::Arc;
17
18use axioval_ir::{Evidence, ObjectId};
19use thiserror::Error;
20
21/// Failure to measure a facade area.
22#[derive(Clone, Debug, Error, PartialEq, Eq)]
23pub enum FacadeAreaError {
24    /// The service holds no geometry for this object.
25    #[error("no geometry for `{0}`")]
26    UnknownObject(ObjectId),
27    /// The geometry could not be measured, for example an object without a
28    /// body, a mesh that fails its audit, or an unmeasured body that could
29    /// cover the object's faces.
30    #[error("facade area unavailable: {0}")]
31    Unavailable(String),
32    /// A measurement is non-finite, negative, its bounds are reversed, or it
33    /// names another object.
34    #[error("facade area measurement is invalid")]
35    InvalidMeasurement,
36    /// An interval was reported as exact, or evidence as inexact, inconsistently.
37    #[error("facade area evidence does not match its exactness")]
38    InexactEvidence,
39}
40
41/// The facade area of one object in square metres, with its evidence.
42#[derive(Clone, Debug, PartialEq)]
43pub struct FacadeArea {
44    object: ObjectId,
45    lower: f64,
46    upper: f64,
47    evidence: Evidence,
48}
49
50impl FacadeArea {
51    /// The facade area of `object`, known to lie in `[lower, upper]`.
52    ///
53    /// The evidence is exact exactly when the bounds coincide: an interval
54    /// cannot be exact evidence, and a point cannot be approximate.
55    pub fn try_new(
56        object: ObjectId,
57        lower: f64,
58        upper: f64,
59        evidence: Evidence,
60    ) -> Result<Self, FacadeAreaError> {
61        if !lower.is_finite() || !upper.is_finite() || lower < 0.0 || lower > upper {
62            return Err(FacadeAreaError::InvalidMeasurement);
63        }
64        #[allow(clippy::float_cmp)]
65        let exact = lower == upper;
66        if evidence.exact != exact || evidence.locator.trim().is_empty() {
67            return Err(FacadeAreaError::InexactEvidence);
68        }
69        Ok(Self {
70            object,
71            lower,
72            upper,
73            evidence,
74        })
75    }
76
77    /// The measured object.
78    #[must_use]
79    pub fn object(&self) -> &ObjectId {
80        &self.object
81    }
82
83    /// Smallest area the facade can have, in square metres.
84    #[must_use]
85    pub fn lower_square_metres(&self) -> f64 {
86        self.lower
87    }
88
89    /// Largest area the facade can have, in square metres.
90    #[must_use]
91    pub fn upper_square_metres(&self) -> f64 {
92        self.upper
93    }
94
95    /// Whether the area is known exactly.
96    #[must_use]
97    pub fn is_exact(&self) -> bool {
98        self.evidence.exact
99    }
100
101    /// Reviewable provenance of the measurement.
102    #[must_use]
103    pub fn evidence(&self) -> &Evidence {
104        &self.evidence
105    }
106}
107
108/// Measures the outward-facing surface area of model objects.
109pub trait FacadeAreaService: Send + Sync + 'static {
110    /// The area of `object`'s faces that face the outside.
111    fn measure_facade_area(&self, object: &ObjectId) -> Result<FacadeArea, FacadeAreaError>;
112    /// The area of `object`'s largest plane face: all of its body's faces
113    /// lying in one oriented plane, such as a wall's side less its openings
114    /// or a slab's top. The answer is a [`FacadeArea`] of that face.
115    ///
116    /// The default refuses with [`FacadeAreaError::Unavailable`], so a
117    /// service that does not measure faces fails closed.
118    fn measure_face_area(&self, object: &ObjectId) -> Result<FacadeArea, FacadeAreaError> {
119        let _ = object;
120        Err(FacadeAreaError::Unavailable(
121            "face areas are not measured by this service".into(),
122        ))
123    }
124}
125
126/// Registry handle for a [`FacadeAreaService`].
127#[derive(Clone)]
128pub struct FacadeAreaServiceHandle(Arc<dyn FacadeAreaService>);
129
130impl FacadeAreaServiceHandle {
131    /// Wraps a trusted facade-area service.
132    #[must_use]
133    pub fn new(service: Arc<dyn FacadeAreaService>) -> Self {
134        Self(service)
135    }
136
137    /// The facade area of `object`. An area naming another object answers a
138    /// different question and is refused.
139    pub fn measure_facade_area(&self, object: &ObjectId) -> Result<FacadeArea, FacadeAreaError> {
140        let area = self.0.measure_facade_area(object)?;
141        if area.object() != object {
142            return Err(FacadeAreaError::InvalidMeasurement);
143        }
144        Ok(area)
145    }
146
147    /// The area of `object`'s largest plane face. An area naming another
148    /// object answers a different question and is refused.
149    pub fn measure_face_area(&self, object: &ObjectId) -> Result<FacadeArea, FacadeAreaError> {
150        let area = self.0.measure_face_area(object)?;
151        if area.object() != object {
152            return Err(FacadeAreaError::InvalidMeasurement);
153        }
154        Ok(area)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use axioval_ir::SourceId;
162
163    fn id(local: &str) -> ObjectId {
164        ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
165    }
166
167    fn exact() -> Evidence {
168        Evidence::exact(SourceId::new("cad", "m").unwrap(), "facade-area:a")
169    }
170
171    #[test]
172    fn exactness_and_bounds_must_agree() {
173        assert!(FacadeArea::try_new(id("a"), 2.0, 2.0, exact()).is_ok());
174        assert_eq!(
175            FacadeArea::try_new(id("a"), 1.0, 2.0, exact()),
176            Err(FacadeAreaError::InexactEvidence)
177        );
178        let mut approximate = exact();
179        approximate.exact = false;
180        assert!(FacadeArea::try_new(id("a"), 1.0, 2.0, approximate.clone()).is_ok());
181        assert_eq!(
182            FacadeArea::try_new(id("a"), 2.0, 2.0, approximate),
183            Err(FacadeAreaError::InexactEvidence)
184        );
185    }
186
187    #[test]
188    fn reversed_negative_or_non_finite_bounds_are_refused() {
189        for (lower, upper) in [
190            (2.0, 1.0),
191            (-1.0, 1.0),
192            (0.0, f64::NAN),
193            (0.0, f64::INFINITY),
194        ] {
195            assert_eq!(
196                FacadeArea::try_new(id("a"), lower, upper, exact()),
197                Err(FacadeAreaError::InvalidMeasurement),
198                "{lower} {upper}"
199            );
200        }
201    }
202
203    struct Other;
204    impl FacadeAreaService for Other {
205        fn measure_facade_area(&self, _: &ObjectId) -> Result<FacadeArea, FacadeAreaError> {
206            FacadeArea::try_new(id("b"), 1.0, 1.0, exact())
207        }
208    }
209
210    #[test]
211    fn an_area_of_another_object_is_refused() {
212        let handle = FacadeAreaServiceHandle::new(Arc::new(Other));
213        assert_eq!(
214            handle.measure_facade_area(&id("a")),
215            Err(FacadeAreaError::InvalidMeasurement)
216        );
217    }
218}