Skip to main content

axioval_engine/
circulation.rs

1//! Source-neutral circulation maps: where a path of a given width can run
2//! within one space, and which entrances and components it comes near.
3//!
4//! A circulation map is a measurement, not a verdict. The free area of a
5//! space (its floor less what the obstacles occupy in a band above it) is
6//! eroded by half the path's width: every point left is a centre at which
7//! a disc as wide as the path fits. A backend cannot compute that erosion
8//! exactly, so it reports two bounds:
9//!
10//! - **pieces**, the connected parts of a region *inside* the exact
11//!   erosion. A path provably runs between any two points of one piece;
12//! - **possible pieces**, the connected parts of a region *containing* the
13//!   exact erosion. A path between two points of different possible pieces
14//!   provably does not exist.
15//!
16//! Each entrance and component the request names is a subject. Its contact
17//! lists the pieces that provably come within half the width plus the
18//! request's tolerance of its plan footprint, and the possible pieces that
19//! may. The skeleton of the pieces (nodes along their middle, joined by
20//! edges) tells where paths run, where they meet and where they end. Node
21//! positions are approximate; that each node lies in its piece, and the
22//! bounds on the free half width around it, are proven.
23//!
24//! Whether a component that no entrance reaches, or an end without a free
25//! area, fails a rule is the capability's decision.
26
27use axioval_ir::{Evidence, ObjectId};
28
29use crate::LengthInterval;
30use crate::door_leaves::{SweptDoor, tidy_swept};
31use crate::free_space::{ElevationBand, FreeSpaceError};
32use crate::services::reviewable_exact_evidence;
33
34/// A circulation map request for one space.
35///
36/// Entrances are never obstacles, and the scope is neither an entrance, a
37/// component nor an obstacle: the constructor removes them, as walkability
38/// does. The lists are sorted and deduplicated.
39#[derive(Clone, Debug, PartialEq)]
40pub struct CirculationRequest {
41    scope: ObjectId,
42    entrances: Vec<ObjectId>,
43    components: Vec<ObjectId>,
44    obstacles: Vec<ObjectId>,
45    width_metres: f64,
46    height_metres: f64,
47    tolerance_metres: f64,
48    swept: Vec<SweptDoor>,
49    merged: Vec<ObjectId>,
50    band_from_metres: f64,
51}
52
53impl CirculationRequest {
54    /// A path `width_metres` wide, free `height_metres` above the scope's
55    /// floor, in `scope`; a subject is near a piece within half the width
56    /// plus `tolerance_metres`.
57    ///
58    /// # Errors
59    ///
60    /// [`FreeSpaceError::InvalidClearanceShape`] for a width or height that
61    /// is not positive and finite, or a tolerance that is negative or not
62    /// finite.
63    pub fn try_new(
64        scope: ObjectId,
65        entrances: Vec<ObjectId>,
66        components: Vec<ObjectId>,
67        obstacles: Vec<ObjectId>,
68        width_metres: f64,
69        height_metres: f64,
70        tolerance_metres: f64,
71    ) -> Result<Self, FreeSpaceError> {
72        let positive = |value: f64| value.is_finite() && value > 0.0;
73        if !(positive(width_metres)
74            && positive(height_metres)
75            && tolerance_metres.is_finite()
76            && tolerance_metres >= 0.0)
77        {
78            return Err(FreeSpaceError::InvalidClearanceShape);
79        }
80        let tidy = |mut list: Vec<ObjectId>| {
81            list.retain(|object| object != &scope);
82            list.sort();
83            list.dedup();
84            list
85        };
86        let entrances = tidy(entrances);
87        let components = tidy(components);
88        let mut obstacles = tidy(obstacles);
89        obstacles.retain(|object| entrances.binary_search(object).is_err());
90        Ok(Self {
91            scope,
92            entrances,
93            components,
94            obstacles,
95            width_metres,
96            height_metres,
97            tolerance_metres,
98            swept: Vec::new(),
99            merged: Vec::new(),
100            band_from_metres: 0.0,
101        })
102    }
103
104    /// Maps the union of the scope and `merged` scopes, such as the spaces
105    /// of one room split by a virtual boundary, as one free area. Merged
106    /// scopes stand on the scope's floor and are never obstacles.
107    ///
108    /// # Errors
109    ///
110    /// [`FreeSpaceError::MergedScopeConflict`] when a merged scope is an
111    /// entrance or a component.
112    pub fn with_merged_scopes(mut self, mut merged: Vec<ObjectId>) -> Result<Self, FreeSpaceError> {
113        merged.retain(|object| object != &self.scope);
114        merged.sort();
115        merged.dedup();
116        if merged.iter().any(|object| {
117            self.entrances.binary_search(object).is_ok()
118                || self.components.binary_search(object).is_ok()
119        }) {
120            return Err(FreeSpaceError::MergedScopeConflict);
121        }
122        self.obstacles
123            .retain(|object| merged.binary_search(object).is_err());
124        self.merged = merged;
125        Ok(self)
126    }
127
128    /// The scopes mapped together with the scope, sorted.
129    #[must_use]
130    pub fn merged_scopes(&self) -> &[ObjectId] {
131        &self.merged
132    }
133
134    /// Counts obstacles only from `from_metres` above the floor up to the
135    /// height, so a skirting or a low sill below it leaves the path free.
136    ///
137    /// # Errors
138    ///
139    /// [`FreeSpaceError::InvalidElevationBand`] for a start that is
140    /// negative, not finite or not below the height.
141    pub fn with_band_from(mut self, from_metres: f64) -> Result<Self, FreeSpaceError> {
142        ElevationBand::try_new(from_metres, self.height_metres)?;
143        self.band_from_metres = from_metres;
144        Ok(self)
145    }
146
147    /// Counts the sectors `swept` doors sweep as obstacles (see
148    /// [`SweptDoor`]): a piece stays clear of their circumscribed
149    /// polygons, a possible piece only of their inscribed ones. An entrance
150    /// is walked through, so its own swing is dropped, as the entrance is
151    /// from the obstacles.
152    ///
153    /// # Errors
154    ///
155    /// [`FreeSpaceError::ConflictingSweptDoors`] when one door is given
156    /// twice with different sectors.
157    pub fn with_swept_doors(mut self, swept: Vec<SweptDoor>) -> Result<Self, FreeSpaceError> {
158        let mut swept = tidy_swept(swept).ok_or(FreeSpaceError::ConflictingSweptDoors)?;
159        swept.retain(|door| self.entrances.binary_search(door.door()).is_err());
160        self.swept = swept;
161        Ok(self)
162    }
163
164    /// The doors whose swept sectors are obstacles, sorted by door.
165    #[must_use]
166    pub fn swept_doors(&self) -> &[SweptDoor] {
167        &self.swept
168    }
169
170    /// The space the path runs in.
171    #[must_use]
172    pub fn scope(&self) -> &ObjectId {
173        &self.scope
174    }
175
176    /// The entrances the path starts from.
177    #[must_use]
178    pub fn entrances(&self) -> &[ObjectId] {
179        &self.entrances
180    }
181
182    /// The components the path must come near.
183    #[must_use]
184    pub fn components(&self) -> &[ObjectId] {
185        &self.components
186    }
187
188    /// The objects that occupy floor, counted by what they occupy in the
189    /// band.
190    #[must_use]
191    pub fn obstacles(&self) -> &[ObjectId] {
192        &self.obstacles
193    }
194
195    /// The path's width.
196    #[must_use]
197    pub fn width_metres(&self) -> f64 {
198        self.width_metres
199    }
200
201    /// The height above the floor the path must be free to.
202    #[must_use]
203    pub fn height_metres(&self) -> f64 {
204        self.height_metres
205    }
206
207    /// How much farther than half the width a subject may be from a piece
208    /// and still be near it.
209    #[must_use]
210    pub fn tolerance_metres(&self) -> f64 {
211        self.tolerance_metres
212    }
213
214    /// The band obstacles count in: from the band's start (the floor by
215    /// default) up to the height above the floor.
216    #[must_use]
217    pub fn band(&self) -> ElevationBand {
218        ElevationBand::try_new(self.band_from_metres, self.height_metres)
219            .unwrap_or_else(|_| unreachable!("the start lies below the positive, finite height"))
220    }
221
222    /// The subjects a map reports contacts for: the entrances and the
223    /// components, sorted and deduplicated.
224    #[must_use]
225    pub fn subjects(&self) -> Vec<ObjectId> {
226        let mut subjects: Vec<ObjectId> = self
227            .entrances
228            .iter()
229            .chain(&self.components)
230            .cloned()
231            .collect();
232        subjects.sort();
233        subjects.dedup();
234        subjects
235    }
236}
237
238/// What a skeleton node is, by its number of neighbours.
239#[derive(Clone, Copy, Debug, PartialEq, Eq)]
240#[non_exhaustive]
241pub enum CirculationNodeKind {
242    /// Where a path ends: one neighbour.
243    End,
244    /// Along a path: two neighbours.
245    Path,
246    /// Where paths meet: three or more.
247    Junction,
248    /// No neighbour: a piece too small to hold a path.
249    Isolated,
250}
251
252impl CirculationNodeKind {
253    fn admits(self, degree: usize) -> bool {
254        match self {
255            Self::End => degree == 1,
256            Self::Path => degree == 2,
257            Self::Junction => degree >= 3,
258            Self::Isolated => degree == 0,
259        }
260    }
261}
262
263/// One skeleton node.
264#[derive(Clone, Debug, PartialEq)]
265pub struct CirculationNode {
266    point: [f64; 3],
267    kind: CirculationNodeKind,
268    piece: usize,
269    half_width: LengthInterval,
270}
271
272impl CirculationNode {
273    /// A node at `point` (on the floor), in `piece`, whose distance to the
274    /// nearest wall or obstacle of the free area lies in `half_width`.
275    ///
276    /// # Errors
277    ///
278    /// [`FreeSpaceError::Unavailable`] for a coordinate that is not finite.
279    pub fn try_new(
280        point: [f64; 3],
281        kind: CirculationNodeKind,
282        piece: usize,
283        half_width: LengthInterval,
284    ) -> Result<Self, FreeSpaceError> {
285        if !point.iter().all(|value| value.is_finite()) {
286            return Err(FreeSpaceError::Unavailable(
287                "a circulation node's coordinates must be finite".into(),
288            ));
289        }
290        Ok(Self {
291            point,
292            kind,
293            piece,
294            half_width,
295        })
296    }
297
298    /// Where it is, on the floor, in metres. Approximate: the node lies in
299    /// its piece, but how close it is to the piece's middle is not proven.
300    #[must_use]
301    pub fn point(&self) -> [f64; 3] {
302        self.point
303    }
304
305    /// Its kind.
306    #[must_use]
307    pub fn kind(&self) -> CirculationNodeKind {
308        self.kind
309    }
310
311    /// The piece it lies in.
312    #[must_use]
313    pub fn piece(&self) -> usize {
314        self.piece
315    }
316
317    /// Proven bounds on its distance to the free area's boundary: half the
318    /// width of the free area around it.
319    #[must_use]
320    pub fn half_width(&self) -> LengthInterval {
321        self.half_width
322    }
323}
324
325/// Where one subject meets the path.
326#[derive(Clone, Debug, PartialEq, Eq)]
327pub struct CirculationContact {
328    subject: ObjectId,
329    reached: Vec<(usize, Option<usize>)>,
330    possible: Vec<usize>,
331}
332
333impl CirculationContact {
334    /// `reached` pairs each piece proven near the subject with the node of
335    /// that piece nearest to it, if the piece has one; `possible` lists the
336    /// possible pieces that may be near it.
337    #[must_use]
338    pub fn new(
339        subject: ObjectId,
340        mut reached: Vec<(usize, Option<usize>)>,
341        mut possible: Vec<usize>,
342    ) -> Self {
343        reached.sort_unstable();
344        reached.dedup_by_key(|(piece, _)| *piece);
345        possible.sort_unstable();
346        possible.dedup();
347        Self {
348            subject,
349            reached,
350            possible,
351        }
352    }
353
354    /// The entrance or component.
355    #[must_use]
356    pub fn subject(&self) -> &ObjectId {
357        &self.subject
358    }
359
360    /// The pieces proven near it, each with its nearest node.
361    #[must_use]
362    pub fn reached(&self) -> &[(usize, Option<usize>)] {
363        &self.reached
364    }
365
366    /// Whether `piece` is proven near it.
367    #[must_use]
368    pub fn reaches(&self, piece: usize) -> bool {
369        self.reached.iter().any(|(reached, _)| *reached == piece)
370    }
371
372    /// The possible pieces that may be near it. Every other possible piece
373    /// is proven farther away.
374    #[must_use]
375    pub fn possible(&self) -> &[usize] {
376        &self.possible
377    }
378}
379
380/// The circulation map of one space for one path width.
381#[derive(Clone, Debug, PartialEq)]
382pub struct CirculationMap {
383    request: CirculationRequest,
384    pieces: usize,
385    possible_pieces: usize,
386    nodes: Vec<CirculationNode>,
387    edges: Vec<(usize, usize)>,
388    spacing_metres: f64,
389    contacts: Vec<CirculationContact>,
390    unmapped: Vec<(usize, String)>,
391    evidence: Evidence,
392}
393
394impl CirculationMap {
395    /// Validates a map for `request`: every node in a piece, every edge
396    /// within one piece, kinds matching the number of neighbours, one
397    /// contact per subject in order, and exact evidence. `spacing_metres`
398    /// is the boundary sample spacing the skeleton was built with.
399    /// `unmapped` names the pieces whose skeleton could not be built, with
400    /// why; they have no nodes, and where their paths end is unknown.
401    ///
402    /// # Errors
403    ///
404    /// [`FreeSpaceError::InexactPlacementEvidence`] for approximate or
405    /// blank evidence, [`FreeSpaceError::Unavailable`] for a malformed map.
406    #[allow(clippy::too_many_arguments)]
407    pub fn try_new(
408        request: CirculationRequest,
409        pieces: usize,
410        possible_pieces: usize,
411        nodes: Vec<CirculationNode>,
412        mut edges: Vec<(usize, usize)>,
413        spacing_metres: f64,
414        contacts: Vec<CirculationContact>,
415        mut unmapped: Vec<(usize, String)>,
416        evidence: Evidence,
417    ) -> Result<Self, FreeSpaceError> {
418        let malformed = |why: &str| FreeSpaceError::Unavailable(format!("circulation map: {why}"));
419        if !reviewable_exact_evidence(&evidence) {
420            return Err(FreeSpaceError::InexactPlacementEvidence);
421        }
422        if !(spacing_metres.is_finite() && spacing_metres > 0.0) {
423            return Err(malformed("the sample spacing must be positive"));
424        }
425        if nodes.iter().any(|node| node.piece >= pieces) {
426            return Err(malformed("a node lies in no piece"));
427        }
428        unmapped.sort();
429        unmapped.dedup_by_key(|(piece, _)| *piece);
430        if unmapped
431            .iter()
432            .any(|(piece, _)| *piece >= pieces || nodes.iter().any(|node| node.piece == *piece))
433        {
434            return Err(malformed("an unmapped piece is not there or has nodes"));
435        }
436        edges.sort_unstable();
437        edges.dedup();
438        let mut degree = vec![0usize; nodes.len()];
439        for &(a, b) in &edges {
440            if a >= b || b >= nodes.len() || nodes[a].piece != nodes[b].piece {
441                return Err(malformed("an edge joins no two nodes of one piece"));
442            }
443            degree[a] += 1;
444            degree[b] += 1;
445        }
446        if nodes
447            .iter()
448            .zip(&degree)
449            .any(|(node, &degree)| !node.kind.admits(degree))
450        {
451            return Err(malformed("a node's kind does not match its neighbours"));
452        }
453        let subjects = request.subjects();
454        if contacts.len() != subjects.len()
455            || contacts
456                .iter()
457                .zip(&subjects)
458                .any(|(contact, subject)| &contact.subject != subject)
459        {
460            return Err(malformed("the contacts are not the request's subjects"));
461        }
462        for contact in &contacts {
463            for &(piece, node) in &contact.reached {
464                if piece >= pieces
465                    || node.is_some_and(|node| node >= nodes.len() || nodes[node].piece != piece)
466                {
467                    return Err(malformed(
468                        "a contact names a piece or node that is not there",
469                    ));
470                }
471            }
472            if contact
473                .possible
474                .iter()
475                .any(|&piece| piece >= possible_pieces)
476            {
477                return Err(malformed(
478                    "a contact names a possible piece that is not there",
479                ));
480            }
481        }
482        Ok(Self {
483            request,
484            pieces,
485            possible_pieces,
486            nodes,
487            edges,
488            spacing_metres,
489            contacts,
490            unmapped,
491            evidence,
492        })
493    }
494
495    /// The request this map answers.
496    #[must_use]
497    pub fn request(&self) -> &CirculationRequest {
498        &self.request
499    }
500
501    /// How many pieces there are: connected parts of a region inside the
502    /// free area eroded by half the width.
503    #[must_use]
504    pub fn pieces(&self) -> usize {
505        self.pieces
506    }
507
508    /// How many possible pieces there are: connected parts of a region
509    /// containing that erosion.
510    #[must_use]
511    pub fn possible_pieces(&self) -> usize {
512        self.possible_pieces
513    }
514
515    /// The skeleton nodes.
516    #[must_use]
517    pub fn nodes(&self) -> &[CirculationNode] {
518        &self.nodes
519    }
520
521    /// The skeleton edges, as node index pairs, lower first, sorted.
522    #[must_use]
523    pub fn edges(&self) -> &[(usize, usize)] {
524        &self.edges
525    }
526
527    /// The neighbours of `node`, ascending.
528    #[must_use]
529    pub fn neighbours(&self, node: usize) -> Vec<usize> {
530        let mut out: Vec<usize> = self
531            .edges
532            .iter()
533            .filter_map(|&(a, b)| match (a == node, b == node) {
534                (true, _) => Some(b),
535                (_, true) => Some(a),
536                _ => None,
537            })
538            .collect();
539        out.sort_unstable();
540        out
541    }
542
543    /// The boundary sample spacing the skeleton was built with: how far
544    /// node positions may stray is of this order, but not proven.
545    #[must_use]
546    pub fn spacing_metres(&self) -> f64 {
547        self.spacing_metres
548    }
549
550    /// One contact per subject, in subject order.
551    #[must_use]
552    pub fn contacts(&self) -> &[CirculationContact] {
553        &self.contacts
554    }
555
556    /// The contact of `subject`, if the request names it.
557    #[must_use]
558    pub fn contact(&self, subject: &ObjectId) -> Option<&CirculationContact> {
559        self.contacts
560            .iter()
561            .find(|contact| &contact.subject == subject)
562    }
563
564    /// The pieces without a skeleton, with why: where their paths end is
565    /// unknown.
566    #[must_use]
567    pub fn unmapped(&self) -> &[(usize, String)] {
568        &self.unmapped
569    }
570
571    /// Why `piece` has no skeleton, if it has none.
572    #[must_use]
573    pub fn unmapped_reason(&self, piece: usize) -> Option<&str> {
574        self.unmapped
575            .iter()
576            .find(|(unmapped, _)| *unmapped == piece)
577            .map(|(_, why)| why.as_str())
578    }
579
580    /// Provenance.
581    #[must_use]
582    pub fn evidence(&self) -> &Evidence {
583        &self.evidence
584    }
585}