Skip to main content

axioval_engine/
door_leaves.rs

1//! Door and window leaves: how each leaf of a door or panel of a window
2//! moves, which side it hangs on and the sectors it sweeps.
3//!
4//! Door clearances, swing directions, escape doors and landings are judged
5//! from a door's leaves, and a casement's swing from a window's. This seam
6//! supplies them through [`crate::ObjectFrameService::leaves`], which
7//! refuses by default: a source that states no operation answers
8//! [`DoorLeavesError::Unsupported`], never an empty set of leaves. The
9//! types keep their door names; a window's panels are leaves alike.
10//!
11//! A window panel also states its height along `up` and, when it tilts on
12//! a top or bottom hinge, the tilt sector it sweeps in the vertical plane
13//! through that hinge ([`DoorLeaf::tilt`]). Its side swing lies in the
14//! plane of its bottom edge, at sill height, and it sweeps that sector up
15//! its whole height.
16//!
17//! Everything is in canonical metres and world coordinates. A leaf is
18//! described by its closed position (a segment from `origin` along `along`,
19//! `width` long), the direction it opens towards (`opening`) and the
20//! door's up axis. These three axes are the source's placement axes: they
21//! are orthonormal but may be left-handed, since a source may mirror a door.
22//! A hinged leaf also carries the side its hinge is on, seen from above
23//! looking along `opening`, and the [`SwingSector`] it sweeps.
24//!
25//! The sector is the symbolic swing the source's convention defines: a
26//! quarter disc from the closed leaf to the leaf standing open at right
27//! angles, or a half disc for a double-acting leaf. It is not a hardware
28//! stop angle, which sources do not record.
29
30use axioval_ir::{Evidence, ObjectId, SourceId};
31use thiserror::Error;
32
33use crate::MetricDirection;
34use crate::services::reviewable_exact_evidence;
35
36/// Tolerance on unit length, orthogonality and equal lengths, relative to
37/// the magnitudes compared.
38const TOLERANCE: f64 = 1.0e-9;
39
40/// A plan polygon's vertices `[x, y]` in metres, in order, not repeating
41/// the first.
42pub type PlanRing = Vec<[f64; 2]>;
43
44/// Failure to supply a door's leaves.
45#[derive(Clone, Debug, Error, PartialEq, Eq)]
46pub enum DoorLeavesError {
47    /// The service does not cover the door's source.
48    #[error("door-leaf service does not cover source `{0}`")]
49    UncoveredSource(SourceId),
50    /// The object is not part of the source.
51    #[error("object `{0}` is not in the source")]
52    UnknownObject(ObjectId),
53    /// The object is neither a door nor a window, so it has no leaves.
54    #[error("`{0}` is not a door or window")]
55    NotADoor(ObjectId),
56    /// The source does not state what the leaves need: an operation type,
57    /// an overall width or panel definitions. Nothing is defaulted.
58    #[error("the source does not state the door's leaves: {0}")]
59    NotStated(String),
60    /// The source states the door's operation, but not precisely enough to
61    /// place its leaves (a folding or revolving door, an operation whose
62    /// direction the source's specification leaves open, contradictory
63    /// panels).
64    #[error("the door's leaves cannot be derived exactly: {0}")]
65    Refused(String),
66    /// The door, its placement or a unit cannot be read exactly.
67    #[error("the door cannot be read exactly: {0}")]
68    Unreadable(String),
69    /// The service supplies no door leaves.
70    #[error("the object-frame service supplies no door leaves")]
71    Unsupported,
72    /// The leaves are malformed: not orthonormal, a sector not matching its
73    /// leaf, or a hinge side without a swing.
74    #[error("door leaves are invalid: {0}")]
75    InvalidLeaves(String),
76    /// The evidence is not exact, not reviewable, or from another source.
77    #[error("door-leaf evidence is not exact and reviewable")]
78    InexactEvidence,
79    /// The service answered for another door.
80    #[error("door-leaf service answered for another door")]
81    ResponseRequestMismatch,
82}
83
84/// Left or right, seen from above looking along a leaf's opening direction.
85#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
86pub enum HingeSide {
87    /// The left-hand side.
88    Left,
89    /// The right-hand side.
90    Right,
91}
92
93impl HingeSide {
94    /// The side's spelling in messages and evidence.
95    #[must_use]
96    pub fn name(self) -> &'static str {
97        match self {
98            Self::Left => "left",
99            Self::Right => "right",
100        }
101    }
102}
103
104/// Where a leaf sits in its door, as the source states it.
105#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
106pub enum LeafPosition {
107    /// At the low end of the door's width.
108    Left,
109    /// In the middle.
110    Middle,
111    /// At the high end of the door's width.
112    Right,
113    /// At the bottom of a window.
114    Bottom,
115    /// At the top of a window.
116    Top,
117    /// Not stated.
118    NotDefined,
119}
120
121/// How a leaf moves.
122#[derive(Clone, Copy, Debug, PartialEq)]
123pub enum LeafMotion {
124    /// Swings about its hinge towards its opening direction.
125    Swing,
126    /// Swings about its hinge to both sides.
127    DoubleSwing,
128    /// Turns on its side hinge like [`Self::Swing`], or tilts on its
129    /// bottom hinge (a tilt-and-turn window panel).
130    TiltAndTurn,
131    /// Tilts on a top or bottom hinge only (a top- or bottom-hung window
132    /// panel).
133    Tilt,
134    /// Slides in the given direction when opening: along the door's width,
135    /// or along a window panel's width or height.
136    Slide(MetricDirection),
137    /// Rolls up out of the opening, sweeping no floor area.
138    RollUp,
139    /// Is taken out rather than opened (a removable window casement).
140    Removable,
141    /// Does not open.
142    Fixed,
143}
144
145impl LeafMotion {
146    /// Whether the leaf turns on a side hinge and sweeps a [`SwingSector`]
147    /// ([`DoorLeaf::swing`]).
148    #[must_use]
149    pub fn is_hinged(self) -> bool {
150        matches!(self, Self::Swing | Self::DoubleSwing | Self::TiltAndTurn)
151    }
152
153    /// Whether the leaf tilts on a top or bottom hinge and sweeps a tilt
154    /// sector ([`DoorLeaf::tilt`]).
155    #[must_use]
156    pub fn tilts(self) -> bool {
157        matches!(self, Self::TiltAndTurn | Self::Tilt)
158    }
159
160    /// The motion's spelling in messages and evidence.
161    #[must_use]
162    pub fn name(self) -> &'static str {
163        match self {
164            Self::Swing => "swinging",
165            Self::DoubleSwing => "double-acting",
166            Self::TiltAndTurn => "tilt-and-turn",
167            Self::Tilt => "tilting",
168            Self::Slide(_) => "sliding",
169            Self::RollUp => "rolling up",
170            Self::Removable => "removable",
171            Self::Fixed => "fixed",
172        }
173    }
174}
175
176fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
177    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
178}
179
180fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
181    [
182        a[1] * b[2] - a[2] * b[1],
183        a[2] * b[0] - a[0] * b[2],
184        a[0] * b[1] - a[1] * b[0],
185    ]
186}
187
188fn finite(point: [f64; 3]) -> bool {
189    point.iter().all(|c| c.is_finite())
190}
191
192fn invalid(message: impl Into<String>) -> DoorLeavesError {
193    DoorLeavesError::InvalidLeaves(message.into())
194}
195
196/// The floor sector a hinged leaf sweeps, in world metres.
197///
198/// A single-swing leaf sweeps the quarter disc of `radius` about `hinge`
199/// from `closed` (hinge towards the closed leaf's free edge) to `open` (the
200/// leaf standing open at right angles). A double-acting leaf sweeps the
201/// half disc from the reverse of `open`, through `closed`, to `open`.
202#[derive(Clone, Copy, Debug, PartialEq)]
203pub struct SwingSector {
204    hinge: [f64; 3],
205    radius: f64,
206    closed: MetricDirection,
207    open: MetricDirection,
208    double_acting: bool,
209}
210
211impl SwingSector {
212    /// A sector; `closed` and `open` must be perpendicular and the radius
213    /// positive.
214    pub fn try_new(
215        hinge: [f64; 3],
216        radius: f64,
217        closed: MetricDirection,
218        open: MetricDirection,
219        double_acting: bool,
220    ) -> Result<Self, DoorLeavesError> {
221        if !finite(hinge) || !radius.is_finite() || radius <= 0.0 {
222            return Err(invalid(
223                "a sector needs a finite hinge and a positive radius",
224            ));
225        }
226        if dot(closed.components(), open.components()).abs() > TOLERANCE {
227            return Err(invalid(
228                "a sector's closed and open directions are not perpendicular",
229            ));
230        }
231        Ok(Self {
232            hinge,
233            radius,
234            closed,
235            open,
236            double_acting,
237        })
238    }
239
240    /// The hinge, the sector's centre.
241    #[must_use]
242    pub fn hinge(&self) -> [f64; 3] {
243        self.hinge
244    }
245
246    /// The radius, the leaf's width.
247    #[must_use]
248    pub fn radius_metres(&self) -> f64 {
249        self.radius
250    }
251
252    /// From the hinge towards the closed leaf's free edge.
253    #[must_use]
254    pub fn closed(&self) -> MetricDirection {
255        self.closed
256    }
257
258    /// From the hinge along the leaf standing open at right angles.
259    #[must_use]
260    pub fn open(&self) -> MetricDirection {
261        self.open
262    }
263
264    /// Whether the leaf swings to both sides.
265    #[must_use]
266    pub fn is_double_acting(&self) -> bool {
267        self.double_acting
268    }
269
270    /// The swept angle in radians: π/2, or π for a double-acting leaf.
271    #[must_use]
272    pub fn sweep(&self) -> f64 {
273        if self.double_acting {
274            std::f64::consts::PI
275        } else {
276            std::f64::consts::FRAC_PI_2
277        }
278    }
279
280    /// Unit direction from the hinge at `angle` radians along the sweep:
281    /// the first boundary ray (`closed`, or the reverse of `open` for a
282    /// double-acting leaf) at zero, `open` at [`Self::sweep`].
283    #[must_use]
284    pub fn direction_at(&self, angle: f64) -> [f64; 3] {
285        let (closed, open) = (self.closed.components(), self.open.components());
286        // Measured from `closed`: a double-acting sweep starts a quarter
287        // turn before it.
288        let from_closed = if self.double_acting {
289            angle - std::f64::consts::FRAC_PI_2
290        } else {
291            angle
292        };
293        let (sin, cos) = from_closed.sin_cos();
294        [
295            cos * closed[0] + sin * open[0],
296            cos * closed[1] + sin * open[1],
297            cos * closed[2] + sin * open[2],
298        ]
299    }
300
301    /// Whether the sector lies in a horizontal plane (its rotation axis is
302    /// vertical), so that it has a plan footprint of its own shape.
303    #[must_use]
304    pub fn is_horizontal(&self) -> bool {
305        let axis = cross(self.closed.components(), self.open.components());
306        (axis[2].abs() - 1.0).abs() <= 1.0e-6
307    }
308
309    /// Two convex plan polygons bracketing the sector's footprint, each
310    /// anticlockwise: the first inscribed (every point lies in the sector),
311    /// the second circumscribed (the sector lies in it). `segments` chords
312    /// or tangents approximate each quarter turn; their radial gap is at
313    /// most `radius · (1 / cos(π / (4 · segments)) − 1)`.
314    ///
315    /// `None` for a sector that is not horizontal or for no segments.
316    #[must_use]
317    pub fn plan_bounds(&self, segments: usize) -> Option<(PlanRing, PlanRing)> {
318        if segments == 0 || !self.is_horizontal() {
319            return None;
320        }
321        let quarters = if self.double_acting { 2 } else { 1 };
322        let count = segments * quarters;
323        #[allow(clippy::cast_precision_loss)]
324        let step = self.sweep() / count as f64;
325        let [hx, hy, _] = self.hinge;
326        let at = |angle: f64, radius: f64| {
327            let [dx, dy, _] = self.direction_at(angle);
328            [hx + radius * dx, hy + radius * dy]
329        };
330        let mut inner = vec![[hx, hy]];
331        let mut outer = vec![[hx, hy]];
332        // Tangents at the mid-angles meet at `r / cos(step / 2)`.
333        let reach = self.radius / (step / 2.0).cos();
334        outer.push(at(0.0, self.radius));
335        for index in 0..=count {
336            #[allow(clippy::cast_precision_loss)]
337            let angle = step * index as f64;
338            inner.push(at(angle, self.radius));
339            if index < count {
340                outer.push(at(angle + step / 2.0, reach));
341            }
342        }
343        outer.push(at(self.sweep(), self.radius));
344        for polygon in [&mut inner, &mut outer] {
345            if signed_area(polygon) < 0.0 {
346                polygon.reverse();
347            }
348        }
349        Some((inner, outer))
350    }
351}
352
353/// Twice the signed area of a plan polygon: positive when anticlockwise.
354fn signed_area(polygon: &[[f64; 2]]) -> f64 {
355    polygon
356        .iter()
357        .zip(polygon.iter().cycle().skip(1))
358        .map(|(a, b)| a[0] * b[1] - b[0] * a[1])
359        .sum()
360}
361
362/// One door leaf, in world metres.
363#[derive(Clone, Debug, PartialEq)]
364pub struct DoorLeaf {
365    position: LeafPosition,
366    motion: LeafMotion,
367    origin: [f64; 3],
368    along: MetricDirection,
369    opening: MetricDirection,
370    up: MetricDirection,
371    width: f64,
372    depth: Option<f64>,
373    hinge_side: Option<HingeSide>,
374    swing: Option<SwingSector>,
375    height: Option<f64>,
376    tilt: Option<SwingSector>,
377}
378
379impl DoorLeaf {
380    /// A leaf whose closed position runs from `origin` along `along` for
381    /// `width` metres, opening towards `opening`, with the door's `up`.
382    ///
383    /// The three axes must be orthonormal (either handedness). A hinged
384    /// leaf needs a hinge side and a sector whose radius is the width,
385    /// whose open direction is `opening` and whose closed direction runs
386    /// along the leaf; every other leaf has neither. A sliding leaf slides
387    /// along `along` or against it (a window panel also along `up`).
388    /// `depth`, the leaf's thickness where the source states it, must be
389    /// positive. A window panel adds its height with [`Self::with_height`]
390    /// and, when it tilts, its tilt sector with [`Self::with_tilt`].
391    #[allow(clippy::too_many_arguments)]
392    pub fn try_new(
393        position: LeafPosition,
394        motion: LeafMotion,
395        origin: [f64; 3],
396        along: MetricDirection,
397        opening: MetricDirection,
398        up: MetricDirection,
399        width: f64,
400        depth: Option<f64>,
401        hinge_side: Option<HingeSide>,
402        swing: Option<SwingSector>,
403    ) -> Result<Self, DoorLeavesError> {
404        let (x, y, z) = (along.components(), opening.components(), up.components());
405        if dot(x, y).abs() > TOLERANCE || dot(y, z).abs() > TOLERANCE || dot(z, x).abs() > TOLERANCE
406        {
407            return Err(invalid("a leaf's axes are not orthonormal"));
408        }
409        if !finite(origin) || !width.is_finite() || width <= 0.0 {
410            return Err(invalid("a leaf needs a finite origin and a positive width"));
411        }
412        if depth.is_some_and(|depth| !depth.is_finite() || depth <= 0.0) {
413            return Err(invalid("a leaf's stated depth must be positive"));
414        }
415        match (motion, hinge_side, &swing) {
416            (
417                LeafMotion::Swing | LeafMotion::DoubleSwing | LeafMotion::TiltAndTurn,
418                Some(_),
419                Some(sector),
420            ) => {
421                let double = matches!(motion, LeafMotion::DoubleSwing);
422                let same = |a: [f64; 3], b: [f64; 3]| {
423                    a.iter().zip(b).all(|(a, b)| (a - b).abs() <= TOLERANCE)
424                };
425                if sector.is_double_acting() != double
426                    || (sector.radius_metres() - width).abs() > TOLERANCE * width.max(1.0)
427                    || !same(sector.open().components(), y)
428                    || dot(sector.closed().components(), x).abs() < 1.0 - TOLERANCE
429                {
430                    return Err(invalid(
431                        "a leaf's sector does not match its width, motion or axes",
432                    ));
433                }
434            }
435            (LeafMotion::Swing | LeafMotion::DoubleSwing | LeafMotion::TiltAndTurn, _, _) => {
436                return Err(invalid("a hinged leaf needs a hinge side and a sector"));
437            }
438            (_, None, None) => {
439                if let LeafMotion::Slide(direction) = motion
440                    && dot(direction.components(), x).abs() < 1.0 - TOLERANCE
441                    && dot(direction.components(), z).abs() < 1.0 - TOLERANCE
442                {
443                    return Err(invalid("a leaf slides along its width or height"));
444                }
445            }
446            _ => return Err(invalid("only a hinged leaf has a hinge side or a sector")),
447        }
448        Ok(Self {
449            position,
450            motion,
451            origin,
452            along,
453            opening,
454            up,
455            width,
456            depth,
457            hinge_side,
458            swing,
459            height: None,
460            tilt: None,
461        })
462    }
463
464    /// The leaf with its height along `up`, in metres: a window panel's,
465    /// which must be positive.
466    pub fn with_height(mut self, height: f64) -> Result<Self, DoorLeavesError> {
467        if !height.is_finite() || height <= 0.0 {
468            return Err(invalid("a leaf's height must be positive"));
469        }
470        self.height = Some(height);
471        Ok(self)
472    }
473
474    /// The leaf with the sector it sweeps tilting on a top or bottom hinge.
475    ///
476    /// Only a tilting leaf ([`LeafMotion::tilts`]) with a height has one:
477    /// its radius is the height, its `open` the leaf's opening direction
478    /// and its `closed` runs along `up`, up from a bottom hinge or down
479    /// from a top one. The leaf sweeps it along its width.
480    pub fn with_tilt(mut self, tilt: SwingSector) -> Result<Self, DoorLeavesError> {
481        let Some(height) = self.height.filter(|_| self.motion.tilts()) else {
482            return Err(invalid("only a tilting leaf with a height has a tilt"));
483        };
484        let same =
485            |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(a, b)| (a - b).abs() <= TOLERANCE);
486        if tilt.is_double_acting()
487            || (tilt.radius_metres() - height).abs() > TOLERANCE * height.max(1.0)
488            || !same(tilt.open().components(), self.opening.components())
489            || dot(tilt.closed().components(), self.up.components()).abs() < 1.0 - TOLERANCE
490        {
491            return Err(invalid("a leaf's tilt does not match its height or axes"));
492        }
493        self.tilt = Some(tilt);
494        Ok(self)
495    }
496
497    /// Where the leaf sits in its door.
498    #[must_use]
499    pub fn position(&self) -> LeafPosition {
500        self.position
501    }
502
503    /// How the leaf moves.
504    #[must_use]
505    pub fn motion(&self) -> LeafMotion {
506        self.motion
507    }
508
509    /// The closed leaf's end at the low end of `along`.
510    #[must_use]
511    pub fn origin(&self) -> [f64; 3] {
512        self.origin
513    }
514
515    /// The door's width direction; the closed leaf runs along it.
516    #[must_use]
517    pub fn along(&self) -> MetricDirection {
518        self.along
519    }
520
521    /// The direction the leaf opens towards: the side a single-swing leaf
522    /// sweeps.
523    #[must_use]
524    pub fn opening(&self) -> MetricDirection {
525        self.opening
526    }
527
528    /// The door's up axis.
529    #[must_use]
530    pub fn up(&self) -> MetricDirection {
531        self.up
532    }
533
534    /// Whether the axes are left-handed: the source mirrors the door.
535    #[must_use]
536    pub fn is_mirrored(&self) -> bool {
537        dot(
538            cross(self.along.components(), self.opening.components()),
539            self.up.components(),
540        ) < 0.0
541    }
542
543    /// The leaf's width in metres.
544    #[must_use]
545    pub fn width_metres(&self) -> f64 {
546        self.width
547    }
548
549    /// The leaf's thickness in metres, where the source states it.
550    #[must_use]
551    pub fn depth_metres(&self) -> Option<f64> {
552        self.depth
553    }
554
555    /// The closed leaf's two ends.
556    #[must_use]
557    pub fn closed_edge(&self) -> ([f64; 3], [f64; 3]) {
558        let [dx, dy, dz] = self.along.components();
559        let [ox, oy, oz] = self.origin;
560        (
561            self.origin,
562            [
563                ox + self.width * dx,
564                oy + self.width * dy,
565                oz + self.width * dz,
566            ],
567        )
568    }
569
570    /// The side the hinge is on, seen from above looking along
571    /// [`Self::opening`]; `None` for a leaf without a hinge.
572    #[must_use]
573    pub fn hinge_side(&self) -> Option<HingeSide> {
574        self.hinge_side
575    }
576
577    /// The sector a hinged leaf sweeps; `None` for any other leaf.
578    #[must_use]
579    pub fn swing(&self) -> Option<&SwingSector> {
580        self.swing.as_ref()
581    }
582
583    /// The leaf's height along `up` in metres, where the source states it
584    /// (a window panel's).
585    #[must_use]
586    pub fn height_metres(&self) -> Option<f64> {
587        self.height
588    }
589
590    /// The sector a tilting leaf sweeps in the vertical plane through one
591    /// end of its top or bottom hinge; `None` for any other leaf.
592    #[must_use]
593    pub fn tilt(&self) -> Option<&SwingSector> {
594        self.tilt.as_ref()
595    }
596}
597
598/// Every leaf of one door, with provenance.
599#[derive(Clone, Debug, PartialEq)]
600pub struct DoorLeaves {
601    door: ObjectId,
602    operation: String,
603    overall_width: f64,
604    lining_thickness: Option<f64>,
605    leaves: Vec<DoorLeaf>,
606    evidence: Evidence,
607}
608
609impl DoorLeaves {
610    /// The leaves of `door`, operated as `operation` (the source's own
611    /// name for it), `overall_width` metres wide, with the lining thickness
612    /// the source states.
613    ///
614    /// A door has at least one leaf, every tilting leaf its tilt, and the
615    /// evidence must be exact, reviewable and from the door's source: the
616    /// leaves are derived from what the source states, never estimated.
617    /// For a window, `door` is the window, `operation` its partitioning and
618    /// the leaves its panels.
619    pub fn try_new(
620        door: ObjectId,
621        operation: impl Into<String>,
622        overall_width: f64,
623        lining_thickness: Option<f64>,
624        leaves: Vec<DoorLeaf>,
625        evidence: Evidence,
626    ) -> Result<Self, DoorLeavesError> {
627        let operation = operation.into();
628        if operation.trim().is_empty() || leaves.is_empty() {
629            return Err(invalid("a door has an operation and at least one leaf"));
630        }
631        if leaves
632            .iter()
633            .any(|leaf| leaf.motion.tilts() && leaf.tilt.is_none())
634        {
635            return Err(invalid("a tilting leaf needs its tilt sector"));
636        }
637        if !overall_width.is_finite() || overall_width <= 0.0 {
638            return Err(invalid("a door's overall width must be positive"));
639        }
640        if lining_thickness.is_some_and(|thickness| !thickness.is_finite() || thickness < 0.0) {
641            return Err(invalid("a lining thickness must not be negative"));
642        }
643        if !reviewable_exact_evidence(&evidence) || evidence.source != door.source {
644            return Err(DoorLeavesError::InexactEvidence);
645        }
646        Ok(Self {
647            door,
648            operation,
649            overall_width,
650            lining_thickness,
651            leaves,
652            evidence,
653        })
654    }
655
656    /// The door.
657    #[must_use]
658    pub fn door(&self) -> &ObjectId {
659        &self.door
660    }
661
662    /// The operation type, as the source names it (`SINGLE_SWING_LEFT`).
663    #[must_use]
664    pub fn operation(&self) -> &str {
665        &self.operation
666    }
667
668    /// The door's overall width in metres, lining included.
669    #[must_use]
670    pub fn overall_width_metres(&self) -> f64 {
671        self.overall_width
672    }
673
674    /// The lining's thickness across the opening in metres, where the
675    /// source states it.
676    #[must_use]
677    pub fn lining_thickness_metres(&self) -> Option<f64> {
678        self.lining_thickness
679    }
680
681    /// The leaves, in the source's order.
682    #[must_use]
683    pub fn leaves(&self) -> &[DoorLeaf] {
684        &self.leaves
685    }
686
687    /// The leaves that swing on a side hinge: single-swing, double-acting
688    /// and tilt-and-turn.
689    pub fn hinged(&self) -> impl Iterator<Item = &DoorLeaf> {
690        self.leaves.iter().filter(|leaf| leaf.motion.is_hinged())
691    }
692
693    /// Reviewable provenance.
694    #[must_use]
695    pub fn evidence(&self) -> &Evidence {
696        &self.evidence
697    }
698}
699
700/// How far below a floor a swept sector's hinge may lie and still stand
701/// on that floor: a door's leaf starts at its sill, which may sit below
702/// the finished floor of the space it opens into, but never a storey
703/// below it.
704pub const SWEPT_FLOOR_REACH_METRES: f64 = 0.5;
705
706/// The floor sectors a door's hinged leaves sweep, counted as obstacles by
707/// a free-space or walkability request whose rule chose the door.
708///
709/// A swept sector is an obstacle in plan, over the whole elevation band of
710/// a floor it stands on ([`Self::stands_on`]). Its footprint is never
711/// measured from a door's body: a backend brackets each sector with
712/// [`SwingSector::plan_bounds`] and proves a fit, a path or a connection
713/// against the circumscribed polygon, and an absence, a narrowing or a
714/// separation against the inscribed one.
715#[derive(Clone, Debug, PartialEq)]
716pub struct SweptDoor {
717    door: ObjectId,
718    sectors: Vec<SwingSector>,
719}
720
721impl SweptDoor {
722    /// The sectors `door` sweeps.
723    ///
724    /// # Errors
725    ///
726    /// [`DoorLeavesError::InvalidLeaves`] for no sector or a sector that
727    /// is not horizontal, which sweeps no plan area of its own shape.
728    pub fn try_new(door: ObjectId, sectors: Vec<SwingSector>) -> Result<Self, DoorLeavesError> {
729        if sectors.is_empty() {
730            return Err(invalid("a swept door sweeps at least one sector"));
731        }
732        if sectors.iter().any(|sector| !sector.is_horizontal()) {
733            return Err(invalid("a swept sector must lie in a horizontal plane"));
734        }
735        Ok(Self { door, sectors })
736    }
737
738    /// Every sector the hinged leaves of `leaves` sweep, or `None` when no
739    /// leaf swings on a side hinge (a sliding or fixed door sweeps no
740    /// floor).
741    ///
742    /// # Errors
743    ///
744    /// [`DoorLeavesError::InvalidLeaves`] for a leaf swinging outside a
745    /// horizontal plane.
746    pub fn of(leaves: &DoorLeaves) -> Result<Option<Self>, DoorLeavesError> {
747        let sectors: Vec<SwingSector> = leaves
748            .hinged()
749            .filter_map(DoorLeaf::swing)
750            .copied()
751            .collect();
752        if sectors.is_empty() {
753            return Ok(None);
754        }
755        Self::try_new(leaves.door().clone(), sectors).map(Some)
756    }
757
758    /// The door.
759    #[must_use]
760    pub fn door(&self) -> &ObjectId {
761        &self.door
762    }
763
764    /// The sectors, in the source's leaf order.
765    #[must_use]
766    pub fn sectors(&self) -> &[SwingSector] {
767        &self.sectors
768    }
769
770    /// The sectors that stand on a floor at `floor` metres whose band
771    /// reaches up to `top`: those whose hinge lies at most
772    /// [`SWEPT_FLOOR_REACH_METRES`] below the floor and below the top.
773    pub fn stands_on(&self, floor: f64, top: f64) -> impl Iterator<Item = &SwingSector> {
774        self.sectors.iter().filter(move |sector| {
775            let z = sector.hinge()[2];
776            z >= floor - SWEPT_FLOOR_REACH_METRES && z < top
777        })
778    }
779}
780
781/// `swept` sorted by door, each door once; `None` when a door is given
782/// twice with different sectors.
783pub(crate) fn tidy_swept(mut swept: Vec<SweptDoor>) -> Option<Vec<SweptDoor>> {
784    swept.sort_by(|a, b| a.door.cmp(&b.door));
785    swept.dedup();
786    if swept.windows(2).any(|pair| pair[0].door == pair[1].door) {
787        return None;
788    }
789    Some(swept)
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    fn source() -> SourceId {
797        SourceId::new("cad", "m").unwrap()
798    }
799
800    fn door() -> ObjectId {
801        ObjectId::new(source(), "d").unwrap()
802    }
803
804    fn direction(vector: [f64; 3]) -> MetricDirection {
805        MetricDirection::try_new(vector).unwrap()
806    }
807
808    /// A 0.9 m leaf closed along +x from the origin, opening towards +y,
809    /// hinged at the origin (left, seen looking along +y).
810    fn left_hinged(double: bool) -> DoorLeaf {
811        let sector = SwingSector::try_new(
812            [0.0; 3],
813            0.9,
814            direction([1.0, 0.0, 0.0]),
815            direction([0.0, 1.0, 0.0]),
816            double,
817        )
818        .unwrap();
819        DoorLeaf::try_new(
820            LeafPosition::NotDefined,
821            if double {
822                LeafMotion::DoubleSwing
823            } else {
824                LeafMotion::Swing
825            },
826            [0.0; 3],
827            direction([1.0, 0.0, 0.0]),
828            direction([0.0, 1.0, 0.0]),
829            direction([0.0, 0.0, 1.0]),
830            0.9,
831            Some(0.04),
832            Some(HingeSide::Left),
833            Some(sector),
834        )
835        .unwrap()
836    }
837
838    #[test]
839    fn a_sector_brackets_the_quarter_disc() {
840        let leaf = left_hinged(false);
841        let sector = leaf.swing().unwrap();
842        assert!(sector.is_horizontal());
843        let (inner, outer) = sector.plan_bounds(16).unwrap();
844        assert!(signed_area(&inner) > 0.0 && signed_area(&outer) > 0.0);
845        let quarter = std::f64::consts::FRAC_PI_4 * 0.81;
846        let area = |polygon: &[[f64; 2]]| signed_area(polygon) / 2.0;
847        assert!(area(&inner) < quarter && quarter < area(&outer));
848        assert!(area(&outer) - area(&inner) < 1e-2);
849        // Every inscribed vertex lies within the radius, in the quadrant.
850        for [x, y] in inner {
851            assert!(x >= -1e-12 && y >= -1e-12 && x.hypot(y) <= 0.9 + 1e-12);
852        }
853        let open = sector.direction_at(sector.sweep());
854        assert!((open[1] - 1.0).abs() < 1e-12);
855    }
856
857    #[test]
858    fn a_double_acting_sector_is_a_half_disc() {
859        let sector = *left_hinged(true).swing().unwrap();
860        assert!((sector.sweep() - std::f64::consts::PI).abs() < 1e-12);
861        let start = sector.direction_at(0.0);
862        assert!((start[1] + 1.0).abs() < 1e-12, "{start:?}");
863        let (inner, _) = sector.plan_bounds(8).unwrap();
864        assert!(inner.iter().any(|[_, y]| *y < -0.5));
865        assert!(inner.iter().any(|[_, y]| *y > 0.5));
866    }
867
868    #[test]
869    fn a_swept_door_takes_its_hinged_leaves_sectors() {
870        let exact = Evidence::exact(source(), "leaves");
871        let leaves = DoorLeaves::try_new(
872            door(),
873            "SINGLE_SWING_LEFT",
874            0.9,
875            None,
876            vec![left_hinged(false)],
877            exact,
878        )
879        .unwrap();
880        let swept = SweptDoor::of(&leaves).unwrap().unwrap();
881        assert_eq!(swept.door(), &door());
882        assert_eq!(swept.sectors().len(), 1);
883        // It stands on a floor up to half a metre above its hinge, below
884        // the band's top; not on the storey above or below.
885        assert_eq!(swept.stands_on(0.4, 2.0).count(), 1);
886        assert_eq!(swept.stands_on(-0.2, 2.0).count(), 1);
887        assert_eq!(swept.stands_on(0.6, 2.6).count(), 0);
888        assert_eq!(swept.stands_on(-3.0, -0.9).count(), 0);
889        assert!(SweptDoor::try_new(door(), Vec::new()).is_err());
890        let vertical = SwingSector::try_new(
891            [0.0; 3],
892            1.0,
893            direction([1.0, 0.0, 0.0]),
894            direction([0.0, 0.0, 1.0]),
895            false,
896        )
897        .unwrap();
898        assert!(SweptDoor::try_new(door(), vec![vertical]).is_err());
899        // One door twice is kept once; with other sectors it conflicts.
900        let other = SweptDoor::try_new(door(), vec![*left_hinged(true).swing().unwrap()]).unwrap();
901        assert_eq!(
902            tidy_swept(vec![swept.clone(), swept.clone()]),
903            Some(vec![swept.clone()])
904        );
905        assert_eq!(tidy_swept(vec![swept, other]), None);
906    }
907
908    #[test]
909    fn a_vertical_sector_has_no_plan_footprint() {
910        let sector = SwingSector::try_new(
911            [0.0; 3],
912            1.0,
913            direction([1.0, 0.0, 0.0]),
914            direction([0.0, 0.0, 1.0]),
915            false,
916        )
917        .unwrap();
918        assert!(!sector.is_horizontal());
919        assert!(sector.plan_bounds(4).is_none());
920    }
921
922    #[test]
923    fn leaves_must_be_consistent() {
924        let leaf = left_hinged(false);
925        assert!(!leaf.is_mirrored());
926        let (_, end) = leaf.closed_edge();
927        assert!((end[0] - 0.9).abs() < 1e-12 && end[1].abs() < 1e-12);
928        // A sector of another radius, a hinge side without a sector, and a
929        // sliding leaf moving across its width are refused.
930        let wrong = SwingSector::try_new(
931            [0.0; 3],
932            0.8,
933            direction([1.0, 0.0, 0.0]),
934            direction([0.0, 1.0, 0.0]),
935            false,
936        )
937        .unwrap();
938        let axes = (
939            direction([1.0, 0.0, 0.0]),
940            direction([0.0, 1.0, 0.0]),
941            direction([0.0, 0.0, 1.0]),
942        );
943        let build = |motion, side, sector| {
944            DoorLeaf::try_new(
945                LeafPosition::Left,
946                motion,
947                [0.0; 3],
948                axes.0,
949                axes.1,
950                axes.2,
951                0.9,
952                None,
953                side,
954                sector,
955            )
956        };
957        assert!(build(LeafMotion::Swing, Some(HingeSide::Left), Some(wrong)).is_err());
958        assert!(build(LeafMotion::Swing, Some(HingeSide::Left), None).is_err());
959        assert!(build(LeafMotion::Fixed, Some(HingeSide::Left), None).is_err());
960        assert!(build(LeafMotion::Slide(axes.1), None, None).is_err());
961        assert!(build(LeafMotion::Slide(axes.0), None, None).is_ok());
962        assert!(build(LeafMotion::Slide(axes.2), None, None).is_ok());
963        assert!(build(LeafMotion::TiltAndTurn, Some(HingeSide::Left), None).is_err());
964        // Mirrored axes are accepted and reported.
965        let mirrored = DoorLeaf::try_new(
966            LeafPosition::Left,
967            LeafMotion::Fixed,
968            [0.0; 3],
969            axes.0,
970            direction([0.0, -1.0, 0.0]),
971            axes.2,
972            0.9,
973            None,
974            None,
975            None,
976        )
977        .unwrap();
978        assert!(mirrored.is_mirrored());
979    }
980
981    #[test]
982    fn door_leaves_need_exact_evidence_from_the_door_source() {
983        let exact = Evidence::exact(source(), "door-operation:d");
984        assert!(
985            DoorLeaves::try_new(
986                door(),
987                "SINGLE_SWING_LEFT",
988                1.0,
989                Some(0.05),
990                vec![left_hinged(false)],
991                exact.clone()
992            )
993            .is_ok()
994        );
995        let mut approximate = exact.clone();
996        approximate.exact = false;
997        let foreign = Evidence::exact(SourceId::new("cad", "other").unwrap(), "x");
998        for evidence in [approximate, foreign] {
999            assert_eq!(
1000                DoorLeaves::try_new(door(), "X", 1.0, None, vec![left_hinged(false)], evidence),
1001                Err(DoorLeavesError::InexactEvidence)
1002            );
1003        }
1004        assert!(DoorLeaves::try_new(door(), "X", 1.0, None, vec![], exact).is_err());
1005    }
1006    #[test]
1007    fn a_tilting_panel_states_its_height_and_tilt() {
1008        let axes = (
1009            direction([1.0, 0.0, 0.0]),
1010            direction([0.0, 1.0, 0.0]),
1011            direction([0.0, 0.0, 1.0]),
1012        );
1013        let tilt = |radius: f64, closed: [f64; 3]| {
1014            SwingSector::try_new([0.0, 0.0, 0.9], radius, direction(closed), axes.1, false).unwrap()
1015        };
1016        let panel = || {
1017            DoorLeaf::try_new(
1018                LeafPosition::NotDefined,
1019                LeafMotion::Tilt,
1020                [0.0, 0.0, 0.9],
1021                axes.0,
1022                axes.1,
1023                axes.2,
1024                0.8,
1025                None,
1026                None,
1027                None,
1028            )
1029            .unwrap()
1030        };
1031        let tilted = panel()
1032            .with_height(1.2)
1033            .unwrap()
1034            .with_tilt(tilt(1.2, [0.0, 0.0, 1.0]))
1035            .unwrap();
1036        assert_eq!(tilted.height_metres(), Some(1.2));
1037        assert!(!tilted.tilt().unwrap().is_horizontal());
1038        // A tilt of another radius, across the width, or without a height
1039        // is refused, and so is a tilting leaf without its tilt.
1040        let tall = || panel().with_height(1.2).unwrap();
1041        assert!(tall().with_tilt(tilt(1.0, [0.0, 0.0, 1.0])).is_err());
1042        assert!(tall().with_tilt(tilt(1.2, [1.0, 0.0, 0.0])).is_err());
1043        assert!(panel().with_tilt(tilt(1.2, [0.0, 0.0, 1.0])).is_err());
1044        assert!(panel().with_height(0.0).is_err());
1045        let exact = Evidence::exact(source(), "window-operation:d");
1046        assert!(
1047            DoorLeaves::try_new(
1048                door(),
1049                "SINGLE_PANEL",
1050                0.8,
1051                None,
1052                vec![tall()],
1053                exact.clone()
1054            )
1055            .is_err()
1056        );
1057        assert!(
1058            DoorLeaves::try_new(door(), "SINGLE_PANEL", 0.8, None, vec![tilted], exact).is_ok()
1059        );
1060    }
1061}