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. A power lies
117/// in `-i32::MAX..=i32::MAX`, a range closed under negation, so every chain
118/// inverts exactly.
119/// Equality and hashing are structural: two locations agree when their chains
120/// match entry for entry. Deliberately *not* a comparison of the composed
121/// transforms; that would be a tolerance question, and the answer would depend
122/// on rounding rather than on what the model says.
123#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
124pub struct Location {
125    chain: SmallVec<[(DatumId, i32); 2]>,
126}
127
128impl Location {
129    /// The identity placement.
130    #[must_use]
131    pub fn identity() -> Self {
132        Self::default()
133    }
134
135    /// A placement of one datum, applied once.
136    #[must_use]
137    pub fn of(datum: DatumId) -> Self {
138        let mut chain = SmallVec::new();
139        chain.push((datum, 1));
140        Self { chain }
141    }
142
143    /// A placement of one datum, applied `power` times.
144    ///
145    /// A power of zero gives the identity; a negative power gives the inverse.
146    /// `i32::MIN`, whose negation `i32` cannot hold, is kept as the two entries
147    /// `-i32::MAX` and `-1`.
148    #[must_use]
149    pub fn powered(datum: DatumId, power: i32) -> Self {
150        if power == 0 {
151            return Self::identity();
152        }
153        let mut chain = SmallVec::new();
154        if power == i32::MIN {
155            chain.push((datum, -i32::MAX));
156            chain.push((datum, -1));
157        } else {
158            chain.push((datum, power));
159        }
160        Self { chain }
161    }
162
163    /// Whether this is the identity.
164    #[must_use]
165    pub fn is_identity(&self) -> bool {
166        self.chain.is_empty()
167    }
168
169    /// Whether this placement is `inner` with further placements outside
170    /// it: `inner`'s chain is this chain's tail.
171    #[must_use]
172    pub fn ends_with(&self, inner: &Self) -> bool {
173        self.chain.ends_with(&inner.chain)
174    }
175
176    /// The chain, outermost entry first.
177    #[must_use]
178    pub fn chain(&self) -> &[(DatumId, i32)] {
179        &self.chain
180    }
181
182    /// Number of entries.
183    #[must_use]
184    pub fn depth(&self) -> usize {
185        self.chain.len()
186    }
187
188    /// This placement followed by `inner`.
189    ///
190    /// `outer.then(inner)` applies `inner` first, then `outer`, the same order
191    /// as transform composition, so a sub-shape's placement composed with its
192    /// parent's reads the way the tree does.
193    ///
194    /// Adjacent entries naming the same datum are merged by adding their
195    /// powers when the sum stays in the power range, and an entry whose power
196    /// reaches zero is dropped. That keeps a
197    /// chain from growing without bound as a placement is composed and undone
198    /// repeatedly, and it is what makes `a.then(a.inverted())` come out exactly
199    /// equal to the identity rather than merely close to it.
200    #[must_use]
201    pub fn then(&self, inner: &Self) -> Self {
202        // Most placements in a tree are the identity: nothing to merge.
203        if inner.chain.is_empty() {
204            return self.clone();
205        }
206        if self.chain.is_empty() {
207            return inner.clone();
208        }
209        let mut chain = self.chain.clone();
210        for &(datum, power) in &inner.chain {
211            match chain.last_mut() {
212                Some((last, last_power))
213                    if *last == datum
214                        && last_power
215                            .checked_add(power)
216                            .is_some_and(|sum| sum != i32::MIN) =>
217                {
218                    *last_power += power;
219                    if *last_power == 0 {
220                        chain.pop();
221                    }
222                }
223                _ => chain.push((datum, power)),
224            }
225        }
226        Self { chain }
227    }
228
229    /// This placement with every datum handle bound to a given store.
230    ///
231    /// For reading a document back: the handles a file records are unscoped
232    /// until the store that will hold them exists.
233    pub(crate) fn with_datum_scope(&self, scope: u32) -> Self {
234        Self {
235            chain: self
236                .chain
237                .iter()
238                .map(|(datum, power)| (datum.with_scope(scope), *power))
239                .collect(),
240        }
241    }
242
243    /// This placement with every datum handle shifted by `offset` slots.
244    ///
245    /// For absorbing one document's parts into another: the chain's indices
246    /// were local to the source document, and its datums are about to land
247    /// `offset` slots into the target's store. The handles stay unscoped;
248    /// binding is a separate, later step.
249    pub(crate) fn with_datum_offset(&self, offset: u32) -> Self {
250        Self {
251            chain: self
252                .chain
253                .iter()
254                .map(|(datum, power)| (crate::entity::shifted_key(*datum, offset), *power))
255                .collect(),
256        }
257    }
258
259    /// The inverse placement.
260    ///
261    /// Exact: the chain reverses and every power negates. No matrix is
262    /// inverted, so `l.then(&l.inverted())` is the identity structurally, not
263    /// approximately.
264    #[must_use]
265    pub fn inverted(&self) -> Self {
266        Self {
267            chain: self.chain.iter().rev().map(|&(d, p)| (d, -p)).collect(),
268        }
269    }
270
271    /// The composed transform.
272    ///
273    /// # Errors
274    ///
275    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the chain names a
276    /// datum the store does not hold, and
277    /// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if a negative power
278    /// requires inverting a degenerate transform.
279    pub fn composed(&self, store: &DatumStore) -> OgeomResult<Transform> {
280        let mut result = Transform::IDENTITY;
281        for &(id, power) in &self.chain {
282            let Some(datum) = store.get(id) else {
283                ogeom_bail!(Dangling, "location refers to a datum not in this store");
284            };
285            let step = if power >= 0 { datum } else { datum.inverse()? };
286            result = result * raised(step, power.unsigned_abs());
287        }
288        Ok(result)
289    }
290
291    /// Whether the composed transform preserves handedness.
292    ///
293    /// A shape placed by a handedness-reversing location has to have its
294    /// orientation flipped to stay consistent, or a mirrored solid ends up
295    /// inside out.
296    ///
297    /// # Errors
298    ///
299    /// As [`Location::composed`].
300    pub fn preserves_handedness(&self, store: &DatumStore) -> OgeomResult<bool> {
301        Ok(self.composed(store)?.preserves_handedness())
302    }
303
304    /// Whether two placements put a shape in the same position.
305    ///
306    /// Falls back to comparing the composed transforms, which costs more than
307    /// [`PartialEq`] and answers a different question: two chains built by
308    /// different routes can describe the same placement.
309    ///
310    /// # Errors
311    ///
312    /// As [`Location::composed`].
313    pub fn is_same_placement(
314        &self,
315        other: &Self,
316        store: &DatumStore,
317        tol: Tolerances,
318    ) -> OgeomResult<bool> {
319        if self == other {
320            return Ok(true);
321        }
322        Ok(self.composed(store)?.is_equal(&other.composed(store)?, tol))
323    }
324
325    /// The kind of the composed transform, for dispatch.
326    ///
327    /// # Errors
328    ///
329    /// As [`Location::composed`].
330    pub fn kind(&self, store: &DatumStore) -> OgeomResult<TransformKind> {
331        if self.is_identity() {
332            return Ok(TransformKind::Identity);
333        }
334        Ok(self.composed(store)?.kind())
335    }
336}
337
338/// `step` applied `times` times, by repeated squaring: a power costs its
339/// bit length in products, not its size.
340fn raised(step: Transform, times: u32) -> Transform {
341    let mut result = Transform::IDENTITY;
342    let mut base = step;
343    let mut left = times;
344    while left > 0 {
345        if left & 1 == 1 {
346            result = result * base;
347        }
348        left >>= 1;
349        if left > 0 {
350            base = base * base;
351        }
352    }
353    result
354}
355
356#[cfg(test)]
357#[allow(clippy::unwrap_used)]
358mod tests {
359    use super::*;
360    use ogeom_math::{Axis, Point, Vector};
361
362    const T: Tolerances = Tolerances::millimetres();
363
364    fn store() -> (DatumStore, DatumId, DatumId) {
365        let mut s = DatumStore::new();
366        let a = s.insert(Transform::translation(Vector::new(1.0, 0.0, 0.0)));
367        let b = s.insert(Transform::rotation(Axis::Z, core::f64::consts::FRAC_PI_2));
368        (s, a, b)
369    }
370
371    #[test]
372    fn the_identity_is_empty_and_composes_to_nothing() {
373        let (s, _, _) = store();
374        let id = Location::identity();
375        assert!(id.is_identity());
376        assert_eq!(id.depth(), 0);
377        assert_eq!(id.composed(&s).unwrap().kind(), TransformKind::Identity);
378    }
379
380    #[test]
381    fn composition_applies_the_inner_placement_first() {
382        let (s, a, b) = store();
383        let outer = Location::of(a);
384        let inner = Location::of(b);
385        let combined = outer.then(&inner);
386
387        let expected = outer.composed(&s).unwrap() * inner.composed(&s).unwrap();
388        assert!(combined.composed(&s).unwrap().is_equal(&expected, T));
389
390        // And the two orders differ, as they must.
391        assert!(
392            !combined
393                .composed(&s)
394                .unwrap()
395                .is_equal(&inner.then(&outer).composed(&s).unwrap(), T)
396        );
397    }
398
399    #[test]
400    fn inversion_is_exact_rather_than_approximate() {
401        // The property a matrix representation cannot offer: composing a
402        // placement with its inverse gives the identity *structurally*, with no
403        // residue to accumulate down an assembly tree.
404        let (_, a, b) = store();
405        let l = Location::of(a)
406            .then(&Location::of(b))
407            .then(&Location::of(a));
408        let round_trip = l.then(&l.inverted());
409        assert!(round_trip.is_identity(), "chain: {:?}", round_trip.chain());
410        assert_eq!(round_trip, Location::identity());
411    }
412
413    #[test]
414    fn repeated_composition_does_not_grow_the_chain() {
415        // A placement applied a hundred times is one entry with a power of 100,
416        // not a hundred entries, so an assembly that nests deeply stays cheap
417        // to compare and to store.
418        let (s, a, _) = store();
419        let mut l = Location::identity();
420        for _ in 0..100 {
421            l = l.then(&Location::of(a));
422        }
423        assert_eq!(l.depth(), 1);
424        assert_eq!(l.chain()[0].1, 100);
425        assert!(
426            l.composed(&s)
427                .unwrap()
428                .apply(Point::ORIGIN)
429                .is_equal(Point::new(100.0, 0.0, 0.0), T)
430        );
431    }
432
433    #[test]
434    fn powers_cancel_exactly() {
435        let (s, a, _) = store();
436        let forward = Location::powered(a, 5);
437        let back = Location::powered(a, -5);
438        assert!(forward.then(&back).is_identity());
439        assert_eq!(Location::powered(a, 0), Location::identity());
440        assert!(
441            Location::powered(a, -2)
442                .composed(&s)
443                .unwrap()
444                .apply(Point::ORIGIN)
445                .is_equal(Point::new(-2.0, 0.0, 0.0), T)
446        );
447    }
448
449    #[test]
450    fn equality_is_structural_not_numerical() {
451        // Two chains that compose to the same transform are still different
452        // placements. Asking whether they *are* the same is a comparison of
453        // integers; asking whether they *land* in the same place is a separate,
454        // costlier question with its own method.
455        let mut s = DatumStore::new();
456        let a = s.insert(Transform::translation(Vector::new(1.0, 0.0, 0.0)));
457        let b = s.insert(Transform::translation(Vector::new(1.0, 0.0, 0.0)));
458
459        let via_a = Location::of(a);
460        let via_b = Location::of(b);
461        assert_ne!(via_a, via_b, "different datums are different placements");
462        assert!(via_a.is_same_placement(&via_b, &s, T).unwrap());
463
464        assert_eq!(via_a, Location::of(a));
465        assert!(via_a.is_same_placement(&Location::of(a), &s, T).unwrap());
466    }
467
468    #[test]
469    fn locations_hash_consistently_with_equality() {
470        use ogeom_core::FastSet;
471        let (_, a, b) = store();
472        let mut set = FastSet::default();
473        set.insert(Location::of(a));
474        set.insert(Location::of(a));
475        set.insert(Location::of(b));
476        set.insert(Location::identity());
477        assert_eq!(set.len(), 3);
478        assert!(set.contains(&Location::of(a)));
479    }
480
481    #[test]
482    fn evaluating_a_location_does_not_change_its_identity() {
483        // A location is used as a hash key, so nothing it does may alter how it
484        // compares or hashes. It holds no interior mutability at all, which is
485        // what makes that guarantee rather than a hope.
486        let (s, a, _) = store();
487        let x = Location::of(a);
488        let y = Location::of(a);
489        let _ = x.composed(&s).unwrap();
490        assert_eq!(x, y);
491
492        use std::collections::hash_map::DefaultHasher;
493        use std::hash::{Hash, Hasher};
494        let hash = |l: &Location| {
495            let mut h = DefaultHasher::new();
496            l.hash(&mut h);
497            h.finish()
498        };
499        assert_eq!(hash(&x), hash(&y));
500    }
501
502    #[test]
503    fn a_location_is_send_and_sync() {
504        // Needed for the parallel algorithms further up the stack, and easy to
505        // lose to a cache tucked inside the type.
506        const fn assert_send_sync<T: Send + Sync>() {}
507        assert_send_sync::<Location>();
508        assert_send_sync::<DatumStore>();
509    }
510
511    #[test]
512    fn a_handle_the_store_does_not_hold_is_reported_rather_than_ignored() {
513        let (s, _, _) = store();
514        let mut other = DatumStore::new();
515        // Past the end of `s`, so it genuinely does not resolve.
516        let mut beyond = other.insert(Transform::translation(Vector::Y));
517        for _ in 0..10 {
518            beyond = other.insert(Transform::translation(Vector::Y));
519        }
520        assert!(Location::of(beyond).composed(&s).is_err());
521    }
522
523    #[test]
524    fn a_handle_from_another_store_is_refused_rather_than_resolved() {
525        // Resolved, it would name whatever transform happened to sit at that
526        // index, silently and confidently. An arena handle carries the
527        // identifier of the arena that issued it, so a foreign one names a
528        // datum this store does not have, and composing it says so.
529        let (s, first, _) = store();
530        let mut other = DatumStore::new();
531        let foreign = other.insert(Transform::translation(Vector::new(0.0, 99.0, 0.0)));
532
533        assert_eq!(
534            foreign.index(),
535            first.index(),
536            "the same slot in both stores, so only the store tells the two apart"
537        );
538        assert!(s.get(foreign).is_none());
539        assert!(Location::of(foreign).composed(&s).is_err());
540        assert!(Location::of(first).composed(&s).is_ok());
541    }
542
543    #[test]
544    fn handedness_follows_the_composed_transform() {
545        let mut s = DatumStore::new();
546        let mirror = s.insert(Transform::plane_mirror(
547            Point::ORIGIN,
548            ogeom_math::Direction::Z,
549        ));
550        let once = Location::of(mirror);
551        assert!(!once.preserves_handedness(&s).unwrap());
552        // Two mirrors make a rotation.
553        assert!(
554            Location::powered(mirror, 2)
555                .preserves_handedness(&s)
556                .unwrap()
557        );
558        assert!(Location::identity().preserves_handedness(&s).unwrap());
559    }
560
561    #[test]
562    fn composing_different_datums_keeps_both_entries() {
563        let (_, a, b) = store();
564        let l = Location::of(a).then(&Location::of(b));
565        assert_eq!(l.depth(), 2);
566        assert_eq!(l.chain(), &[(a, 1), (b, 1)]);
567    }
568
569    /// A power costs its bit length in products: the largest a chain can
570    /// hold composes at once, and lands where the arithmetic says.
571    #[test]
572    fn a_huge_power_composes_at_once() {
573        let mut s = DatumStore::new();
574        let step = s.insert(Transform::translation(Vector::new(1e-9, 0.0, 0.0)));
575        let far = Location::powered(step, i32::MAX).composed(&s).unwrap();
576        let x = far.apply(Point::ORIGIN).x;
577        let want = 1e-9 * f64::from(i32::MAX);
578        assert!((x - want).abs() < want * 1e-9, "{x} against {want}");
579        let turn = s.insert(Transform::rotation(
580            ogeom_math::Axis::new(Point::ORIGIN, ogeom_math::Direction::Z),
581            core::f64::consts::FRAC_PI_2,
582        ));
583        let four = Location::powered(turn, 4).composed(&s).unwrap();
584        assert!(
585            four.apply(Point::new(1.0, 0.0, 0.0))
586                .distance(Point::new(1.0, 0.0, 0.0))
587                < 1e-12
588        );
589    }
590
591    /// Powers that would overflow when merged stay two entries.
592    #[test]
593    fn merging_powers_never_overflows() {
594        let (_, a, _) = store();
595        let l = Location::powered(a, i32::MAX).then(&Location::powered(a, 1));
596        assert_eq!(l.chain(), &[(a, i32::MAX), (a, 1)]);
597    }
598
599    /// The most negative power has no negation in `i32`; a chain never holds
600    /// it, so every placement inverts exactly, back and forth.
601    #[test]
602    fn the_most_negative_power_inverts_exactly() {
603        let (s, a, _) = store();
604        let built = Location::powered(a, i32::MIN);
605        let merged = Location::powered(a, i32::MIN + 1).then(&Location::powered(a, -1));
606        for l in [built, merged] {
607            assert!(
608                l.chain().iter().all(|&(_, p)| p != i32::MIN),
609                "{:?}",
610                l.chain()
611            );
612            let x = |l: &Location| l.composed(&s).unwrap().apply(Point::ORIGIN).x;
613            assert_eq!(x(&l), -2_147_483_648.0);
614            let inverse = l.inverted();
615            assert_eq!(x(&inverse), 2_147_483_648.0);
616            assert_eq!(inverse.inverted(), l);
617            assert!(l.then(&inverse).is_identity());
618            assert!(inverse.then(&l).is_identity());
619        }
620    }
621
622    #[test]
623    fn the_datum_store_does_not_deduplicate() {
624        // Deduplicating would mean deciding two matrices are equal, which is a
625        // tolerance question, precisely the one the chain exists to avoid.
626        let mut s = DatumStore::new();
627        let t = Transform::translation(Vector::X);
628        let a = s.insert(t);
629        let b = s.insert(t);
630        assert_ne!(a, b);
631        assert_eq!(s.len(), 2);
632        assert!(!s.is_empty());
633    }
634}