From efb296029e5dc31659ee75fbadb5bf640f303d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Fri, 23 May 2025 00:16:16 +0200 Subject: [PATCH] python: gracefully handle argument errors in the primitives module. Yeah, now it's finally up to my standards, not giving in to the usual laziness. --- doc/python/conf.py | 1 + doc/python/magnum.primitives.rst | 70 ++++++++++++ src/python/magnum/primitives.cpp | 93 ++++++++++++++-- src/python/magnum/test/test_primitives.py | 125 ++++++++++++++++++++++ 4 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 doc/python/magnum.primitives.rst diff --git a/doc/python/conf.py b/doc/python/conf.py index 0d0f984..2d51ecd 100644 --- a/doc/python/conf.py +++ b/doc/python/conf.py @@ -210,6 +210,7 @@ INPUT_DOCS = [ 'magnum.materialtools.rst', 'magnum.meshtools.rst', 'magnum.platform.rst', + 'magnum.primitives.rst', 'magnum.scenegraph.rst', 'magnum.scenetools.rst', 'magnum.shaders.rst', diff --git a/doc/python/magnum.primitives.rst b/doc/python/magnum.primitives.rst new file mode 100644 index 0000000..017f5e3 --- /dev/null +++ b/doc/python/magnum.primitives.rst @@ -0,0 +1,70 @@ +.. + This file is part of Magnum. + + Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, + 2020, 2021, 2022, 2023, 2024, 2025 + Vladimír Vondruš + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +.. + +.. py:function:: magnum.primitives.capsule2d_wireframe + :raise AssertionError: If :p:`hemisphere_rings` is less than :py:`1` + :raise AssertionError: If :p:`cylinder_rings` is less than :py:`1` +.. py:function:: magnum.primitives.capsule3d_solid + :raise AssertionError: If :p:`hemisphere_rings` is less than :py:`1` + :raise AssertionError: If :p:`cylinder_rings` is less than :py:`1` + :raise AssertionError: If :p:`segments` is less than :py:`3` +.. py:function:: magnum.primitives.capsule3d_wireframe + :raise AssertionError: If :p:`hemisphere_rings` is less than :py:`1` + :raise AssertionError: If :p:`cylinder_rings` is less than :py:`1` + :raise AssertionError: If :p:`segments` is zero or not a multiple of + :py:`4` + +.. py:function:: magnum.primitives.circle2d_solid + :raise AssertionError: If :p:`segments` is less than :py:`3` +.. py:function:: magnum.primitives.circle2d_wireframe + :raise AssertionError: If :p:`segments` is less than :py:`3` +.. py:function:: magnum.primitives.circle3d_solid + :raise AssertionError: If :p:`segments` is less than :py:`3` +.. py:function:: magnum.primitives.circle3d_wireframe + :raise AssertionError: If :p:`segments` is less than :py:`3` + +.. py:function:: magnum.primitives.cone_solid + :raise AssertionError: If :p:`rings` is less than :py:`1` + :raise AssertionError: If :p:`segments` is less than :py:`3` +.. py:function:: magnum.primitives.cone_wireframe + :raise AssertionError: If :p:`segments` is zero or not a multiple of + :py:`4` + +.. py:function:: magnum.primitives.cylinder_solid + :raise AssertionError: If :p:`rings` is less than :py:`1` + :raise AssertionError: If :p:`segments` is less than :py:`3` +.. py:function:: magnum.primitives.cylinder_wireframe + :raise AssertionError: If :p:`rings` is less than :py:`1` + :raise AssertionError: If :p:`segments` is zero or not a multiple of + :py:`4` + +.. py:function:: magnum.primitives.uv_sphere_solid + :raise AssertionError: If :p:`rings` is less than :py:`2` + :raise AssertionError: If :p:`segments` is less than :py:`3` +.. py:function:: magnum.primitives.uv_sphere_wireframe + :raise AssertionError: If :p:`rings` is zero or not a multiple of :py:`2` + :raise AssertionError: If :p:`segments` is zero or not a multiple of + :py:`4` diff --git a/src/python/magnum/primitives.cpp b/src/python/magnum/primitives.cpp index 26d49f3..5f84338 100644 --- a/src/python/magnum/primitives.cpp +++ b/src/python/magnum/primitives.cpp @@ -114,25 +114,80 @@ void primitives(py::module_& m) { .def("axis2d", Primitives::axis2D, "2D axis") .def("axis3d", Primitives::axis3D, "3D axis") - .def("capsule2d_wireframe", Primitives::capsule2DWireframe, "Wireframe 2D capsule", py::arg("hemisphere_rings"), py::arg("cylinder_rings"), py::arg("half_length")) + .def("capsule2d_wireframe", [](UnsignedInt hemisphereRings, UnsignedInt cylinderRings, Float halfLength) { + if(hemisphereRings < 1 || cylinderRings < 1) { + PyErr_Format(PyExc_AssertionError, "expected at least one hemisphere ring and one cylinder ring but got %u and %u", hemisphereRings, cylinderRings); + throw py::error_already_set{}; + } + + return Primitives::capsule2DWireframe(hemisphereRings, cylinderRings, halfLength); + }, "Wireframe 2D capsule", py::arg("hemisphere_rings"), py::arg("cylinder_rings"), py::arg("half_length")) .def("capsule3d_solid", [](UnsignedInt hemisphereRings, UnsignedInt cylinderRings, UnsignedInt segments, Float halfLength, Primitives::CapsuleFlag flags) { + if(hemisphereRings < 1 || cylinderRings < 1 || segments < 3) { + PyErr_Format(PyExc_AssertionError, "expected at least one hemisphere ring, one cylinder ring and three segments but got %u, %u and %u", hemisphereRings, cylinderRings, segments); + throw py::error_already_set{}; + } + return Primitives::capsule3DSolid(hemisphereRings, cylinderRings, segments, halfLength, flags); }, "Solid 3D capsule", py::arg("hemisphere_rings"), py::arg("cylinder_rings"), py::arg("segments"), py::arg("half_length"), py::arg("flags") = Primitives::CapsuleFlag{}) - .def("capsule3d_wireframe", Primitives::capsule3DWireframe, "Wireframe 3D capsule", py::arg("hemisphere_rings"), py::arg("cylinder_rings"), py::arg("segments"), py::arg("half_length")) + .def("capsule3d_wireframe", [](UnsignedInt hemisphereRings, UnsignedInt cylinderRings, UnsignedInt segments, Float halfLength) { + if(hemisphereRings < 1 || cylinderRings < 1 || segments % 4 != 0 || !segments) { + PyErr_Format(PyExc_AssertionError, "expected at least one hemisphere ring, one cylinder ring and multiples of 4 segments but got %u, %u and %u", hemisphereRings, cylinderRings, segments); + throw py::error_already_set{}; + } + + return Primitives::capsule3DWireframe(hemisphereRings, cylinderRings, segments, halfLength); + }, "Wireframe 3D capsule", py::arg("hemisphere_rings"), py::arg("cylinder_rings"), py::arg("segments"), py::arg("half_length")) .def("circle2d_solid", [](UnsignedInt segments, Primitives::Circle2DFlag flags) { + if(segments < 3) { + PyErr_Format(PyExc_AssertionError, "expected at least three segments but got %u", segments); + throw py::error_already_set{}; + } + return Primitives::circle2DSolid(segments, flags); }, "Solid 2D circle", py::arg("segments"), py::arg("flags") = Primitives::Circle2DFlag{}) - .def("circle2d_wireframe", Primitives::circle2DWireframe, "Wireframe 2D circle", py::arg("segments")) + .def("circle2d_wireframe", [](UnsignedInt segments) { + if(segments < 3) { + PyErr_Format(PyExc_AssertionError, "expected at least three segments but got %u", segments); + throw py::error_already_set{}; + } + + return Primitives::circle2DWireframe(segments); + }, "Wireframe 2D circle", py::arg("segments")) .def("circle3d_solid", [](UnsignedInt segments, Primitives::Circle3DFlag flags) { + if(segments < 3) { + PyErr_Format(PyExc_AssertionError, "expected at least three segments but got %u", segments); + throw py::error_already_set{}; + } + return Primitives::circle3DSolid(segments, flags); }, "Solid 3D circle", py::arg("segments"), py::arg("flags") = Primitives::Circle3DFlag{}) - .def("circle3d_wireframe", Primitives::circle3DWireframe, "Wireframe 3D circle", py::arg("segments")) + .def("circle3d_wireframe", [](UnsignedInt segments) { + if(segments < 3) { + PyErr_Format(PyExc_AssertionError, "expected at least three segments but got %u", segments); + throw py::error_already_set{}; + } + + return Primitives::circle3DWireframe(segments); + }, "Wireframe 3D circle", py::arg("segments")) .def("cone_solid", [](UnsignedInt rings, UnsignedInt segments, Float halfLength, Primitives::ConeFlag flags) { + if(rings < 1 || segments < 3) { + PyErr_Format(PyExc_AssertionError, "expected at least one ring and three segments but got %u and %u", rings, segments); + throw py::error_already_set{}; + } + return Primitives::coneSolid(rings, segments, halfLength, flags); }, "Solid 3D cone", py::arg("rings"), py::arg("segments"), py::arg("half_length"), py::arg("flags") = Primitives::ConeFlag{}) - .def("cone_wireframe", Primitives::coneWireframe, "Wireframe 3D cone", py::arg("segments"), py::arg("half_length")) + .def("cone_wireframe", [](UnsignedInt segments, Float halfLength) { + if(segments % 4 != 0 || !segments) { + PyErr_Format(PyExc_AssertionError, "expected multiples of 4 segments but got %u", segments); + throw py::error_already_set{}; + } + + return Primitives::coneWireframe(segments, halfLength); + }, "Wireframe 3D cone", py::arg("segments"), py::arg("half_length")) .def("crosshair2d", Primitives::crosshair2D, "2D crosshair") .def("crosshair3d", Primitives::crosshair3D, "3D crosshair") @@ -142,9 +197,21 @@ void primitives(py::module_& m) { .def("cube_wireframe", Primitives::cubeWireframe, "Wireframe 3D cube") .def("cylinder_solid", [](UnsignedInt rings, UnsignedInt segments, Float halfLength, Primitives::CylinderFlag flags) { + if(rings < 1 || segments < 3) { + PyErr_Format(PyExc_AssertionError, "expected at least one ring and three segments but got %u and %u", rings, segments); + throw py::error_already_set{}; + } + return Primitives::cylinderSolid(rings, segments, halfLength, flags); }, "Solid 3D cylinder", py::arg("rings"), py::arg("segments"), py::arg("half_length"), py::arg("flags") = Primitives::CylinderFlag{}) - .def("cylinder_wireframe", Primitives::cylinderWireframe, "Wireframe 3D cylinder", py::arg("rings"), py::arg("segments"), py::arg("half_length")) + .def("cylinder_wireframe", [](UnsignedInt rings, UnsignedInt segments, Float halfLength) { + if(rings < 1 || segments % 4 != 0 || !segments) { + PyErr_Format(PyExc_AssertionError, "expected at least one ring and multiples of 4 segments but got %u and %u", rings, segments); + throw py::error_already_set{}; + } + + return Primitives::cylinderWireframe(rings, segments, halfLength); + }, "Wireframe 3D cylinder", py::arg("rings"), py::arg("segments"), py::arg("half_length")) .def("gradient2d", Primitives::gradient2D, "2D square with a gradient", py::arg("a"), py::arg("color_a"), py::arg("b"), py::arg("color_b")) .def("gradient2d_horizontal", Primitives::gradient2DHorizontal, "2D square with a horizontal gradient", py::arg("color_left"), py::arg("color_right")) @@ -176,9 +243,21 @@ void primitives(py::module_& m) { .def("square_wireframe", Primitives::squareWireframe, "Wireframe 2D square") .def("uv_sphere_solid", [](UnsignedInt rings, UnsignedInt segments, Primitives::UVSphereFlag flags) { + if(rings < 2 || segments < 3) { + PyErr_Format(PyExc_AssertionError, "expected at least two rings and three segments but got %u and %u", rings, segments); + throw py::error_already_set{}; + } + return Primitives::uvSphereSolid(rings, segments, flags); }, "Solid 3D UV sphere", py::arg("rings"), py::arg("segments"), py::arg("flags") = Primitives::UVSphereFlag{}) - .def("uv_sphere_wireframe", Primitives::uvSphereWireframe, "Wireframe 3D UV sphere", py::arg("rings"), py::arg("segments")); + .def("uv_sphere_wireframe", [](UnsignedInt rings, UnsignedInt segments) { + if(rings % 2 != 0 || !rings || segments % 4 != 0 || !segments) { + PyErr_Format(PyExc_AssertionError, "expected multiples of 2 rings and multiples of 4 segments but got %u and %u", rings, segments); + throw py::error_already_set{}; + } + + return Primitives::uvSphereWireframe(rings, segments); + }, "Wireframe 3D UV sphere", py::arg("rings"), py::arg("segments")); } } diff --git a/src/python/magnum/test/test_primitives.py b/src/python/magnum/test/test_primitives.py index c2a3f29..ed25c13 100644 --- a/src/python/magnum/test/test_primitives.py +++ b/src/python/magnum/test/test_primitives.py @@ -48,6 +48,15 @@ class Capsule(unittest.TestCase): self.assertEqual(a.primitive, MeshPrimitive.LINES) self.assertEqual(a.attribute_count(), 1) + def test_2d_wireframe_invalid(self): + # This is fine + primitives.capsule2d_wireframe(1, 1, 1.0) + + with self.assertRaisesRegex(AssertionError, "expected at least one hemisphere ring and one cylinder ring but got 0 and 1"): + primitives.capsule2d_wireframe(0, 1, 1.0) + with self.assertRaisesRegex(AssertionError, "expected at least one hemisphere ring and one cylinder ring but got 1 and 0"): + primitives.capsule2d_wireframe(1, 0, 1.0) + def test_3d_solid(self): a = primitives.capsule3d_solid(3, 3, 10, 2.0, primitives.CapsuleFlags.TEXTURE_COORDINATES|primitives.CapsuleFlags.TANGENTS) self.assertEqual(a.primitive, MeshPrimitive.TRIANGLES) @@ -59,11 +68,36 @@ class Capsule(unittest.TestCase): self.assertTrue(b.is_indexed) self.assertEqual(b.attribute_count(), 2) + def test_3d_solid_invalid(self): + # This is fine + primitives.capsule3d_solid(1, 1, 3, 1.0) + + with self.assertRaisesRegex(AssertionError, "expected at least one hemisphere ring, one cylinder ring and three segments but got 0, 1 and 3"): + primitives.capsule3d_solid(0, 1, 3, 1.0) + with self.assertRaisesRegex(AssertionError, "expected at least one hemisphere ring, one cylinder ring and three segments but got 1, 0 and 3"): + primitives.capsule3d_solid(1, 0, 3, 1.0) + with self.assertRaisesRegex(AssertionError, "expected at least one hemisphere ring, one cylinder ring and three segments but got 1, 1 and 2"): + primitives.capsule3d_solid(1, 1, 2, 1.0) + def test_3d_wireframe(self): a = primitives.capsule3d_wireframe(5, 3, 12, 0.3) self.assertEqual(a.primitive, MeshPrimitive.LINES) self.assertTrue(a.is_indexed) + def test_3d_wireframe_invalid(self): + # This is fine + primitives.capsule3d_wireframe(1, 1, 4, 1.0) + primitives.capsule3d_wireframe(1, 1, 16, 1.0) + + with self.assertRaisesRegex(AssertionError, "expected at least one hemisphere ring, one cylinder ring and multiples of 4 segments but got 0, 1 and 4"): + primitives.capsule3d_wireframe(0, 1, 4, 1.0) + with self.assertRaisesRegex(AssertionError, "expected at least one hemisphere ring, one cylinder ring and multiples of 4 segments but got 1, 0 and 4"): + primitives.capsule3d_wireframe(1, 0, 4, 1.0) + with self.assertRaisesRegex(AssertionError, "expected at least one hemisphere ring, one cylinder ring and multiples of 4 segments but got 1, 1 and 9"): + primitives.capsule3d_wireframe(1, 1, 9, 1.0) + with self.assertRaisesRegex(AssertionError, "expected at least one hemisphere ring, one cylinder ring and multiples of 4 segments but got 1, 1 and 0"): + primitives.capsule3d_wireframe(1, 1, 0, 1.0) + class Circle(unittest.TestCase): def test_2d_solid(self): a = primitives.circle2d_solid(5, primitives.Circle2DFlags.TEXTURE_COORDINATES) @@ -76,11 +110,25 @@ class Circle(unittest.TestCase): self.assertFalse(b.is_indexed) self.assertEqual(b.attribute_count(), 1) + def test_2d_solid_invalid(self): + # This is fine + primitives.circle2d_solid(3) + + with self.assertRaisesRegex(AssertionError, "expected at least three segments but got 2"): + primitives.circle2d_solid(2) + def test_2d_wireframe(self): a = primitives.circle2d_wireframe(5) self.assertEqual(a.primitive, MeshPrimitive.LINE_LOOP) self.assertFalse(a.is_indexed) + def test_2d_wireframe_invalid(self): + # This is fine + primitives.circle2d_wireframe(3) + + with self.assertRaisesRegex(AssertionError, "expected at least three segments but got 2"): + primitives.circle2d_wireframe(2) + def test_3d_solid(self): a = primitives.circle3d_solid(5, primitives.Circle3DFlags.TEXTURE_COORDINATES|primitives.Circle3DFlags.TANGENTS) self.assertEqual(a.primitive, MeshPrimitive.TRIANGLE_FAN) @@ -92,11 +140,25 @@ class Circle(unittest.TestCase): self.assertFalse(b.is_indexed) self.assertEqual(b.attribute_count(), 2) + def test_3d_solid_invalid(self): + # This is fine + primitives.circle3d_solid(3) + + with self.assertRaisesRegex(AssertionError, "expected at least three segments but got 2"): + primitives.circle3d_solid(2) + def test_3d_wireframe(self): a = primitives.circle3d_wireframe(5) self.assertEqual(a.primitive, MeshPrimitive.LINE_LOOP) self.assertFalse(a.is_indexed) + def test_3d_wireframe_invalid(self): + # This is fine + primitives.circle3d_wireframe(3) + + with self.assertRaisesRegex(AssertionError, "expected at least three segments but got 2"): + primitives.circle3d_wireframe(2) + class Cone(unittest.TestCase): def test_solid(self): a = primitives.cone_solid(5, 7, 7.1, primitives.ConeFlags.TEXTURE_COORDINATES|primitives.ConeFlags.CAP_END) @@ -109,11 +171,30 @@ class Cone(unittest.TestCase): self.assertTrue(b.is_indexed) self.assertEqual(b.attribute_count(), 2) + def test_solid_invalid(self): + # This is fine + primitives.cone_solid(1, 3, 1.0) + + with self.assertRaisesRegex(AssertionError, "expected at least one ring and three segments but got 0 and 3"): + primitives.cone_solid(0, 3, 1.0) + with self.assertRaisesRegex(AssertionError, "expected at least one ring and three segments but got 1 and 2"): + primitives.cone_solid(1, 2, 1.0) + def test_wireframe(self): a = primitives.cone_wireframe(16, 7.1) self.assertEqual(a.primitive, MeshPrimitive.LINES) self.assertTrue(a.is_indexed) + def test_wireframe_invalid(self): + # This is fine + primitives.cone_wireframe(4, 1.0) + primitives.cone_wireframe(16, 1.0) + + with self.assertRaisesRegex(AssertionError, "expected multiples of 4 segments but got 9"): + primitives.cone_wireframe(9, 1.0) + with self.assertRaisesRegex(AssertionError, "expected multiples of 4 segments but got 0"): + primitives.cone_wireframe(0, 1.0) + class Crosshair(unittest.TestCase): def test_2d(self): a = primitives.crosshair2d() @@ -153,11 +234,32 @@ class Cylinder(unittest.TestCase): self.assertTrue(b.is_indexed) self.assertEqual(b.attribute_count(), 2) + def test_solid_invalid(self): + # This is fine + primitives.cylinder_solid(1, 3, 1.0) + + with self.assertRaisesRegex(AssertionError, "expected at least one ring and three segments but got 0 and 3"): + primitives.cylinder_solid(0, 3, 1.0) + with self.assertRaisesRegex(AssertionError, "expected at least one ring and three segments but got 1 and 2"): + primitives.cylinder_solid(1, 2, 1.0) + def test_wireframe(self): a = primitives.cylinder_wireframe(8, 16, 1.1) self.assertEqual(a.primitive, MeshPrimitive.LINES) self.assertTrue(a.is_indexed) + def test_wireframe_invalid(self): + # This is fine + primitives.cylinder_wireframe(1, 4, 1.0) + primitives.cylinder_wireframe(1, 16, 1.0) + + with self.assertRaisesRegex(AssertionError, "expected at least one ring and multiples of 4 segments but got 0 and 4"): + primitives.cylinder_wireframe(0, 4, 1.0) + with self.assertRaisesRegex(AssertionError, "expected at least one ring and multiples of 4 segments but got 1 and 9"): + primitives.cylinder_wireframe(1, 9, 1.0) + with self.assertRaisesRegex(AssertionError, "expected at least one ring and multiples of 4 segments but got 1 and 0"): + primitives.cylinder_wireframe(1, 0, 1.0) + class Gradient(unittest.TestCase): def test_gradient2d(self): a = primitives.gradient2d((3.1, 2.0), Color3(), (0.2, 1.1), Color4()) @@ -280,7 +382,30 @@ class UVSphere(unittest.TestCase): self.assertTrue(b.is_indexed) self.assertEqual(b.attribute_count(), 2) + def test_solid_invalid(self): + # This is fine + primitives.uv_sphere_solid(2, 3) + + with self.assertRaisesRegex(AssertionError, "expected at least two rings and three segments but got 1 and 3"): + primitives.uv_sphere_solid(1, 3) + with self.assertRaisesRegex(AssertionError, "expected at least two rings and three segments but got 2 and 2"): + primitives.uv_sphere_solid(2, 2) + def test_wireframe(self): a = primitives.uv_sphere_wireframe(6, 8) self.assertEqual(a.primitive, MeshPrimitive.LINES) self.assertTrue(a.is_indexed) + + def test_wireframe_invalid(self): + # This is fine + primitives.uv_sphere_wireframe(2, 4) + primitives.uv_sphere_wireframe(4, 16) + + with self.assertRaisesRegex(AssertionError, "expected multiples of 2 rings and multiples of 4 segments but got 3 and 4"): + primitives.uv_sphere_wireframe(3, 4) + with self.assertRaisesRegex(AssertionError, "expected multiples of 2 rings and multiples of 4 segments but got 0 and 4"): + primitives.uv_sphere_wireframe(0, 4) + with self.assertRaisesRegex(AssertionError, "expected multiples of 2 rings and multiples of 4 segments but got 2 and 9"): + primitives.uv_sphere_wireframe(2, 9) + with self.assertRaisesRegex(AssertionError, "expected multiples of 2 rings and multiples of 4 segments but got 2 and 0"): + primitives.uv_sphere_wireframe(2, 0)