@@ -217,6 +217,27 @@ pub fn inverse(q: anytype) @TypeOf(q) {
217217 return conjugate (q ) / @as (@TypeOf (q ), @splat (vector .norm (q )));
218218}
219219
220+ /// Returns `q` scaled to unit length, or `identity` when `q` is too short to denote a
221+ /// rotation. `vector.normalize` divides by the norm unconditionally, so a zero quaternion
222+ /// yields NaN; a rotation wants a usable value instead.
223+ ///
224+ /// The threshold is on the *squared* norm, so no square root is taken on the reject path.
225+ pub fn normalize (q : anytype , min_norm_sqr : std .meta .Child (@TypeOf (q ))) @TypeOf (q ) {
226+ comptime {
227+ std .debug .assert (@typeInfo (@TypeOf (q )) == .vector );
228+ std .debug .assert (@typeInfo (@TypeOf (q )).vector .len == 4 );
229+ }
230+ const T = std .meta .Child (@TypeOf (q ));
231+ const len_sqr = vector .norm_sqr (q );
232+ if (len_sqr <= min_norm_sqr ) return identity (T );
233+ return q / @as (@TypeOf (q ), @splat (@sqrt (len_sqr )));
234+ }
235+
236+ /// `normalize` with a default threshold of 1.0e-6 on the squared norm.
237+ pub fn normalize_default (q : anytype ) @TypeOf (q ) {
238+ return normalize (q , 1.0e-6 );
239+ }
240+
220241pub fn slerp (a : anytype , b : anytype , factor : std .meta .Child (@TypeOf (a ))) Quat (std .meta .Child (@TypeOf (a ))) {
221242 comptime {
222243 std .debug .assert (@typeInfo (@TypeOf (a )) == .vector );
@@ -269,6 +290,31 @@ pub fn lerp(a: anytype, b: anytype, factor: std.meta.Child(@TypeOf(a))) Quat(std
269290 @as (Quat (std .meta .Child (@TypeOf (a ))), @splat (factor )) * map_to_vector (b );
270291}
271292
293+ /// Normalized lerp: interpolates along the chord and renormalizes, taking the shorter of the
294+ /// two arcs. Cheaper than `slerp` but not constant angular velocity.
295+ ///
296+ /// Unlike `lerp`, this negates `b` when the two quaternions lie on opposite hemispheres, so
297+ /// `nlerp(q, -q, t)` stays at `q` rather than sweeping through the antipodal rotation. Since
298+ /// `q` and `-q` denote the same orientation, that flip is what makes the result track the
299+ /// intended rotation.
300+ pub fn nlerp (a : anytype , b : anytype , factor : std .meta .Child (@TypeOf (a ))) Quat (std .meta .Child (@TypeOf (a ))) {
301+ comptime {
302+ std .debug .assert (@typeInfo (@TypeOf (a )) == .vector );
303+ std .debug .assert (@typeInfo (@TypeOf (a )).vector .len == 4 );
304+ std .debug .assert (@typeInfo (@TypeOf (b )) == .vector );
305+ std .debug .assert (@typeInfo (@TypeOf (b )).vector .len == 4 );
306+ std .debug .assert (@TypeOf (a ) == @TypeOf (b ));
307+ }
308+ const inner_a = map_to_vector (a );
309+ const inner_b = map_to_vector (b );
310+ const nearest = if (vector .dot (inner_a , inner_b ) < 0 ) - inner_b else inner_b ;
311+ return normalize_default (lerp (inner_a , nearest , factor ));
312+ }
313+
314+ /// Intrinsic Z-Y-X (equivalently extrinsic X-Y-Z): `q = qz * qy * qx`, i.e. the X rotation is
315+ /// applied first about the fixed axes. `angles` is ordered `.{ x, y, z }` regardless.
316+ ///
317+ /// See `from_euler_xyz_intrinsic` for the mirrored convention.
272318pub fn from_eular_angles (inAngles : anytype ) @Vector (4 , std .meta .Child (@TypeOf (inAngles ))) {
273319 comptime {
274320 std .debug .assert (@typeInfo (@TypeOf (inAngles )) == .vector );
@@ -298,6 +344,8 @@ pub fn from_rotation(axis: anytype, angle: std.meta.Child(@TypeOf(axis))) @Vecto
298344 return .{ in_axis [0 ] * std .math .sin (angle * 0.5 ), in_axis [1 ] * std .math .sin (angle * 0.5 ), in_axis [2 ] * std .math .sin (angle * 0.5 ), std .math .cos (angle * 0.5 ) };
299345}
300346
347+ /// Inverse of `from_eular_angles`: recovers intrinsic Z-Y-X angles as `.{ x, y, z }`.
348+ /// `q` is assumed normalized.
301349pub fn to_eular_angles (q : anytype ) @Vector (3 , std .meta .Child (@TypeOf (q ))) {
302350 comptime {
303351 std .debug .assert (@typeInfo (@TypeOf (q )) == .vector );
@@ -324,6 +372,61 @@ pub fn to_eular_angles(q: anytype) @Vector(3, std.meta.Child(@TypeOf(q))) {
324372 return .{ roll , pitch , yaw };
325373}
326374
375+ /// Intrinsic X-Y-Z (equivalently extrinsic Z-Y-X): `q = qx * qy * qz`, i.e. the Z rotation is
376+ /// applied first about the fixed axes. `angles` is ordered `.{ x, y, z }`.
377+ ///
378+ /// This is the mirror of `from_eular_angles`, which composes in the opposite order. The two
379+ /// are not interchangeable; pick by the convention your data was authored in.
380+ pub fn from_euler_xyz_intrinsic (angles : anytype ) @Vector (4 , std .meta .Child (@TypeOf (angles ))) {
381+ comptime {
382+ std .debug .assert (@typeInfo (@TypeOf (angles )) == .vector );
383+ std .debug .assert (@typeInfo (@TypeOf (angles )).vector .len == 3 );
384+ }
385+
386+ const half = @as (@TypeOf (angles ), @splat (0.5 )) * angles ;
387+ const res = vector .sin_cos (half );
388+
389+ const sx = res .sin_out [0 ];
390+ const cx = res .cos_out [0 ];
391+ const sy = res .sin_out [1 ];
392+ const cy = res .cos_out [1 ];
393+ const sz = res .sin_out [2 ];
394+ const cz = res .cos_out [2 ];
395+
396+ return .{
397+ sx * cy * cz + cx * sy * sz ,
398+ cx * sy * cz - sx * cy * sz ,
399+ cx * cy * sz + sx * sy * cz ,
400+ cx * cy * cz - sx * sy * sz ,
401+ };
402+ }
403+
404+ /// Inverse of `from_euler_xyz_intrinsic`: recovers intrinsic X-Y-Z angles as `.{ x, y, z }`.
405+ /// `q` is assumed normalized.
406+ ///
407+ /// The Y angle is extracted through `asin` and so is clamped to [-pi/2, pi/2]; at the poles
408+ /// (|sin y| == 1) the X and Z angles are not separable and the decomposition is not unique.
409+ pub fn to_euler_xyz_intrinsic (q : anytype ) @Vector (3 , std .meta .Child (@TypeOf (q ))) {
410+ comptime {
411+ std .debug .assert (@typeInfo (@TypeOf (q )) == .vector );
412+ std .debug .assert (@typeInfo (@TypeOf (q )).vector .len == 4 );
413+ }
414+ const T = std .meta .Child (@TypeOf (q ));
415+
416+ // Matrix elements of the rotation q denotes: r12, r22, r02, r01, r00.
417+ const sin_x = 2.0 * (q [3 ] * q [0 ] - q [1 ] * q [2 ]);
418+ const cos_x = 1.0 - 2.0 * (q [0 ] * q [0 ] + q [1 ] * q [1 ]);
419+ const sin_y = std .math .clamp (2.0 * (q [3 ] * q [1 ] + q [2 ] * q [0 ]), @as (T , -1.0 ), @as (T , 1.0 ));
420+ const sin_z = 2.0 * (q [3 ] * q [2 ] - q [0 ] * q [1 ]);
421+ const cos_z = 1.0 - 2.0 * (q [1 ] * q [1 ] + q [2 ] * q [2 ]);
422+
423+ return .{
424+ std .math .atan2 (sin_x , cos_x ),
425+ std .math .asin (sin_y ),
426+ std .math .atan2 (sin_z , cos_z ),
427+ };
428+ }
429+
327430test from_to {
328431 try std .testing .expect (vector .is_close_default (from_to (@Vector (3 , f32 ){ 10 , 0 , 0 }, @Vector (3 , f32 ){ 20 , 0 , 0 }), identity (f32 )));
329432}
@@ -349,11 +452,122 @@ test lerp {
349452 try std .testing .expect (vector .is_close_default (lerp (v1 , v2 , 0.25 ), Quat4f32 { 2 , 3 , 4 , 5 }));
350453}
351454
352- //test to_eular_angles {
353- // var qx: Quat4f32 = from_eular_angles(from_rotation(x_axis(f32), std.math.degreesToRadians(-10)));
354- // var qy: Quat4f32 = from_eular_angles(from_rotation(y_axis(f32), std.math.degreesToRadians(-20)));
355- // var qz: Quat4f32 = from_eular_angles(from_rotation(z_axis(f32), std.math.degreesToRadians(-30)));
356- //}
455+ test normalize {
456+ // A degenerate quaternion falls back to identity rather than dividing through to NaN.
457+ try std .testing .expectEqual (identity (f32 ), normalize_default (Quat4f32 { 0 , 0 , 0 , 0 }));
458+ try std .testing .expect (std .math .isNan (vector .normalize (Quat4f32 { 0 , 0 , 0 , 0 })[0 ]));
459+
460+ // An already-unit quaternion is left alone.
461+ const unit = from_rotation (x_axis (f32 ), 0.7 );
462+ try std .testing .expect (vector .is_close (normalize_default (unit ), unit , 1e-12 ));
463+
464+ // A scaled one is brought back to unit length.
465+ const scaled = @as (Quat4f32 , @splat (3.0 )) * unit ;
466+ try std .testing .expect (vector .is_close (normalize_default (scaled ), unit , 1e-12 ));
467+ try std .testing .expect (vector .is_normalized_default (normalize_default (Quat4f32 { 1 , 2 , 3 , 4 })));
468+
469+ // The threshold is on the squared norm.
470+ const tiny = @as (Quat4f32 , @splat (1.0e-4 )) * unit ; // norm_sqr == 1.0e-8
471+ try std .testing .expectEqual (identity (f32 ), normalize_default (tiny ));
472+ try std .testing .expect (vector .is_close (normalize (tiny , 1.0e-12 ), unit , 1e-6 ));
473+ }
474+
475+ test nlerp {
476+ const a = from_rotation (x_axis (f32 ), 0.3 );
477+ const b = from_rotation (x_axis (f32 ), 1.1 );
478+
479+ // Endpoints are reproduced, and every sample stays on the unit sphere.
480+ try std .testing .expect (vector .is_close (nlerp (a , b , 0 ), a , 1e-6 ));
481+ try std .testing .expect (vector .is_close (nlerp (a , b , 1 ), b , 1e-6 ));
482+ for ([_ ]f32 { 0 , 0.25 , 0.5 , 0.75 , 1 }) | t | {
483+ try std .testing .expect (vector .is_normalized_default (nlerp (a , b , t )));
484+ }
485+
486+ // Along a single axis nlerp stays on the arc, so it agrees with slerp.
487+ try std .testing .expect (vector .is_close (nlerp (a , b , 0.5 ), slerp (a , b , 0.5 ), 1e-6 ));
488+
489+ // The hemisphere flip: q and -q denote the same orientation, so interpolating between
490+ // them must stay put. Plain `lerp` collapses to zero at the midpoint instead.
491+ try std .testing .expect (vector .is_close (nlerp (a , - a , 0.5 ), a , 1e-6 ));
492+ try std .testing .expect (vector .is_close_default (lerp (a , - a , 0.5 ), @as (Quat4f32 , @splat (0 ))));
493+ }
494+
495+ test from_eular_angles {
496+ // Pins the convention: intrinsic Z-Y-X, i.e. qz * qy * qx.
497+ const angles = @Vector (3 , f32 ){ 0.3 , -0.7 , 1.1 };
498+ const composed = mul (
499+ from_rotation (z_axis (f32 ), angles [2 ]),
500+ mul (from_rotation (y_axis (f32 ), angles [1 ]), from_rotation (x_axis (f32 ), angles [0 ])),
501+ );
502+ try std .testing .expect (vector .is_close (from_eular_angles (angles ), composed , 1e-6 ));
503+
504+ // Single-axis inputs reduce to the corresponding axis rotation.
505+ try std .testing .expect (vector .is_close (
506+ from_eular_angles (@Vector (3 , f32 ){ 0.4 , 0 , 0 }),
507+ from_rotation (x_axis (f32 ), 0.4 ),
508+ 1e-6 ,
509+ ));
510+ try std .testing .expect (vector .is_close (
511+ from_eular_angles (@Vector (3 , f32 ){ 0 , 0 , -0.9 }),
512+ from_rotation (z_axis (f32 ), -0.9 ),
513+ 1e-6 ,
514+ ));
515+ }
516+
517+ test to_eular_angles {
518+ // Round trips away from the gimbal-lock poles.
519+ const angles = @Vector (3 , f32 ){ 0.3 , -0.7 , 1.1 };
520+ try std .testing .expect (vector .is_close (to_eular_angles (from_eular_angles (angles )), angles , 1e-6 ));
521+ }
522+
523+ test from_euler_xyz_intrinsic {
524+ // Pins the mirrored convention: intrinsic X-Y-Z, i.e. qx * qy * qz.
525+ const angles = @Vector (3 , f32 ){ 0.3 , -0.7 , 1.1 };
526+ const composed = mul (
527+ from_rotation (x_axis (f32 ), angles [0 ]),
528+ mul (from_rotation (y_axis (f32 ), angles [1 ]), from_rotation (z_axis (f32 ), angles [2 ])),
529+ );
530+ try std .testing .expect (vector .is_close (from_euler_xyz_intrinsic (angles ), composed , 1e-6 ));
531+
532+ // The result is unit length without an explicit normalize.
533+ try std .testing .expect (vector .is_normalized_default (from_euler_xyz_intrinsic (angles )));
534+
535+ // Single-axis inputs agree with `from_eular_angles`; multi-axis ones must not.
536+ try std .testing .expect (vector .is_close (
537+ from_euler_xyz_intrinsic (@Vector (3 , f32 ){ 0 , 0.4 , 0 }),
538+ from_eular_angles (@Vector (3 , f32 ){ 0 , 0.4 , 0 }),
539+ 1e-6 ,
540+ ));
541+ try std .testing .expect (! vector .is_close (
542+ from_euler_xyz_intrinsic (angles ),
543+ from_eular_angles (angles ),
544+ 1e-6 ,
545+ ));
546+ }
547+
548+ test to_euler_xyz_intrinsic {
549+ // Round trips away from the gimbal-lock poles.
550+ const angles = @Vector (3 , f32 ){ 0.3 , -0.7 , 1.1 };
551+ try std .testing .expect (vector .is_close (
552+ to_euler_xyz_intrinsic (from_euler_xyz_intrinsic (angles )),
553+ angles ,
554+ 1e-6 ,
555+ ));
556+
557+ // Recovering from a composed rotation gives back the generating angles.
558+ for ([_ ]@Vector (3 , f32 ){
559+ .{ 0 , 0 , 0 },
560+ .{ 0.5 , 0 , 0 },
561+ .{ 0 , 0 , -1.2 },
562+ .{ -0.9 , 1.0 , 0.2 },
563+ }) | e | {
564+ try std .testing .expect (vector .is_close (
565+ to_euler_xyz_intrinsic (from_euler_xyz_intrinsic (e )),
566+ e ,
567+ 1e-6 ,
568+ ));
569+ }
570+ }
357571
358572test rotate_vector {
359573 const q = from_rotation (z_axis (f32 ), std .math .pi / 2.0 );
0 commit comments