Skip to main content

dualis_core/
pose.rs

1//! Where something is, and which way it faces.
2//!
3//! The kernel had fields — functions of position — and [`Interface`](crate::Interface) for a
4//! discretised boundary, and no way to say where either of them *was*. A domain's coordinates
5//! were world coordinates, implicitly, so two grids could not be placed against each other at
6//! all: not rotated, not offset, not stacked. Almost everything a scene wants sits behind this.
7//!
8//! # A rigid motion, and nothing more
9//!
10//! A [`Pose`] is a rotation and a translation. It is deliberately **not** a general transform:
11//!
12//! - **No scale.** A scaled metre is not a metre. Every quantity in this workspace carries its
13//!   dimension in its type precisely so a factor of a thousand appears in exactly one place, and
14//!   a transform that could stretch space would put one everywhere — silently, in a matrix.
15//! - **No shear, no projection.** Both change lengths and angles, and a conservation law stated
16//!   over a sheared volume is a different law.
17//!
18//! What is left is an isometry, which preserves every distance and angle exactly. That is the
19//! only class of placement a physics can be moved by without its physics changing, and it is
20//! why this type is this small.
21//!
22//! # Which placement this is
23//!
24//! **This is physical placement**: a pose changes what the physics computes. Two solids in
25//! contact, a lens at a distance, a grid rotated against its neighbour.
26//!
27//! The other kind — a position handed to something that *has* no geometry, purely so a viewer
28//! can draw it — is deliberately not here. A [`ThermalNetwork`] node has a capacity and not a
29//! position, and a conductance is not a distance; giving one a coordinate is a statement about a
30//! diagram, not about heat. That belongs to the scene layer, above this one, where the physics
31//! cannot reach it. If the two shared a type, a drawing coordinate would eventually arrive in a
32//! conductance and nothing would fail loudly.
33//!
34//! [`ThermalNetwork`]: https://docs.rs/dualis-thermal
35//!
36//! ```
37//! use dualis_core::Pose;
38//! use dualis_units::{Length, LengthVec};
39//! use glam::{DQuat, DVec3};
40//!
41//! // A grid whose own origin sits 2 m along x, turned a quarter turn about z.
42//! let placed = Pose::new(
43//!     LengthVec::m(2.0, 0.0, 0.0),
44//!     DQuat::from_rotation_z(std::f64::consts::FRAC_PI_2),
45//! );
46//!
47//! // Its local +x axis points along world +y.
48//! let facing = placed.direction_to_world(DVec3::X);
49//! assert!((facing - DVec3::Y).length() < 1e-15);
50//!
51//! // And a point one metre out along that axis lands at (2, 1, 0).
52//! let p = placed.point_to_world(LengthVec::m(1.0, 0.0, 0.0));
53//! assert!((p.to_si() - DVec3::new(2.0, 1.0, 0.0)).length() < 1e-15);
54//! ```
55
56use dualis_units::LengthVec;
57use glam::{DQuat, DVec3};
58
59/// A rotation and a translation: where a domain's own coordinates sit in the world.
60///
61/// Cheap to copy. Composition is [`then`](Pose::then) and reads left to right.
62#[derive(Clone, Copy, Debug, PartialEq)]
63pub struct Pose {
64    translation: LengthVec,
65    rotation: DQuat,
66}
67
68impl Default for Pose {
69    fn default() -> Pose {
70        Pose::IDENTITY
71    }
72}
73
74impl Pose {
75    /// At the origin, unrotated. What a domain has until something places it.
76    pub const IDENTITY: Pose = Pose {
77        translation: LengthVec::ZERO,
78        rotation: DQuat::IDENTITY,
79    };
80
81    /// A rotation and a translation.
82    ///
83    /// The rotation is normalised on the way in. A quaternion accumulated by repeated
84    /// multiplication drifts off the unit sphere, and an unnormalised one stretches space —
85    /// which is exactly the thing this type exists not to do.
86    pub fn new(translation: LengthVec, rotation: DQuat) -> Pose {
87        Pose {
88            translation,
89            rotation: rotation.normalize(),
90        }
91    }
92
93    /// Moved, not turned.
94    pub fn at(translation: LengthVec) -> Pose {
95        Pose::new(translation, DQuat::IDENTITY)
96    }
97
98    /// Turned, not moved.
99    pub fn turned(rotation: DQuat) -> Pose {
100        Pose::new(LengthVec::ZERO, rotation)
101    }
102
103    /// Where the local origin sits in the world.
104    pub fn translation(&self) -> LengthVec {
105        self.translation
106    }
107
108    /// How the local axes are turned. Always a unit quaternion.
109    pub fn rotation(&self) -> DQuat {
110        self.rotation
111    }
112
113    /// A point in local coordinates, in the world.
114    pub fn point_to_world(&self, local: LengthVec) -> LengthVec {
115        LengthVec::from_si(self.rotation * local.to_si() + self.translation.to_si())
116    }
117
118    /// A point in world coordinates, in the local frame.
119    ///
120    /// The exact inverse of [`point_to_world`](Pose::point_to_world) up to rounding — a rigid
121    /// motion has an exact inverse, unlike anything that scales.
122    pub fn point_to_local(&self, world: LengthVec) -> LengthVec {
123        LengthVec::from_si(
124            self.rotation
125                .conjugate()
126                .mul_vec3(world.to_si() - self.translation.to_si()),
127        )
128    }
129
130    /// A direction in local coordinates, in the world.
131    ///
132    /// Rotated and **not** translated, which is the whole difference between a direction and a
133    /// point. A surface normal moved by a translation would stop being normal to anything.
134    pub fn direction_to_world(&self, local: DVec3) -> DVec3 {
135        self.rotation * local
136    }
137
138    /// A direction in world coordinates, in the local frame.
139    pub fn direction_to_local(&self, world: DVec3) -> DVec3 {
140        self.rotation.conjugate().mul_vec3(world)
141    }
142
143    /// This pose, then `outer`: the result of placing something by `self` inside a frame that is
144    /// itself placed by `outer`.
145    ///
146    /// Reads left to right, so `a.then(b).then(c)` applies `a` first. Associative, and a test
147    /// says so — matrix composition order is the classic place a sign or an order gets lost, and
148    /// it is silent when it does.
149    pub fn then(&self, outer: Pose) -> Pose {
150        Pose {
151            translation: LengthVec::from_si(
152                outer.rotation * self.translation.to_si() + outer.translation.to_si(),
153            ),
154            rotation: (outer.rotation * self.rotation).normalize(),
155        }
156    }
157
158    /// The pose that undoes this one.
159    pub fn inverse(&self) -> Pose {
160        let r = self.rotation.conjugate();
161        Pose {
162            translation: LengthVec::from_si(-(r.mul_vec3(self.translation.to_si()))),
163            rotation: r,
164        }
165    }
166
167    /// Whether this pose moves anything, within a tolerance.
168    ///
169    /// For a caller that can take a fast path when nothing is placed — sampling a field through
170    /// an identity pose should cost nothing.
171    pub fn is_identity(&self, tol: f64) -> bool {
172        self.translation.to_si().length() <= tol && (self.rotation.w.abs() - 1.0).abs() <= tol
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use dualis_units::Length;
180    use std::f64::consts::{FRAC_PI_2, PI};
181
182    fn wild() -> Pose {
183        Pose::new(
184            LengthVec::m(1.5, -0.25, 3.0),
185            DQuat::from_euler(glam::EulerRot::XYZ, 0.3, -1.1, 2.2),
186        )
187    }
188
189    /// **An isometry preserves every distance, which is the property that lets physics be moved.**
190    ///
191    /// If a pose could change a length, then a conservation law stated in local coordinates
192    /// would not be the same law in world coordinates, and placing a domain would silently
193    /// change its physics. That is the reason this type has no scale and the reason this is the
194    /// first test.
195    #[test]
196    fn placing_something_cannot_change_a_distance() {
197        let p = wild();
198        let pairs = [
199            (LengthVec::m(0.0, 0.0, 0.0), LengthVec::m(1.0, 0.0, 0.0)),
200            (LengthVec::m(-2.0, 5.0, 0.5), LengthVec::m(3.0, -1.0, 4.0)),
201            (LengthVec::m(1e-6, 0.0, 0.0), LengthVec::m(-1e-6, 0.0, 0.0)),
202        ];
203        for (a, b) in pairs {
204            let before = (a.to_si() - b.to_si()).length();
205            let after = (p.point_to_world(a).to_si() - p.point_to_world(b).to_si()).length();
206            assert!(
207                (after - before).abs() <= 1e-15 * before.max(1.0),
208                "a {before} m separation became {after} m"
209            );
210        }
211    }
212
213    /// **A round trip returns what went in**, which is what "rigid" buys and what a scaling
214    /// transform could not offer.
215    #[test]
216    fn local_to_world_and_back_is_the_identity() {
217        let p = wild();
218        for v in [
219            LengthVec::m(0.0, 0.0, 0.0),
220            LengthVec::m(1.0, 2.0, 3.0),
221            LengthVec::m(-7.5, 0.0, 1e3),
222        ] {
223            let back = p.point_to_local(p.point_to_world(v));
224            assert!(
225                (back.to_si() - v.to_si()).length() < 1e-12,
226                "{:?} came back as {:?}",
227                v.to_si(),
228                back.to_si()
229            );
230        }
231        // And for directions, which do not translate.
232        for d in [
233            DVec3::X,
234            DVec3::Y,
235            DVec3::Z,
236            DVec3::new(1.0, -2.0, 0.5).normalize(),
237        ] {
238            let back = p.direction_to_local(p.direction_to_world(d));
239            assert!((back - d).length() < 1e-12);
240        }
241    }
242
243    /// **A direction is rotated and not translated.**
244    ///
245    /// The one-line difference between a point and a direction, and the bug that follows from
246    /// missing it is a surface normal that stops being normal to its surface as soon as anything
247    /// is moved off the origin — while every length still checks out.
248    #[test]
249    fn a_direction_ignores_the_translation() {
250        let far = Pose::at(LengthVec::m(1e4, -1e4, 1e4));
251        for d in [DVec3::X, DVec3::Y, DVec3::Z] {
252            assert_eq!(
253                far.direction_to_world(d),
254                d,
255                "translation rotated a direction"
256            );
257        }
258        // A normal stays perpendicular to the surface it came from, wherever the surface goes.
259        let p = wild();
260        let (u, v) = (DVec3::X, DVec3::Y);
261        let n = u.cross(v);
262        let (u2, v2, n2) = (
263            p.direction_to_world(u),
264            p.direction_to_world(v),
265            p.direction_to_world(n),
266        );
267        assert!(n2.dot(u2).abs() < 1e-15 && n2.dot(v2).abs() < 1e-15);
268        assert!((u2.cross(v2) - n2).length() < 1e-15, "handedness flipped");
269    }
270
271    /// **Composition is associative, and reads left to right.**
272    ///
273    /// Order and handedness are where a transform quietly goes wrong: the result is still a
274    /// valid pose, still preserves lengths, and puts everything in the wrong place. Checked
275    /// against a case whose answer is known by hand as well as against associativity.
276    #[test]
277    fn composing_is_associative_and_in_the_order_it_reads() {
278        let (a, b, c) = (
279            Pose::at(LengthVec::m(1.0, 0.0, 0.0)),
280            Pose::turned(DQuat::from_rotation_z(FRAC_PI_2)),
281            Pose::at(LengthVec::m(0.0, 0.0, 2.0)),
282        );
283        let left = a.then(b).then(c);
284        let right = a.then(b.then(c));
285        assert!((left.translation.to_si() - right.translation.to_si()).length() < 1e-14);
286        assert!(left.rotation.abs_diff_eq(right.rotation, 1e-14));
287
288        // By hand: translate 1 m along x, then turn a quarter turn about z — the point lands on
289        // +y, not +x. If `then` had composed the other way it would still be at +x.
290        let origin = a.then(b).point_to_world(LengthVec::ZERO);
291        assert!(
292            (origin.to_si() - DVec3::new(0.0, 1.0, 0.0)).length() < 1e-14,
293            "a then b put the origin at {:?}",
294            origin.to_si()
295        );
296        // And the other order does something different, which is what makes the check mean
297        // anything.
298        let swapped = b.then(a).point_to_world(LengthVec::ZERO);
299        assert!((swapped.to_si() - DVec3::new(1.0, 0.0, 0.0)).length() < 1e-14);
300    }
301
302    /// **A pose and its inverse cancel exactly enough**, which a scaling transform could not
303    /// promise.
304    #[test]
305    fn a_pose_undoes_itself() {
306        let p = wild();
307        let both = p.then(p.inverse());
308        assert!(both.is_identity(1e-12), "{both:?}");
309        assert!(p.inverse().then(p).is_identity(1e-12));
310
311        // The inverse of the identity is the identity, and a half turn is its own inverse.
312        assert!(Pose::IDENTITY.inverse().is_identity(0.0));
313        let half = Pose::turned(DQuat::from_rotation_y(PI));
314        assert!(half.then(half).is_identity(1e-14));
315    }
316
317    /// The identity does nothing, and says it does nothing.
318    #[test]
319    fn the_identity_is_free() {
320        let v = LengthVec::m(3.0, -1.0, 0.25);
321        assert_eq!(Pose::IDENTITY.point_to_world(v).to_si(), v.to_si());
322        assert_eq!(Pose::default(), Pose::IDENTITY);
323        assert!(Pose::IDENTITY.is_identity(0.0));
324        assert!(!Pose::at(LengthVec::m(1e-3, 0.0, 0.0)).is_identity(1e-9));
325        // A metre is a metre: no constructor here can change one.
326        assert_eq!(
327            Pose::at(LengthVec::m(5.0, 0.0, 0.0))
328                .point_to_world(LengthVec::m(1.0, 0.0, 0.0))
329                .to_si()
330                .x,
331            Length::m(6.0).to_si()
332        );
333    }
334}