Skip to content

[vector_math] Fix Quaternion.rotate() applying the inverse rotation - #41

Open
AdityaJagtap18 wants to merge 3 commits into
flutter:mainfrom
AdityaJagtap18:fix-quaternion-rotate-inverse-direction
Open

[vector_math] Fix Quaternion.rotate() applying the inverse rotation#41
AdityaJagtap18 wants to merge 3 commits into
flutter:mainfrom
AdityaJagtap18:fix-quaternion-rotate-inverse-direction

Conversation

@AdityaJagtap18

Copy link
Copy Markdown

Quaternion.rotate()/rotated() computed conjugate(this) * [v,0] * this
instead of the standard sandwich product this * [v,0] * conjugate(this),
so every quaternion rotation (in both the 32-bit vector_math.dart and
64-bit vector_math_64.dart variants) rotated vectors by the inverse of
the intended rotation.

Example: rotating (1, 0, 0) by 90° about +Z must give (0, 1, 0) by
the right-hand rule, and matches Matrix4.rotationZ/
Quaternion.asRotationMatrix(), but the old code returned (0, -1, 0).

Fixes flutter/flutter#192350.

What changed

  • lib/src/vector_math/quaternion.dart and
    lib/src/vector_math_64/quaternion.dart: corrected rotate() to use
    the standard this * [v,0] * conjugate(this) sandwich product.
  • test/fuzz_invariants_test.dart (new): a randomized invariant fuzzer
    covering normalize, normalizeInto consistency, componentwise
    min/max/mix, inverse round-trips for Matrix2/3/4 and Quaternion, cross
    product orthogonality, and — the check that caught this bug —
    Quaternion.rotated() cross-verified against
    Quaternion.asRotationMatrix() on the same vector.
  • test/quaternion_test.dart: corrected the 12 hardcoded expected
    vectors in testQuaternionNormalize(), which encoded the old (buggy)
    rotation direction; new values were independently derived from the
    standard rotation-matrix formulas.
  • Bumped vector_math to 2.4.3 with a CHANGELOG entry.

Verification

  • Derived the correct sandwich-product formula by hand and checked it
    against Matrix4.rotationZ/asRotationMatrix() ground truth for
    several axes/angles.
  • Ran the full vector_math test suite after the fix: all 261 tests
    pass (dart test).
  • dart format --output=none --set-exit-if-changed and dart analyze
    are clean on all changed files.

Transparency

I used Claude Code to help find this bug (via the fuzz test), derive
and verify the fix, and draft this PR. I reviewed the diff and the math
by hand before submitting, and I'm available to address review feedback.

Pre-Review Checklist

🤖 Generated with Claude Code

AdityaJagtap18 and others added 2 commits September 6, 2026 18:24
Quaternion.rotate() (and the rotated() wrapper) implemented
`conjugate(this) * [v,0] * this` instead of the standard sandwich
product `this * [v,0] * conjugate(this)`, so every quaternion rotation
in both the 32-bit (vector_math) and 64-bit (vector_math_64) variants
rotated vectors by the inverse of the intended rotation.

For example, a 90-degree rotation about +Z applied to (1,0,0) must
give (0,1,0) by the right-hand rule (and matches
Matrix4.rotationZ(pi/2) and Quaternion.asRotationMatrix()), but the
old code returned (0,-1,0).

Verified by:
- Deriving the correct sandwich-product formula by hand and comparing
  against Matrix4.rotationZ/asRotationMatrix() ground truth.
- Adding test/fuzz_invariants_test.dart, a randomized invariant
  fuzzer covering normalize, inverse round-trips for
  Matrix2/3/4 and Quaternion, and — the check that surfaced this bug —
  cross-verifying Quaternion.rotated() against
  Quaternion.asRotationMatrix() on the same vector.
- Correcting the 12 hardcoded expected vectors in
  quaternion_test.dart's testQuaternionNormalize(), which encoded the
  old (buggy) rotation direction; new values were independently
  derived from the standard rotation-matrix formulas.
- Running the full vector_math test suite (261 tests) after the fix:
  all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per this repo's version-and-CHANGELOG contribution requirement for
packages/vector_math following the Quaternion.rotate() inverse-rotation
fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added p: vector_math triage-framework Should be looked at in framework triage labels Sep 6, 2026
@google-cla

google-cla Bot commented Sep 6, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request fixes a bug in Quaternion.rotate() and rotated() where the inverse of the intended rotation was being applied in both the 32-bit and 64-bit implementations. It updates the corresponding unit tests, bumps the package version to 2.4.3, and introduces a new fuzz test suite to verify mathematical invariants. The review feedback suggests using the built-in normalized() method instead of cloning and normalizing vectors manually, and removing redundant vector cloning when calling Matrix3.transformed().

for (var i = 0; i < n; i++) {
final Vector2 v2 = randV2();
if (v2.length >= 1e-6) {
final Vector2 copy2 = v2.clone()..normalize();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In vector_math, Vector2, Vector3, and Vector4 all provide a built-in normalized() method that returns a normalized copy of the vector. Using v2.normalized() is more idiomatic and concise than v2.clone()..normalize().\n\nThe same applies to:\n- Line 57: v3.clone()..normalize() -> v3.normalized()\n- Line 62: v4.clone()..normalize() -> v4.normalized()\n- Line 72: v3.clone()..normalize() -> v3.normalized()\n- Line 82: v4.clone()..normalize() -> v4.normalized()

Suggested change
final Vector2 copy2 = v2.clone()..normalize();
final Vector2 copy2 = v2.normalized();

final Vector3 v = randV3();
final Vector3 viaQuat = q.rotated(v);
final Matrix3 m = q.asRotationMatrix();
final Vector3 viaMatrix = m.transformed(v.clone());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In vector_math, Matrix3.transformed(Vector3 arg, [Vector3? out]) does not modify the input vector arg. If the out parameter is omitted, it automatically creates a copy of arg internally before transforming it. Therefore, calling v.clone() here is redundant and results in an extra unnecessary allocation. You can pass v directly.

Suggested change
final Vector3 viaMatrix = m.transformed(v.clone());
final Vector3 viaMatrix = m.transformed(v);

- v.clone()..normalize() -> v.normalized(), matching this codebase's
  own idiom.
- Matrix3.transformed(v.clone()) -> Matrix3.transformed(v): the method
  already copies its argument internally when no `out` is given.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

p: vector_math triage-framework Should be looked at in framework triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[vector_math] Quaternion.rotate()/rotated() applies the inverse rotation

1 participant