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}