From 7a35f405b4d2a7ac916eee548017a5dab8b7b7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Tue, 5 Dec 2023 20:52:03 +0100 Subject: [PATCH] python: raise exceptions for Math API usage errors. Originally those were assertions that were kept even in release builds, which meant that calling math.angle() on non-normalized vectors aborted the whole Python interpreted. Not great. But then the assertions were made debug-only, which means invalid usage from Python (where the bindings are usually only built as Release) now silently gives back a wrong result, which is perhaps even worse. Because the Python overhead is already massive due to all string lookup and such, doing one more check in the implementations isn't really going to slow down anything. Thus I'm mirroring all (debug-only) Magnum assertions on the Python side, turning them into exceptions. With proper messages as well, because those are extremely useful. --- doc/python/magnum.math.rst | 140 ++++++++++++++++++++++++ src/python/magnum/math.cpp | 90 ++++++++++++---- src/python/magnum/math.matrix.h | 144 ++++++++++++++++++++----- src/python/magnum/math.vectorfloat.cpp | 13 ++- src/python/magnum/test/test_math.py | 142 +++++++++++++++++++++++- 5 files changed, 480 insertions(+), 49 deletions(-) diff --git a/doc/python/magnum.math.rst b/doc/python/magnum.math.rst index 7b72f7e..376789b 100644 --- a/doc/python/magnum.math.rst +++ b/doc/python/magnum.math.rst @@ -230,6 +230,146 @@ :py:`mat.translation` is a read-write property accessing the fourth column of the matrix. Similarly for the :ref:`Matrix3` class. +.. py:function:: magnum.Matrix2x2.inverted_orthogonal + :raise ValueError: If the matrix is not orthogonal +.. py:function:: magnum.Matrix2x2d.inverted_orthogonal + :raise ValueError: If the matrix is not orthogonal +.. py:function:: magnum.Matrix3x3.inverted_orthogonal + :raise ValueError: If the matrix is not orthogonal +.. py:function:: magnum.Matrix3x3d.inverted_orthogonal + :raise ValueError: If the matrix is not orthogonal +.. py:function:: magnum.Matrix4x4.inverted_orthogonal + :raise ValueError: If the matrix is not orthogonal +.. py:function:: magnum.Matrix4x4d.inverted_orthogonal + :raise ValueError: If the matrix is not orthogonal +.. py:function:: magnum.Matrix3.inverted_orthogonal + :raise ValueError: If the matrix is not orthogonal +.. py:function:: magnum.Matrix3d.inverted_orthogonal + :raise ValueError: If the matrix is not orthogonal +.. py:function:: magnum.Matrix4.inverted_orthogonal + :raise ValueError: If the matrix is not orthogonal +.. py:function:: magnum.Matrix4d.inverted_orthogonal + :raise ValueError: If the matrix is not orthogonal + +.. py:function:: magnum.Matrix3.reflection + :raise ValueError: If :p:`normal` is not normalized +.. py:function:: magnum.Matrix3d.reflection + :raise ValueError: If :p:`normal` is not normalized +.. py:function:: magnum.Matrix4.reflection + :raise ValueError: If :p:`normal` is not normalized +.. py:function:: magnum.Matrix4d.reflection + :raise ValueError: If :p:`normal` is not normalized +.. py:function:: magnum.Matrix3.rotation(self) + :raise ValueError: If the normalized rotation part is not orthogonal +.. py:function:: magnum.Matrix3d.rotation(self) + :raise ValueError: If the normalized rotation part is not orthogonal +.. py:function:: magnum.Matrix4.rotation(self) + :raise ValueError: If the normalized rotation part is not orthogonal +.. py:function:: magnum.Matrix4d.rotation(self) + :raise ValueError: If the normalized rotation part is not orthogonal +.. py:function:: magnum.Matrix3.rotation_normalized + :raise ValueError: If the rotation part is not orthogonal +.. py:function:: magnum.Matrix3d.rotation_normalized + :raise ValueError: If the rotation part is not orthogonal +.. py:function:: magnum.Matrix4.rotation_normalized + :raise ValueError: If the rotation part is not orthogonal +.. py:function:: magnum.Matrix4d.rotation_normalized + :raise ValueError: If the rotation part is not orthogonal +.. py:function:: magnum.Matrix3.uniform_scaling_squared + :raise ValueError: If the matrix doesn't have uniform scaling +.. py:function:: magnum.Matrix3d.uniform_scaling_squared + :raise ValueError: If the matrix doesn't have uniform scaling +.. py:function:: magnum.Matrix4.uniform_scaling_squared + :raise ValueError: If the matrix doesn't have uniform scaling +.. py:function:: magnum.Matrix4d.uniform_scaling_squared + :raise ValueError: If the matrix doesn't have uniform scaling +.. py:function:: magnum.Matrix3.uniform_scaling + :raise ValueError: If the matrix doesn't have uniform scaling +.. py:function:: magnum.Matrix3d.uniform_scaling + :raise ValueError: If the matrix doesn't have uniform scaling +.. py:function:: magnum.Matrix4.uniform_scaling + :raise ValueError: If the matrix doesn't have uniform scaling +.. py:function:: magnum.Matrix4d.uniform_scaling + :raise ValueError: If the matrix doesn't have uniform scaling +.. py:function:: magnum.Matrix3.inverted_rigid + :raise ValueError: If the matrix doesn't represent a rigid transformation +.. py:function:: magnum.Matrix3d.inverted_rigid + :raise ValueError: If the matrix doesn't represent a rigid transformation +.. py:function:: magnum.Matrix4.inverted_rigid + :raise ValueError: If the matrix doesn't represent a rigid transformation +.. py:function:: magnum.Matrix4d.inverted_rigid + :raise ValueError: If the matrix doesn't represent a rigid transformation + +.. py:function:: magnum.math.half_angle(normalized_a: magnum.Quaternion, normalized_b: magnum.Quaternion) + :raise ValueError: If either of the quaternions is not normalized +.. py:function:: magnum.math.half_angle(normalized_a: magnum.Quaterniond, normalized_b: magnum.Quaterniond) + :raise ValueError: If either of the quaternions is not normalized +.. py:function:: magnum.math.lerp(normalized_a: magnum.Quaternion, normalized_b: magnum.Quaternion, t: float) + :raise ValueError: If either of the quaternions is not normalized +.. py:function:: magnum.math.lerp(normalized_a: magnum.Quaterniond, normalized_b: magnum.Quaterniond, t: float) + :raise ValueError: If either of the quaternions is not normalized +.. py:function:: magnum.math.lerp_shortest_path(normalized_a: magnum.Quaternion, normalized_b: magnum.Quaternion, t: float) + :raise ValueError: If either of the quaternions is not normalized +.. py:function:: magnum.math.lerp_shortest_path(normalized_a: magnum.Quaterniond, normalized_b: magnum.Quaterniond, t: float) + :raise ValueError: If either of the quaternions is not normalized +.. py:function:: magnum.math.slerp(normalized_a: magnum.Quaternion, normalized_b: magnum.Quaternion, t: float) + :raise ValueError: If either of the quaternions is not normalized +.. py:function:: magnum.math.slerp(normalized_a: magnum.Quaterniond, normalized_b: magnum.Quaterniond, t: float) + :raise ValueError: If either of the quaternions is not normalized +.. py:function:: magnum.math.slerp_shortest_path(normalized_a: magnum.Quaternion, normalized_b: magnum.Quaternion, t: float) + :raise ValueError: If either of the quaternions is not normalized +.. py:function:: magnum.math.slerp_shortest_path(normalized_a: magnum.Quaterniond, normalized_b: magnum.Quaterniond, t: float) + :raise ValueError: If either of the quaternions is not normalized +.. py:function:: magnum.Quaternion.rotation(angle: magnum.Rad, normalized_axis: magnum.Vector3) + :raise ValueError: If :p:`normalized_axis` is not normalized +.. py:function:: magnum.Quaterniond.rotation(angle: magnum.Rad, normalized_axis: magnum.Vector3d) + :raise ValueError: If :p:`normalized_axis` is not normalized +.. py:function:: magnum.Quaternion.from_matrix + :raise ValueError: If :p:`matrix` is not a rotation +.. py:function:: magnum.Quaterniond.from_matrix + :raise ValueError: If :p:`matrix` is not a rotation +.. py:function:: magnum.Quaternion.angle + :raise ValueError: If the quaternion is not normalized +.. py:function:: magnum.Quaterniond.angle + :raise ValueError: If the quaternion is not normalized +.. py:function:: magnum.Quaternion.axis + :raise ValueError: If the quaternion is not normalized +.. py:function:: magnum.Quaterniond.axis + :raise ValueError: If the quaternion is not normalized +.. py:function:: magnum.Quaternion.inverted_normalized + :raise ValueError: If the quaternion is not normalized +.. py:function:: magnum.Quaterniond.inverted_normalized + :raise ValueError: If the quaternion is not normalized +.. py:function:: magnum.Quaternion.transform_vector_normalized + :raise ValueError: If the quaternion is not normalized +.. py:function:: magnum.Quaterniond.transform_vector_normalized + :raise ValueError: If the quaternion is not normalized + +.. py:function:: magnum.math.angle(normalized_a: magnum.Vector2, normalized_b: magnum.Vector2) + :raise ValueError: If either of the vectors is not normalized +.. py:function:: magnum.math.angle(normalized_a: magnum.Vector2d, normalized_b: magnum.Vector2d) + :raise ValueError: If either of the vectors is not normalized +.. py:function:: magnum.math.angle(normalized_a: magnum.Vector3, normalized_b: magnum.Vector3) + :raise ValueError: If either of the vectors is not normalized +.. py:function:: magnum.math.angle(normalized_a: magnum.Vector3d, normalized_b: magnum.Vector3d) + :raise ValueError: If either of the vectors is not normalized +.. py:function:: magnum.math.angle(normalized_a: magnum.Vector4, normalized_b: magnum.Vector4) + :raise ValueError: If either of the vectors is not normalized +.. py:function:: magnum.math.angle(normalized_a: magnum.Vector4d, normalized_b: magnum.Vector4d) + :raise ValueError: If either of the vectors is not normalized +.. py:function:: magnum.Vector2.projected_onto_normalized + :raise ValueError: If :p:`line` is not normalized +.. py:function:: magnum.Vector2d.projected_onto_normalized + :raise ValueError: If :p:`line` is not normalized +.. py:function:: magnum.Vector3.projected_onto_normalized + :raise ValueError: If :p:`line` is not normalized +.. py:function:: magnum.Vector3d.projected_onto_normalized + :raise ValueError: If :p:`line` is not normalized +.. py:function:: magnum.Vector4.projected_onto_normalized + :raise ValueError: If :p:`line` is not normalized +.. py:function:: magnum.Vector4d.projected_onto_normalized + :raise ValueError: If :p:`line` is not normalized + .. For pickling, because py::pickle() doesn't seem to have a way to set the __setstate__ / __getstate__ docs directly FFS diff --git a/src/python/magnum/math.cpp b/src/python/magnum/math.cpp index 94d9432..36f4a46 100644 --- a/src/python/magnum/math.cpp +++ b/src/python/magnum/math.cpp @@ -302,27 +302,60 @@ template void quaternion(py::module_& m, py::class_& c) { .def("dot", static_cast(&Math::dot), "Dot product between two quaternions") .def("half_angle", [](const T& normalizedA, const T& normalizedB) { + if(!normalizedA.isNormalized() || !normalizedB.isNormalized()) { + PyErr_Format(PyExc_ValueError, "quaternions %S and %S are not normalized", py::cast(normalizedA).ptr(), py::cast(normalizedB).ptr()); + throw py::error_already_set{}; + } /** @todo switch back to angle() once it's reintroduced with the correct output again */ return Radd(Math::halfAngle(normalizedA, normalizedB)); }, "Angle between normalized quaternions", py::arg("normalized_a"), py::arg("normalized_b")) - .def("lerp", static_cast(&Math::lerp), - "Linear interpolation of two quaternions", py::arg("normalized_a"), py::arg("normalized_b"), py::arg("t")) - .def("lerp_shortest_path", static_cast(&Math::lerpShortestPath), - "Linear shortest-path interpolation of two quaternions", py::arg("normalized_a"), py::arg("normalized_b"), py::arg("t")) - .def("slerp", static_cast(&Math::slerp), - "Spherical linear interpolation of two quaternions", py::arg("normalized_a"), py::arg("normalized_b"), py::arg("t")) - .def("slerp_shortest_path", static_cast(&Math::slerpShortestPath), - "Spherical linear shortest-path interpolation of two quaternions", py::arg("normalized_a"), py::arg("normalized_b"), py::arg("t")) - ; + .def("lerp", [](const T& normalizedA, const T& normalizedB, typename T::Type t) { + if(!normalizedA.isNormalized() || !normalizedB.isNormalized()) { + PyErr_Format(PyExc_ValueError, "quaternions %S and %S are not normalized", py::cast(normalizedA).ptr(), py::cast(normalizedB).ptr()); + throw py::error_already_set{}; + } + return Math::lerp(normalizedA, normalizedB, t); + }, "Linear interpolation of two quaternions", py::arg("normalized_a"), py::arg("normalized_b"), py::arg("t")) + .def("lerp_shortest_path", [](const T& normalizedA, const T& normalizedB, typename T::Type t) { + if(!normalizedA.isNormalized() || !normalizedB.isNormalized()) { + PyErr_Format(PyExc_ValueError, "quaternions %S and %S are not normalized", py::cast(normalizedA).ptr(), py::cast(normalizedB).ptr()); + throw py::error_already_set{}; + } + return Math::lerpShortestPath(normalizedA, normalizedB, t); + }, "Linear shortest-path interpolation of two quaternions", py::arg("normalized_a"), py::arg("normalized_b"), py::arg("t")) + .def("slerp", [](const T& normalizedA, const T& normalizedB, typename T::Type t) { + if(!normalizedA.isNormalized() || !normalizedB.isNormalized()) { + PyErr_Format(PyExc_ValueError, "quaternions %S and %S are not normalized", py::cast(normalizedA).ptr(), py::cast(normalizedB).ptr()); + throw py::error_already_set{}; + } + return Math::slerp(normalizedA, normalizedB, t); + }, "Spherical linear interpolation of two quaternions", py::arg("normalized_a"), py::arg("normalized_b"), py::arg("t")) + .def("slerp_shortest_path", [](const T& normalizedA, const T& normalizedB, typename T::Type t) { + if(!normalizedA.isNormalized() || !normalizedB.isNormalized()) { + PyErr_Format(PyExc_ValueError, "quaternions %S and %S are not normalized", py::cast(normalizedA).ptr(), py::cast(normalizedB).ptr()); + throw py::error_already_set{}; + } + return Math::slerpShortestPath(normalizedA, normalizedB, t); + }, "Spherical linear shortest-path interpolation of two quaternions", py::arg("normalized_a"), py::arg("normalized_b"), py::arg("t")); c /* Constructors */ - .def_static("rotation", [](Radd angle, const Math::Vector3& axis) { - return T::rotation(Math::Rad(angle), axis); + .def_static("rotation", [](Radd angle, const Math::Vector3& normalizedAxis) { + if(!normalizedAxis.isNormalized()) { + PyErr_Format(PyExc_ValueError, "axis %S is not normalized", py::cast(normalizedAxis).ptr()); + throw py::error_already_set{}; + } + return T::rotation(Math::Rad(angle), normalizedAxis); }, "Rotation quaternion", py::arg("angle"), py::arg("normalized_axis")) - .def_static("from_matrix", &T::fromMatrix, - "Create a quaternion from rotation matrix", py::arg("matrix")) + .def_static("from_matrix", [](const Math::Matrix3x3& matrix) { + /* Same as the check in fromMatrix() */ + if(std::abs(matrix.determinant() - typename T::Type(1)) >= typename T::Type(3)*Math::TypeTraits::epsilon()) { + PyErr_Format(PyExc_ValueError, "the matrix is not a rotation:\n%S", py::cast(matrix).ptr()); + throw py::error_already_set{}; + } + return T::fromMatrix(matrix); + }, "Create a quaternion from rotation matrix", py::arg("matrix")) .def_static("zero_init", []() { return T{Math::ZeroInit}; }, "Construct a zero-initialized quaternion") @@ -385,10 +418,19 @@ template void quaternion(py::module_& m, py::class_& c) { .def("is_normalized", &T::isNormalized, "Whether the quaternion is normalized") .def("angle", [](const T& self) { + if(!self.isNormalized()) { + PyErr_Format(PyExc_ValueError, "%S is not normalized", py::cast(self).ptr()); + throw py::error_already_set{}; + } return Radd(self.angle()); }, "Rotation angle of a unit quaternion") - .def("axis", &T::axis, - "Rotation axis of a unit quaternion") + .def("axis", [](const T& self) { + if(!self.isNormalized()) { + PyErr_Format(PyExc_ValueError, "%S is not normalized", py::cast(self).ptr()); + throw py::error_already_set{}; + } + return self.axis(); + }, "Rotation axis of a unit quaternion") .def("to_matrix", &T::toMatrix, "Convert to a rotation matrix") .def("dot", &T::dot, @@ -401,12 +443,22 @@ template void quaternion(py::module_& m, py::class_& c) { "Conjugated quaternion") .def("inverted", &T::inverted, "Inverted quaternion") - .def("inverted_normalized", &T::invertedNormalized, - "Inverted normalized quaternion") + .def("inverted_normalized", [](const T& self) { + if(!self.isNormalized()) { + PyErr_Format(PyExc_ValueError, "%S is not normalized", py::cast(self).ptr()); + throw py::error_already_set{}; + } + return self.invertedNormalized(); + }, "Inverted normalized quaternion") .def("transform_vector", &T::transformVector, "Rotate a vector with a quaternion", py::arg("vector")) - .def("transform_vector_normalized", &T::transformVectorNormalized, - "Rotate a vector with a normalized quaternion", py::arg("vector")) + .def("transform_vector_normalized", [](const T& self, const Math::Vector3& vector) { + if(!self.isNormalized()) { + PyErr_Format(PyExc_ValueError, "%S is not normalized", py::cast(self).ptr()); + throw py::error_already_set{}; + } + return self.transformVectorNormalized(vector); + }, "Rotate a vector with a normalized quaternion", py::arg("vector")) /* Properties */ .def_property("vector", diff --git a/src/python/magnum/math.matrix.h b/src/python/magnum/math.matrix.h index f97cd8a..9ecb3ca 100644 --- a/src/python/magnum/math.matrix.h +++ b/src/python/magnum/math.matrix.h @@ -266,7 +266,13 @@ template void everyMatrix(py::class_& c) { return self.adjugate(); }, "Adjugate matrix") .def("inverted", &T::inverted, "Inverted matrix") - .def("inverted_orthogonal", &T::invertedOrthogonal, "Inverted orthogonal matrix") + .def("inverted_orthogonal", [](const T& self) { + if(!self.isOrthogonal()) { + PyErr_Format(PyExc_ValueError, "the matrix is not orthogonal:\n%S", py::cast(self).ptr()); + throw py::error_already_set{}; + } + return self.invertedOrthogonal(); + }, "Inverted orthogonal matrix") .def("__matmul__", [](const T& self, const T& other) -> T { return self*other; }, "Multiply a matrix") @@ -570,8 +576,13 @@ template void matrices( matrix3 /* Constructors. The translation() / scaling() / rotation() are handled below as they conflict with member functions. */ - .def_static("reflection", &Math::Matrix3::reflection, - "2D reflection matrix", py::arg("normal")) + .def_static("reflection", [](const Math::Vector2& normal) { + if(!normal.isNormalized()) { + PyErr_Format(PyExc_ValueError, "normal %S is not normalized", py::cast(normal).ptr()); + throw py::error_already_set{}; + } + return Math::Matrix3::reflection(normal); + }, "2D reflection matrix", py::arg("normal")) .def_static("shearing_x", &Math::Matrix3::shearingX, "2D shearing matrix along the X axis", py::arg("amount")) .def_static("shearing_y", &Math::Matrix3::shearingY, @@ -605,16 +616,43 @@ template void matrices( "2D rotation and scaling part of the matrix") .def("rotation_shear", &Math::Matrix3::rotationShear, "2D rotation and shear part of the matrix") - .def("rotation_normalized", &Math::Matrix3::rotationNormalized, - "2D rotation part of the matrix assuming there is no scaling") + .def("rotation_normalized", [](const Math::Matrix3& self) { + /* Same as implementation of rotationNormalized() */ + const Math::Matrix2x2 rotationScaling = self.rotationScaling(); + if(!rotationScaling.isOrthogonal()) { + PyErr_Format(PyExc_ValueError, "the rotation part is not orthogonal:\n%S", py::cast(rotationScaling).ptr()); + throw py::error_already_set{}; + } + return rotationScaling; + }, "2D rotation part of the matrix assuming there is no scaling") .def("scaling_squared", &Math::Matrix3::scalingSquared, "Non-uniform scaling part of the matrix, squared") - .def("uniform_scaling_squared", &Math::Matrix3::uniformScalingSquared, - "Uniform scaling part of the matrix, squared") - .def("uniform_scaling", &Math::Matrix3::uniformScaling, - "Uniform scaling part of the matrix") - .def("inverted_rigid", &Math::Matrix3::invertedRigid, - "Inverted rigid transformation matrix") + .def("uniform_scaling_squared", [](const Math::Matrix3& self) { + /* Same as implementation of uniformScalingSquared() */ + const T scalingSquared = self[0].xy().dot(); + if(!Math::TypeTraits::equals(self[1].xy().dot(), scalingSquared)) { + PyErr_Format(PyExc_ValueError, "the matrix doesn't have uniform scaling:\n%S", py::cast(self.rotationScaling()).ptr()); + throw py::error_already_set{}; + } + return scalingSquared; + }, "Uniform scaling part of the matrix, squared") + .def("uniform_scaling", [](const Math::Matrix3& self) { + /* Same as implementation of uniformScalingSquared(), which + uniformScaling() delegates to */ + const T scalingSquared = self[0].xy().dot(); + if(!Math::TypeTraits::equals(self[1].xy().dot(), scalingSquared)) { + PyErr_Format(PyExc_ValueError, "the matrix doesn't have uniform scaling:\n%S", py::cast(self.rotationScaling()).ptr()); + throw py::error_already_set{}; + }; + return std::sqrt(scalingSquared); + }, "Uniform scaling part of the matrix") + .def("inverted_rigid", [](const Math::Matrix3& self) { + if(!self.isRigidTransformation()) { + PyErr_Format(PyExc_ValueError, "the matrix doesn't represent a rigid transformation:\n%S", py::cast(self).ptr()); + throw py::error_already_set{}; + } + return self.invertedRigid(); + }, "Inverted rigid transformation matrix") .def("transform_vector", &Math::Matrix3::transformVector, "Transform a 2D vector with the matrix", py::arg("vector")) .def("transform_point", &Math::Matrix3::transformPoint, @@ -719,7 +757,15 @@ Overloaded function. .def_static("_srotation", [](Radd angle) { return Math::Matrix3::rotation(Math::Rad(angle)); }) - .def("_irotation", static_cast(Math::Matrix3::*)() const>(&Math::Matrix3::rotation)) + .def("_irotation", [](const Math::Matrix3& self) { + /* Same as implementation of rotation() */ + const Math::Matrix2x2 rotationShear = self.rotationShear(); + if(!rotationShear.isOrthogonal()) { + PyErr_Format(PyExc_ValueError, "the normalized rotation part is not orthogonal:\n%S", py::cast(rotationShear).ptr()); + throw py::error_already_set{}; + } + return rotationShear; + }) .def("rotation", [matrix3](const py::args& args, const py::kwargs& kwargs) { if(py::len(args) && py::isinstance>(args[0])) { return matrix3.attr("_irotation")(*args, **kwargs); @@ -759,8 +805,13 @@ Overloaded function. .def_static("rotation_z", [](Radd angle) { return Math::Matrix4::rotationZ(Math::Rad(angle)); }, "3D rotation matrix around the Z axis", py::arg("angle")) - .def_static("reflection", &Math::Matrix4::reflection, - "3D reflection matrix", py::arg("normal")) + .def_static("reflection", [](const Math::Vector3& normal) { + if(!normal.isNormalized()) { + PyErr_Format(PyExc_ValueError, "normal %S is not normalized", py::cast(normal).ptr()); + throw py::error_already_set{}; + } + return Math::Matrix4::reflection(normal); + }, "3D reflection matrix", py::arg("normal")) .def_static("shearing_xy", &Math::Matrix4::shearingXY, "3D shearing matrix along the XY plane", py::arg("amount_x"), py::arg("amount_y")) .def_static("shearing_xz", &Math::Matrix4::shearingXZ, @@ -809,18 +860,49 @@ Overloaded function. "3D rotation and scaling part of the matrix") .def("rotation_shear", &Math::Matrix4::rotationShear, "3D rotation and shear part of the matrix") - .def("rotation_normalized", &Math::Matrix4::rotationNormalized, - "3D rotation part of the matrix assuming there is no scaling") + .def("rotation_normalized", [](const Math::Matrix4& self) { + /* Same as implementation of rotationNormalized() */ + const Math::Matrix3x3 rotationScaling = self.rotationScaling(); + if(!rotationScaling.isOrthogonal()) { + PyErr_Format(PyExc_ValueError, "the rotation part is not orthogonal:\n%S", py::cast(rotationScaling).ptr()); + throw py::error_already_set{}; + } + return rotationScaling; + }, "3D rotation part of the matrix assuming there is no scaling") .def("scaling_squared", &Math::Matrix4::scalingSquared, "Non-uniform scaling part of the matrix, squared") - .def("uniform_scaling_squared", &Math::Matrix4::uniformScalingSquared, - "Uniform scaling part of the matrix, squared") - .def("uniform_scaling", &Math::Matrix4::uniformScaling, - "Uniform scaling part of the matrix") + .def("uniform_scaling_squared", [](const Math::Matrix4& self) { + /* Same as implementation of uniformScalingSquared() */ + const T scalingSquared = self[0].xyz().dot(); + if(!Math::TypeTraits::equals(self[1].xyz().dot(), scalingSquared) || + !Math::TypeTraits::equals(self[2].xyz().dot(), scalingSquared) + ) { + PyErr_Format(PyExc_ValueError, "the matrix doesn't have uniform scaling:\n%S", py::cast(self.rotationScaling()).ptr()); + throw py::error_already_set{}; + } + return scalingSquared; + }, "Uniform scaling part of the matrix, squared") + .def("uniform_scaling", [](const Math::Matrix4& self) { + /* Same as implementation of uniformScalingSquared(), which + uniformScaling() delegates to */ + const T scalingSquared = self[0].xyz().dot(); + if(!Math::TypeTraits::equals(self[1].xyz().dot(), scalingSquared) || + !Math::TypeTraits::equals(self[2].xyz().dot(), scalingSquared) + ) { + PyErr_Format(PyExc_ValueError, "the matrix doesn't have uniform scaling:\n%S", py::cast(self.rotationScaling()).ptr()); + throw py::error_already_set{}; + } + return std::sqrt(scalingSquared); + }, "Uniform scaling part of the matrix") .def("normal_matrix", &Math::Matrix4::normalMatrix, "Normal matrix") - .def("inverted_rigid", &Math::Matrix4::invertedRigid, - "Inverted rigid transformation matrix") + .def("inverted_rigid", [](const Math::Matrix4& self) { + if(!self.isRigidTransformation()) { + PyErr_Format(PyExc_ValueError, "the matrix doesn't represent a rigid transformation:\n%S", py::cast(self).ptr()); + throw py::error_already_set{}; + } + return self.invertedRigid(); + }, "Inverted rigid transformation matrix") .def("transform_vector", &Math::Matrix4::transformVector, "Transform a 3D vector with the matrix", py::arg("vector")) .def("transform_point", &Math::Matrix4::transformPoint, @@ -928,10 +1010,22 @@ Overloaded function. /* Static/member rotation(). Pybind doesn't support that natively, so we create a rotation(*args, **kwargs) and dispatch ourselves. */ - .def_static("_srotation", [](Radd angle, const Math::Vector3& axis) { - return Math::Matrix4::rotation(Math::Rad(angle), axis); + .def_static("_srotation", [](Radd angle, const Math::Vector3& normalizedAxis) { + if(!normalizedAxis.isNormalized()) { + PyErr_Format(PyExc_ValueError, "axis %S is not normalized", py::cast(normalizedAxis).ptr()); + throw py::error_already_set{}; + } + return Math::Matrix4::rotation(Math::Rad(angle), normalizedAxis); + }) + .def("_irotation", [](const Math::Matrix4& self) { + /* Same as implementation of rotation() */ + const Math::Matrix3x3 rotationShear = self.rotationShear(); + if(!rotationShear.isOrthogonal()) { + PyErr_Format(PyExc_ValueError, "the normalized rotation part is not orthogonal:\n%S", py::cast(rotationShear).ptr()); + throw py::error_already_set{}; + } + return rotationShear; }) - .def("_irotation", static_cast(Math::Matrix4::*)() const>(&Math::Matrix4::rotation)) .def("rotation", [matrix4](const py::args& args, const py::kwargs& kwargs) { if(py::len(args) && py::isinstance>(args[0])) { return matrix4.attr("_irotation")(*args, **kwargs); diff --git a/src/python/magnum/math.vectorfloat.cpp b/src/python/magnum/math.vectorfloat.cpp index 2712ce2..972c7cc 100644 --- a/src/python/magnum/math.vectorfloat.cpp +++ b/src/python/magnum/math.vectorfloat.cpp @@ -57,8 +57,13 @@ template void vectorFloat(py::module_& m, py::class_& c) { return T{Math::fma(a, b, c)}; }, "Fused multiply-add") - .def("angle", [](const T& a, const T& b) { return Radd(Math::angle(a, b)); }, - "Angle between normalized vectors", py::arg("normalized_a"), py::arg("normalized_b")); + .def("angle", [](const T& normalizedA, const T& normalizedB) { + if(!normalizedA.isNormalized() || !normalizedB.isNormalized()) { + PyErr_Format(PyExc_ValueError, "vectors %S and %S are not normalized", py::cast(normalizedA).ptr(), py::cast(normalizedB).ptr()); + throw py::error_already_set{}; + } + return Radd(Math::angle(normalizedA, normalizedB)); + }, "Angle between normalized vectors", py::arg("normalized_a"), py::arg("normalized_b")); c .def("is_normalized", &T::isNormalized, "Whether the vector is normalized") @@ -74,6 +79,10 @@ template void vectorFloat(py::module_& m, py::class_& c) { return self.projected(line); }, "Vector projected onto a line", py::arg("line")) .def("projected_onto_normalized", [](const T& self, const T& line) { + if(!line.isNormalized()) { + PyErr_Format(PyExc_ValueError, "line %S is not normalized", py::cast(line).ptr()); + throw py::error_already_set{}; + } return self.projectedOntoNormalized(line); }, "Vector projected onto a normalized line", py::arg("line")); } diff --git a/src/python/magnum/test/test_math.py b/src/python/magnum/test/test_math.py index 75a9075..d9e9072 100644 --- a/src/python/magnum/test/test_math.py +++ b/src/python/magnum/test/test_math.py @@ -402,15 +402,27 @@ def test_properties(self): self.assertEqual(a.minmax(), (-13.5, 3.5)) def test_ops(self): + a = Vector2(0.707107, 0.707107) + b = Vector2(1.0, 0.0) + self.assertEqual(math.dot(Vector2(0.5, 3.0), Vector2(2.0, 0.5)), 2.5) - self.assertEqual(Deg(math.angle( - Vector2(0.5, 3.0).normalized(), - Vector2(2.0, 0.5).normalized())), Deg(66.5014333443446)) + self.assertEqual(Deg(math.angle(a, b)), Deg(44.9999807616716)) self.assertEqual(Vector3(1.0, 2.0, 0.3).projected(Vector3.y_axis()), Vector3.y_axis(2.0)) self.assertEqual(Vector3(1.0, 2.0, 0.3).projected_onto_normalized(Vector3.y_axis()), Vector3.y_axis(2.0)) + def test_ops_invalid(self): + a = Vector2(0.707107, 0.707107) + b = Vector2(1.0, 0.0) + + with self.assertRaisesRegex(ValueError, "vectors Vector\\(1.41421, 1.41421\\) and Vector\\(1, 0\\) are not normalized"): + math.angle(a*2.0, b) + with self.assertRaisesRegex(ValueError, "vectors Vector\\(0.707107, 0.707107\\) and Vector\\(2, 0\\) are not normalized"): + math.angle(a, b*2.0) + with self.assertRaisesRegex(ValueError, "line Vector\\(2, 0.5\\) is not normalized"): + Vector2(0.5, 3.0).projected_onto_normalized(Vector2(2.0, 0.5)) + def test_ops_number_on_the_left(self): self.assertEqual(2.0*Vector2(1.0, -3.0), Vector2(2.0, -6.0)) self.assertEqual(6.0/Vector2(2.0, -3.0), Vector2(3.0, -2.0)) @@ -938,6 +950,13 @@ def test_methods(self): (0.0, -1.0), (1.0, 0.0))) + def test_methods_invalid(self): + with self.assertRaisesRegex(ValueError, """the matrix is not orthogonal: +Matrix\\(4, 0, + 0, 2\\)"""): + Matrix2x2((4.0, 0.0), + (0.0, 2.0)).inverted_orthogonal() + def test_repr(self): a = Matrix2x3((1.0, 2.0, 3.0), (4.0, 5.0, 6.0)) @@ -1039,6 +1058,10 @@ def test_static_methods(self): Vector3(4.0, 5.0, 0.0), Vector3(7.0, 8.0, 1.0))) + def test_static_methods_invalid(self): + with self.assertRaisesRegex(ValueError, "normal Vector\\(2, 0\\) is not normalized"): + Matrix3.reflection(Vector2(2.0, 0.0)) + def test_pickle(self): data = pickle.dumps(Matrix3((1.0, 2.0, 3.0), (4.0, 5.0, 6.0), @@ -1091,6 +1114,29 @@ def test_methods(self): self.assertEqual(b.inverted(), Matrix3.scaling(Vector2(1/3.0))) self.assertEqual(a.inverted_orthogonal(), Matrix3.rotation(Deg(-45.0))) + def test_methods_invalid(self): + with self.assertRaisesRegex(ValueError, """the rotation part is not orthogonal: +Matrix\\(3, 0, + 0, 3\\)"""): + Matrix3.scaling(Vector2(3.0)).rotation_normalized() + with self.assertRaisesRegex(ValueError, """the matrix doesn't have uniform scaling: +Matrix\\(3, 0, + 0, 2\\)"""): + Matrix3.scaling((3.0, 2.0)).uniform_scaling_squared() + with self.assertRaisesRegex(ValueError, """the matrix doesn't have uniform scaling: +Matrix\\(3, 0, + 0, 2\\)"""): + Matrix3.scaling((3.0, 2.0)).uniform_scaling() + with self.assertRaisesRegex(ValueError, """the matrix doesn't represent a rigid transformation: +Matrix\\(3, 0, 0, + 0, 3, 0, + 0, 0, 1\\)"""): + Matrix3.scaling(Vector2(3.0)).inverted_rigid() + with self.assertRaisesRegex(ValueError, """the normalized rotation part is not orthogonal: +Matrix\\(1, 0.894427, + 0, 0.447214\\)"""): + Matrix3.shearing_x(2.0).rotation() + def test_methods_return_type(self): self.assertIsInstance(Matrix3.zero_init(), Matrix3) self.assertIsInstance(Matrix3.from_diagonal((3.0, 1.0, 1.0)), Matrix3) @@ -1244,6 +1290,10 @@ def test_static_methods(self): Vector4(9.0, 10.0, 11.0, 0.0), Vector4(13.0, 14.0, 15.0, 1.0))) + def test_static_methods_invalid(self): + with self.assertRaisesRegex(ValueError, "normal Vector\\(2, 0, 0\\) is not normalized"): + Matrix4.reflection(Vector3(2.0, 0.0, 0.0)) + def test_pickle(self): data = pickle.dumps(Matrix4((1.0, 2.0, 3.0, 4.0), (5.0, 6.0, 7.0, 8.0), @@ -1316,6 +1366,44 @@ def test_methods_return_type(self): self.assertIsInstance(Matrix4().transposed(), Matrix4) self.assertIsInstance(Matrix4().inverted(), Matrix4) + def test_methods_invalid(self): + with self.assertRaisesRegex(ValueError, """the rotation part is not orthogonal: +Matrix\\(3, 0, 0, + 0, 3, 0, + 0, 0, 3\\)"""): + Matrix4.scaling(Vector3(3.0)).rotation_normalized() + with self.assertRaisesRegex(ValueError, """the matrix doesn't have uniform scaling: +Matrix\\(3, 0, 0, + 0, 2, 0, + 0, 0, 3\\)"""): + Matrix4.scaling((3.0, 2.0, 3.0)).uniform_scaling_squared() + with self.assertRaisesRegex(ValueError, """the matrix doesn't have uniform scaling: +Matrix\\(3, 0, 0, + 0, 3, 0, + 0, 0, 2\\)"""): + Matrix4.scaling((3.0, 3.0, 2.0)).uniform_scaling_squared() + with self.assertRaisesRegex(ValueError, """the matrix doesn't have uniform scaling: +Matrix\\(3, 0, 0, + 0, 2, 0, + 0, 0, 3\\)"""): + Matrix4.scaling((3.0, 2.0, 3.0)).uniform_scaling() + with self.assertRaisesRegex(ValueError, """the matrix doesn't have uniform scaling: +Matrix\\(3, 0, 0, + 0, 3, 0, + 0, 0, 2\\)"""): + Matrix4.scaling((3.0, 3.0, 2.0)).uniform_scaling() + with self.assertRaisesRegex(ValueError, """the matrix doesn't represent a rigid transformation: +Matrix\\(3, 0, 0, 0, + 0, 3, 0, 0, + 0, 0, 3, 0, + 0, 0, 0, 1\\)"""): + Matrix4.scaling(Vector3(3.0)).inverted_rigid() + with self.assertRaisesRegex(ValueError, """the normalized rotation part is not orthogonal: +Matrix\\(1, 0.816497, 0, + 0, 0.408248, 0, + 0, 0.408248, 1\\)"""): + Matrix4.shearing_xz(2.0, 1.0).rotation() + # conversion from buffer is tested in test_math_numpy, array.array is # one-dimensional and I don't want to drag numpy here just for one test @@ -1366,6 +1454,15 @@ def test_static_methods(self): b = Quaternion.from_matrix(Matrix4.rotation_x(Deg(45.0)).rotation_scaling()) self.assertEqual(a, Quaternion((0.382683, 0.0, 0.0), 0.92388)) + def test_static_methods_invalid(self): + with self.assertRaisesRegex(ValueError, "axis Vector\\(2, 0, 1\\) is not normalized"): + Quaternion.rotation(Deg(35.0), Vector3(2.0, 0.0, 1.0)) + with self.assertRaisesRegex(ValueError, """the matrix is not a rotation: +Matrix\\(2, 0, 0, + 0, 2, 0, + 0, 0, 2\\)"""): + Quaternion.from_matrix(Matrix4.scaling(Vector3(2.0)).rotation_scaling()) + def test_pickle(self): data = pickle.dumps(Quaternion((1.0, 2.0, 3.0), 4.0)) self.assertEqual(pickle.loads(data), Quaternion((1.0, 2.0, 3.0), 4.0)) @@ -1386,6 +1483,18 @@ def test_methods(self): self.assertEqual(a.transform_vector(Vector3.y_axis()), Vector3(0.0, 0.707107, 0.707107)) self.assertEqual(a.transform_vector_normalized(Vector3.y_axis()), Vector3(0.0, 0.707107, 0.707107)) + def test_methods_invalid(self): + a = Quaternion.rotation(Deg(45.0), Vector3.x_axis())*3.0 + + with self.assertRaisesRegex(ValueError, "Quaternion\\({1.14805, 0, 0}, 2.77164\\) is not normalized"): + a.angle() + with self.assertRaisesRegex(ValueError, "Quaternion\\({1.14805, 0, 0}, 2.77164\\) is not normalized"): + a.axis() + with self.assertRaisesRegex(ValueError, "Quaternion\\({1.14805, 0, 0}, 2.77164\\) is not normalized"): + a.inverted_normalized() + with self.assertRaisesRegex(ValueError, "Quaternion\\({1.14805, 0, 0}, 2.77164\\) is not normalized"): + a.transform_vector_normalized(Vector3()) + def test_functions(self): a = Quaternion.rotation(Deg(45.0), Vector3d.x_axis()) b = Quaternion.rotation(Deg(-145.0), Vector3d.x_axis()) @@ -1396,6 +1505,33 @@ def test_functions(self): self.assertEqual(math.slerp(a, b, 0.25), Quaternion((-0.0218149, 0.0, 0.0), 0.99976)) self.assertEqual(math.slerp_shortest_path(a, b, 0.25), Quaternion((-0.691513, 0.0, 0.0), -0.722364)) + def test_functions_invalid(self): + a = Quaternion.rotation(Deg(45.0), Vector3d.x_axis()) + b = Quaternion.rotation(Deg(-145.0), Vector3d.x_axis()) + a_invalid = a*3.0 + b_invalid = b*0.5 + + with self.assertRaisesRegex(ValueError, "quaternions Quaternion\\({1.14805, 0, 0}, 2.77164\\) and Quaternion\\({-0.953717, -0, -0}, 0.300706\\) are not normalized"): + math.half_angle(a_invalid, b) + with self.assertRaisesRegex(ValueError, "quaternions Quaternion\\({0.382683, 0, 0}, 0.92388\\) and Quaternion\\({-0.476858, -0, -0}, 0.150353\\) are not normalized"): + math.half_angle(a, b_invalid) + with self.assertRaisesRegex(ValueError, "quaternions Quaternion\\({1.14805, 0, 0}, 2.77164\\) and Quaternion\\({-0.953717, -0, -0}, 0.300706\\) are not normalized"): + math.lerp(a_invalid, b, 0.25) + with self.assertRaisesRegex(ValueError, "quaternions Quaternion\\({0.382683, 0, 0}, 0.92388\\) and Quaternion\\({-0.476858, -0, -0}, 0.150353\\) are not normalized"): + math.lerp(a, b_invalid, 0.25) + with self.assertRaisesRegex(ValueError, "quaternions Quaternion\\({1.14805, 0, 0}, 2.77164\\) and Quaternion\\({-0.953717, -0, -0}, 0.300706\\) are not normalized"): + math.lerp_shortest_path(a_invalid, b, 0.25) + with self.assertRaisesRegex(ValueError, "quaternions Quaternion\\({0.382683, 0, 0}, 0.92388\\) and Quaternion\\({-0.476858, -0, -0}, 0.150353\\) are not normalized"): + math.lerp_shortest_path(a, b_invalid, 0.25) + with self.assertRaisesRegex(ValueError, "quaternions Quaternion\\({1.14805, 0, 0}, 2.77164\\) and Quaternion\\({-0.953717, -0, -0}, 0.300706\\) are not normalized"): + math.slerp(a_invalid, b, 0.25) + with self.assertRaisesRegex(ValueError, "quaternions Quaternion\\({0.382683, 0, 0}, 0.92388\\) and Quaternion\\({-0.476858, -0, -0}, 0.150353\\) are not normalized"): + math.slerp(a, b_invalid, 0.25) + with self.assertRaisesRegex(ValueError, "quaternions Quaternion\\({1.14805, 0, 0}, 2.77164\\) and Quaternion\\({-0.953717, -0, -0}, 0.300706\\) are not normalized"): + math.slerp_shortest_path(a_invalid, b, 0.25) + with self.assertRaisesRegex(ValueError, "quaternions Quaternion\\({0.382683, 0, 0}, 0.92388\\) and Quaternion\\({-0.476858, -0, -0}, 0.150353\\) are not normalized"): + math.slerp_shortest_path(a, b_invalid, 0.25) + def test_properties(self): a = Quaternion() a.vector = (1.0, 2.0, 3.0)