Skip to content

Commit f9ad7b5

Browse files
committed
feat: add normalize safe variants for quat/vector
Signed-off-by: Michael Pollind <mpollind@gmail.com>
1 parent 1e71e54 commit f9ad7b5

2 files changed

Lines changed: 264 additions & 5 deletions

File tree

src/quat.zig

Lines changed: 219 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
220241
pub 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.
272318
pub 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.
301349
pub 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+
327430
test 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

358572
test rotate_vector {
359573
const q = from_rotation(z_axis(f32), std.math.pi / 2.0);

src/vector.zig

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,26 @@ pub fn normalize(vec: anytype) @TypeOf(vec) {
190190
);
191191
}
192192

193+
/// Like `normalize`, but returns `fallback` when the vector is too short to have a
194+
/// meaningful direction. `normalize` divides by the norm unconditionally, so a zero
195+
/// vector yields NaN; this is the guarded form for inputs that may be degenerate.
196+
///
197+
/// The threshold is on the *squared* norm, so no square root is taken on the reject path.
198+
/// Container kind is preserved: a `@Vector` in yields a `@Vector`, an array yields an array.
199+
pub fn normalize_safe(
200+
vec: anytype,
201+
fallback: @TypeOf(vec),
202+
min_norm_sqr: Float(@bitSizeOf(meta.Child(@TypeOf(vec)))),
203+
) @TypeOf(vec) {
204+
if (norm_sqr(vec) <= min_norm_sqr) return fallback;
205+
return normalize(vec);
206+
}
207+
208+
/// `normalize_safe` with a default threshold of 1.0e-6 on the squared norm.
209+
pub fn normalize_safe_default(vec: anytype, fallback: @TypeOf(vec)) @TypeOf(vec) {
210+
return normalize_safe(vec, fallback, 1.0e-6);
211+
}
212+
193213
/// dot product of two vectors.
194214
pub fn dot(vec: anytype, other: @TypeOf(vec)) meta.Child(@TypeOf(vec)) {
195215
if (@typeInfo(@TypeOf(vec)) == .array)
@@ -525,6 +545,31 @@ test normalize {
525545
try std.testing.expectEqual(@Vector(2, i32){ 0, 0 }, normalized_i32); // Integer division limitations
526546
}
527547

548+
test normalize_safe {
549+
const fallback = @Vector(3, f32){ 1, 0, 0 };
550+
551+
// Degenerate input takes the fallback instead of dividing through to NaN.
552+
try std.testing.expectEqual(fallback, normalize_safe_default(@Vector(3, f32){ 0, 0, 0 }, fallback));
553+
try std.testing.expect(std.math.isNan(normalize(@Vector(3, f32){ 0, 0, 0 })[0]));
554+
555+
// Non-degenerate input agrees with `normalize`.
556+
const v = @Vector(3, f32){ 3, 4, 0 };
557+
try std.testing.expectEqual(normalize(v), normalize_safe_default(v, fallback));
558+
559+
// The threshold is on the squared norm, so a vector shorter than it is rejected.
560+
const tiny = @Vector(3, f32){ 1.0e-4, 0, 0 }; // norm_sqr == 1.0e-8
561+
try std.testing.expectEqual(fallback, normalize_safe_default(tiny, fallback));
562+
try std.testing.expectEqual(normalize(tiny), normalize_safe(tiny, fallback, 1.0e-12));
563+
564+
// Container kind is preserved: an array in yields an array out.
565+
const array_fallback = [3]f32{ 1, 0, 0 };
566+
try std.testing.expectEqual(array_fallback, normalize_safe_default([3]f32{ 0, 0, 0 }, array_fallback));
567+
const normalized_array = normalize_safe_default([3]f32{ 3, 4, 0 }, array_fallback);
568+
try std.testing.expectEqual([3]f32, @TypeOf(normalized_array));
569+
try std.testing.expectApproxEqAbs(@as(f32, 0.6), normalized_array[0], 0.0001);
570+
try std.testing.expectApproxEqAbs(@as(f32, 0.8), normalized_array[1], 0.0001);
571+
}
572+
528573
test dot {
529574
const v1 = @Vector(3, f32){ 1, 2, 3 };
530575
const v2 = @Vector(3, f32){ 4, 5, 6 };

0 commit comments

Comments
 (0)