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}