Skip to main content

ogeom_topo/
location.rs

1//! Placement as a chain of transforms.
2//!
3//! `docs/DATA_MODEL.md` §2. A [`Location`] is a *sequence* of `(datum, power)`
4//! pairs, not a 4×4 matrix, and that is load-bearing rather than stylistic:
5//!
6//! - **Composition is concatenation.** No matrix product, and no drift from
7//!   composing the same placement a thousand times down an assembly tree.
8//! - **Identity is structural.** Two shapes are at the same place when their
9//!   chains match, decided by comparing a handful of integers rather than
10//!   sixteen floats against a tolerance. That is what lets ten thousand
11//!   identical fasteners share one piece of geometry *and* be recognisable as
12//!   instances of it.
13//! - **Inverses are exact.** Negate the powers; no matrix inversion, no
14//!   rounding.
15//!
16//! The composed [`Transform`] is derived on demand. It is deliberately *not*
17//! cached inside the location: a location is used as a hash key, and a value
18//! with interior mutability has no business being one, the hazard being that
19//! a key's hash can change while it sits in a map. Composing a chain is a few
20//! transform products, and chains are short; a caller that finds it hot can
21//! memoize outside.
22
23use ogeom_core::{Arena, Key, OgeomResult, Tolerances, ogeom_bail};
24use ogeom_math::{Transform, TransformKind};
25use smallvec::SmallVec;
26
27/// A rigid or similarity transform that placements are built from.
28///
29/// Shared: many locations refer to the same datum, and comparing two references
30/// to it is what makes placement identity cheap.
31pub type Datum = Transform;
32
33/// A handle to a shared [`Datum`].
34pub type DatumId = Key<Datum>;
35
36/// The store of transforms that [`Location`] chains refer into.
37///
38/// One per document. Interning is not an optimisation here; it is what gives
39/// placements a stable notion of sameness, since two chains naming the same
40/// datum are known to agree without any floating-point comparison.
41///
42/// # Handles are relative to their store, and know it
43///
44/// A [`DatumId`] means nothing without the store that issued it, and it says
45/// which one that was. A handle from another store resolves to `None` rather
46/// than to whatever transform happens to sit at that index, so mixing
47/// documents is an error that shows up where it happens rather than a wrong
48/// answer several operations later.
49#[derive(Debug, Clone, Default)]
50pub struct DatumStore {
51    arena: Arena<Datum>,
52}
53
54impl DatumStore {
55    /// An empty store.
56    #[must_use]
57    pub const fn new() -> Self {
58        Self {
59            arena: Arena::new(),
60        }
61    }
62
63    /// Intern a transform, returning a handle to it.
64    ///
65    /// Identical transforms are *not* deduplicated: recognising two matrices as
66    /// equal is a tolerance question, and the whole point of the chain
67    /// representation is to avoid asking it. Callers that want sharing hold on
68    /// to the handle.
69    pub fn insert(&mut self, transform: Datum) -> DatumId {
70        self.arena.insert(transform)
71    }
72
73    /// The transform behind `id`.
74    #[must_use]
75    pub fn get(&self, id: DatumId) -> Option<Datum> {
76        self.arena.get(id).copied()
77    }
78
79    /// Number of interned transforms.
80    #[must_use]
81    pub fn len(&self) -> usize {
82        self.arena.len()
83    }
84
85    /// Whether nothing has been interned.
86    #[must_use]
87    pub fn is_empty(&self) -> bool {
88        self.arena.is_empty()
89    }
90
91    /// The identifier this store's arena issues keys under.
92    pub(crate) const fn scope(&self) -> u32 {
93        self.arena.scope()
94    }
95
96    /// Whether the arena has only ever been appended to.
97    ///
98    /// The precondition for extending the store by offset; see
99    /// [`Arena::is_dense`].
100    pub(crate) fn is_dense(&self) -> bool {
101        self.arena.is_dense()
102    }
103
104    /// Every datum, with its handle, in arena order.
105    pub fn iter(&self) -> impl Iterator<Item = (DatumId, Datum)> {
106        self.arena.iter().map(|(id, t)| (id, *t))
107    }
108}
109
110/// A placement: a chain of `(datum, power)` pairs.
111///
112/// Applied left to right, so the first entry is the outermost transform. An
113/// empty chain is the identity.
114///
115/// Powers are integers, so a datum applied twice costs one entry rather than
116/// two, and its inverse is the same entry with the sign flipped.
117/// Equality and hashing are structural: two locations agree when their chains
118/// match entry for entry. Deliberately *not* a comparison of the composed
119/// transforms; that would be a tolerance question, and the answer would depend
120/// on rounding rather than on what the model says.
121#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
122pub struct Location {
123    chain: SmallVec<[(DatumId, i32); 2]>,
124}
125
126impl Location {
127    /// The identity placement.
128    #[must_use]
129    pub fn identity() -> Self {
130        Self::default()
131    }
132
133    /// A placement of one datum, applied once.
134    #[must_use]
135    pub fn of(datum: DatumId) -> Self {
136        let mut chain = SmallVec::new();
137        chain.push((datum, 1));
138        Self { chain }
139    }
140
141    /// A placement of one datum, applied `power` times.
142    ///
143    /// A power of zero gives the identity; a negative power gives the inverse.
144    #[must_use]
145    pub fn powered(datum: DatumId, power: i32) -> Self {
146        if power == 0 {
147            return Self::identity();
148        }
149        let mut chain = SmallVec::new();
150        chain.push((datum, power));
151        Self { chain }
152    }
153
154    /// Whether this is the identity.
155    #[must_use]
156    pub fn is_identity(&self) -> bool {
157        self.chain.is_empty()
158    }
159
160    /// Whether this placement is `inner` with further placements outside
161    /// it: `inner`'s chain is this chain's tail.
162    #[must_use]
163    pub fn ends_with(&self, inner: &Self) -> bool {
164        self.chain.ends_with(&inner.chain)
165    }
166
167    /// The chain, outermost entry first.
168    #[must_use]
169    pub fn chain(&self) -> &[(DatumId, i32)] {
170        &self.chain
171    }
172
173    /// Number of entries.
174    #[must_use]
175    pub fn depth(&self) -> usize {
176        self.chain.len()
177    }
178
179    /// This placement followed by `inner`.
180    ///
181    /// `outer.then(inner)` applies `inner` first, then `outer`, the same order
182    /// as transform composition, so a sub-shape's placement composed with its
183    /// parent's reads the way the tree does.
184    ///
185    /// Adjacent entries naming the same datum are merged by adding their
186    /// powers, and an entry whose power reaches zero is dropped. That keeps a
187    /// chain from growing without bound as a placement is composed and undone
188    /// repeatedly, and it is what makes `a.then(a.inverted())` come out exactly
189    /// equal to the identity rather than merely close to it.
190    #[must_use]
191    pub fn then(&self, inner: &Self) -> Self {
192        let mut chain = self.chain.clone();
193        for &(datum, power) in &inner.chain {
194            match chain.last_mut() {
195                Some((last, last_power)) if *last == datum => {
196                    *last_power += power;
197                    if *last_power == 0 {
198                        chain.pop();
199                    }
200                }
201                _ => chain.push((datum, power)),
202            }
203        }
204        Self { chain }
205    }
206
207    /// This placement with every datum handle bound to a given store.
208    ///
209    /// For reading a document back: the handles a file records are unscoped
210    /// until the store that will hold them exists.
211    pub(crate) fn with_datum_scope(&self, scope: u32) -> Self {
212        Self {
213            chain: self
214                .chain
215                .iter()
216                .map(|(datum, power)| (datum.with_scope(scope), *power))
217                .collect(),
218        }
219    }
220
221    /// This placement with every datum handle shifted by `offset` slots.
222    ///
223    /// For absorbing one document's parts into another: the chain's indices
224    /// were local to the source document, and its datums are about to land
225    /// `offset` slots into the target's store. The handles stay unscoped;
226    /// binding is a separate, later step.
227    pub(crate) fn with_datum_offset(&self, offset: u32) -> Self {
228        Self {
229            chain: self
230                .chain
231                .iter()
232                .map(|(datum, power)| (crate::entity::shifted_key(*datum, offset), *power))
233                .collect(),
234        }
235    }
236
237    /// The inverse placement.
238    ///
239    /// Exact: the chain reverses and every power negates. No matrix is
240    /// inverted, so `l.then(&l.inverted())` is the identity structurally, not
241    /// approximately.
242    #[must_use]
243    pub fn inverted(&self) -> Self {
244        Self {
245            chain: self.chain.iter().rev().map(|&(d, p)| (d, -p)).collect(),
246        }
247    }
248
249    /// The composed transform.
250    ///
251    /// # Errors
252    ///
253    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the chain names a
254    /// datum the store does not hold, and
255    /// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if a negative power
256    /// requires inverting a degenerate transform.
257    pub fn composed(&self, store: &DatumStore) -> OgeomResult<Transform> {
258        let mut result = Transform::IDENTITY;
259        for &(id, power) in &self.chain {
260            let Some(datum) = store.get(id) else {
261                ogeom_bail!(Dangling, "location refers to a datum not in this store");
262            };
263            let step = if power >= 0 { datum } else { datum.inverse()? };
264            for _ in 0..power.unsigned_abs() {
265                result = result * step;
266            }
267        }
268        Ok(result)
269    }
270
271    /// Whether the composed transform preserves handedness.
272    ///
273    /// A shape placed by a handedness-reversing location has to have its
274    /// orientation flipped to stay consistent, or a mirrored solid ends up
275    /// inside out.
276    ///
277    /// # Errors
278    ///
279    /// As [`Location::composed`].
280    pub fn preserves_handedness(&self, store: &DatumStore) -> OgeomResult<bool> {
281        Ok(self.composed(store)?.preserves_handedness())
282    }
283
284    /// Whether two placements put a shape in the same position.
285    ///
286    /// Falls back to comparing the composed transforms, which costs more than
287    /// [`PartialEq`] and answers a different question: two chains built by
288    /// different routes can describe the same placement.
289    ///
290    /// # Errors
291    ///
292    /// As [`Location::composed`].
293    pub fn is_same_placement(
294        &self,
295        other: &Self,
296        store: &DatumStore,
297        tol: Tolerances,
298    ) -> OgeomResult<bool> {
299        if self == other {
300            return Ok(true);
301        }
302        Ok(self.composed(store)?.is_equal(&other.composed(store)?, tol))
303    }
304
305    /// The kind of the composed transform, for dispatch.
306    ///
307    /// # Errors
308    ///
309    /// As [`Location::composed`].
310    pub fn kind(&self, store: &DatumStore) -> OgeomResult<TransformKind> {
311        if self.is_identity() {
312            return Ok(TransformKind::Identity);
313        }
314        Ok(self.composed(store)?.kind())
315    }
316}
317
318#[cfg(test)]
319#[allow(clippy::unwrap_used)]
320mod tests {
321    use super::*;
322    use ogeom_math::{Axis, Point, Vector};
323
324    const T: Tolerances = Tolerances::millimetres();
325
326    fn store() -> (DatumStore, DatumId, DatumId) {
327        let mut s = DatumStore::new();
328        let a = s.insert(Transform::translation(Vector::new(1.0, 0.0, 0.0)));
329        let b = s.insert(Transform::rotation(Axis::Z, core::f64::consts::FRAC_PI_2));
330        (s, a, b)
331    }
332
333    #[test]
334    fn the_identity_is_empty_and_composes_to_nothing() {
335        let (s, _, _) = store();
336        let id = Location::identity();
337        assert!(id.is_identity());
338        assert_eq!(id.depth(), 0);
339        assert_eq!(id.composed(&s).unwrap().kind(), TransformKind::Identity);
340    }
341
342    #[test]
343    fn composition_applies_the_inner_placement_first() {
344        let (s, a, b) = store();
345        let outer = Location::of(a);
346        let inner = Location::of(b);
347        let combined = outer.then(&inner);
348
349        let expected = outer.composed(&s).unwrap() * inner.composed(&s).unwrap();
350        assert!(combined.composed(&s).unwrap().is_equal(&expected, T));
351
352        // And the two orders differ, as they must.
353        assert!(
354            !combined
355                .composed(&s)
356                .unwrap()
357                .is_equal(&inner.then(&outer).composed(&s).unwrap(), T)
358        );
359    }
360
361    #[test]
362    fn inversion_is_exact_rather_than_approximate() {
363        // The property a matrix representation cannot offer: composing a
364        // placement with its inverse gives the identity *structurally*, with no
365        // residue to accumulate down an assembly tree.
366        let (_, a, b) = store();
367        let l = Location::of(a)
368            .then(&Location::of(b))
369            .then(&Location::of(a));
370        let round_trip = l.then(&l.inverted());
371        assert!(round_trip.is_identity(), "chain: {:?}", round_trip.chain());
372        assert_eq!(round_trip, Location::identity());
373    }
374
375    #[test]
376    fn repeated_composition_does_not_grow_the_chain() {
377        // A placement applied a hundred times is one entry with a power of 100,
378        // not a hundred entries, so an assembly that nests deeply stays cheap
379        // to compare and to store.
380        let (s, a, _) = store();
381        let mut l = Location::identity();
382        for _ in 0..100 {
383            l = l.then(&Location::of(a));
384        }
385        assert_eq!(l.depth(), 1);
386        assert_eq!(l.chain()[0].1, 100);
387        assert!(
388            l.composed(&s)
389                .unwrap()
390                .apply(Point::ORIGIN)
391                .is_equal(Point::new(100.0, 0.0, 0.0), T)
392        );
393    }
394
395    #[test]
396    fn powers_cancel_exactly() {
397        let (s, a, _) = store();
398        let forward = Location::powered(a, 5);
399        let back = Location::powered(a, -5);
400        assert!(forward.then(&back).is_identity());
401        assert_eq!(Location::powered(a, 0), Location::identity());
402        assert!(
403            Location::powered(a, -2)
404                .composed(&s)
405                .unwrap()
406                .apply(Point::ORIGIN)
407                .is_equal(Point::new(-2.0, 0.0, 0.0), T)
408        );
409    }
410
411    #[test]
412    fn equality_is_structural_not_numerical() {
413        // Two chains that compose to the same transform are still different
414        // placements. Asking whether they *are* the same is a comparison of
415        // integers; asking whether they *land* in the same place is a separate,
416        // costlier question with its own method.
417        let mut s = DatumStore::new();
418        let a = s.insert(Transform::translation(Vector::new(1.0, 0.0, 0.0)));
419        let b = s.insert(Transform::translation(Vector::new(1.0, 0.0, 0.0)));
420
421        let via_a = Location::of(a);
422        let via_b = Location::of(b);
423        assert_ne!(via_a, via_b, "different datums are different placements");
424        assert!(via_a.is_same_placement(&via_b, &s, T).unwrap());
425
426        assert_eq!(via_a, Location::of(a));
427        assert!(via_a.is_same_placement(&Location::of(a), &s, T).unwrap());
428    }
429
430    #[test]
431    fn locations_hash_consistently_with_equality() {
432        use std::collections::HashSet;
433        let (_, a, b) = store();
434        let mut set = HashSet::new();
435        set.insert(Location::of(a));
436        set.insert(Location::of(a));
437        set.insert(Location::of(b));
438        set.insert(Location::identity());
439        assert_eq!(set.len(), 3);
440        assert!(set.contains(&Location::of(a)));
441    }
442
443    #[test]
444    fn evaluating_a_location_does_not_change_its_identity() {
445        // A location is used as a hash key, so nothing it does may alter how it
446        // compares or hashes. It holds no interior mutability at all, which is
447        // what makes that guarantee rather than a hope.
448        let (s, a, _) = store();
449        let x = Location::of(a);
450        let y = Location::of(a);
451        let _ = x.composed(&s).unwrap();
452        assert_eq!(x, y);
453
454        use std::collections::hash_map::DefaultHasher;
455        use std::hash::{Hash, Hasher};
456        let hash = |l: &Location| {
457            let mut h = DefaultHasher::new();
458            l.hash(&mut h);
459            h.finish()
460        };
461        assert_eq!(hash(&x), hash(&y));
462    }
463
464    #[test]
465    fn a_location_is_send_and_sync() {
466        // Needed for the parallel algorithms further up the stack, and easy to
467        // lose to a cache tucked inside the type.
468        const fn assert_send_sync<T: Send + Sync>() {}
469        assert_send_sync::<Location>();
470        assert_send_sync::<DatumStore>();
471    }
472
473    #[test]
474    fn a_handle_the_store_does_not_hold_is_reported_rather_than_ignored() {
475        let (s, _, _) = store();
476        let mut other = DatumStore::new();
477        // Past the end of `s`, so it genuinely does not resolve.
478        let mut beyond = other.insert(Transform::translation(Vector::Y));
479        for _ in 0..10 {
480            beyond = other.insert(Transform::translation(Vector::Y));
481        }
482        assert!(Location::of(beyond).composed(&s).is_err());
483    }
484
485    #[test]
486    fn a_handle_from_another_store_is_refused_rather_than_resolved() {
487        // Resolved, it would name whatever transform happened to sit at that
488        // index, silently and confidently. An arena handle carries the
489        // identifier of the arena that issued it, so a foreign one names a
490        // datum this store does not have, and composing it says so.
491        let (s, first, _) = store();
492        let mut other = DatumStore::new();
493        let foreign = other.insert(Transform::translation(Vector::new(0.0, 99.0, 0.0)));
494
495        assert_eq!(
496            foreign.index(),
497            first.index(),
498            "the same slot in both stores, which is what used to make this silent"
499        );
500        assert!(s.get(foreign).is_none());
501        assert!(Location::of(foreign).composed(&s).is_err());
502        assert!(Location::of(first).composed(&s).is_ok());
503    }
504
505    #[test]
506    fn handedness_follows_the_composed_transform() {
507        let mut s = DatumStore::new();
508        let mirror = s.insert(Transform::plane_mirror(
509            Point::ORIGIN,
510            ogeom_math::Direction::Z,
511        ));
512        let once = Location::of(mirror);
513        assert!(!once.preserves_handedness(&s).unwrap());
514        // Two mirrors make a rotation.
515        assert!(
516            Location::powered(mirror, 2)
517                .preserves_handedness(&s)
518                .unwrap()
519        );
520        assert!(Location::identity().preserves_handedness(&s).unwrap());
521    }
522
523    #[test]
524    fn composing_different_datums_keeps_both_entries() {
525        let (_, a, b) = store();
526        let l = Location::of(a).then(&Location::of(b));
527        assert_eq!(l.depth(), 2);
528        assert_eq!(l.chain(), &[(a, 1), (b, 1)]);
529    }
530
531    #[test]
532    fn the_datum_store_does_not_deduplicate() {
533        // Deduplicating would mean deciding two matrices are equal, which is a
534        // tolerance question, precisely the one the chain exists to avoid.
535        let mut s = DatumStore::new();
536        let t = Transform::translation(Vector::X);
537        let a = s.insert(t);
538        let b = s.insert(t);
539        assert_ne!(a, b);
540        assert_eq!(s.len(), 2);
541        assert!(!s.is_empty());
542    }
543}