From ad01c1a306cc9c08cafb618d70df0568e22b2c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Tue, 21 May 2019 21:22:39 +0200 Subject: [PATCH] python: initial SceneGraph bits. --- doc/python/conf.py | 4 +- doc/python/magnum.scenegraph.rst | 87 +++++ package/ci/travis-desktop.sh | 2 +- src/python/CMakeLists.txt | 1 + src/python/magnum/CMakeLists.txt | 15 +- src/python/magnum/scenegraph.cpp | 388 ++++++++++++++++++++++ src/python/magnum/test/test_scenegraph.py | 263 +++++++++++++++ src/python/setup.py.cmake | 1 + 8 files changed, 758 insertions(+), 3 deletions(-) create mode 100644 doc/python/magnum.scenegraph.rst create mode 100644 src/python/magnum/scenegraph.cpp create mode 100644 src/python/magnum/test/test_scenegraph.py diff --git a/doc/python/conf.py b/doc/python/conf.py index 640552b..34417a4 100644 --- a/doc/python/conf.py +++ b/doc/python/conf.py @@ -15,10 +15,11 @@ import magnum.platform.glx import magnum.platform.glfw import magnum.platform.sdl2 import magnum.shaders +import magnum.scenegraph # So the doc see everything # TODO: use just +=, m.css should reorder this on its own -magnum.__all__ = ['math', 'gl', 'platform', 'shaders'] + magnum.__all__ +magnum.__all__ = ['math', 'gl', 'platform', 'shaders', 'scenegraph'] + magnum.__all__ # TODO ugh... can this be expressed directly in pybind? magnum.gl.__annotations__ = {} @@ -48,6 +49,7 @@ INPUT_DOCS = [ 'magnum.gl.rst', 'magnum.math.rst', 'magnum.platform.rst', + 'magnum.scenegraph.rst', 'magnum.shaders.rst' ] diff --git a/doc/python/magnum.scenegraph.rst b/doc/python/magnum.scenegraph.rst new file mode 100644 index 0000000..9e29aae --- /dev/null +++ b/doc/python/magnum.scenegraph.rst @@ -0,0 +1,87 @@ +.. + This file is part of Magnum. + + Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019 + 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:module:: magnum.scenegraph + + The Python API for :dox:`SceneGraph` provides, similarly to C++, multiple + different transformation implementations. Recommended usage is importing + desired implementation akin to :cpp:`typedef`\ ing the types in C++: + + .. code-figure:: + + .. code:: c++ + + #include + #include + #include + + typedef SceneGraph::Scene Scene3D; + typedef SceneGraph::Object Object3D; + + C++ + + .. code-figure:: + + .. code:: py + + from magnum import scenegraph + from magnum.scenegraph.matrix import Scene3D, Object3D + + Python + + `Scene vs Object`_ + ================== + + In C++, the Scene is a subclass of Object. However, because the Scene + object is not transformable nor it's possible to attach features to it, + most of the inherited API is unusable. This could be considered a wart of + the C++ API, so the Python bindings expose Scene and Object as two + unrelated types and all APIs that can take either a Scene or an Object + have corresponding overloads. + + `Reference counting`_ + ===================== + + Compared to C++, the following is done with all Object instances created + on Python side: + + - the object is additionally referenced by its parent (if there's any) + so objects created in local scope stay alive even after exiting the + scope + - deleting its parent (either due to it going out of scope or using + :py:`del` in Python) will cause it to have no parent instead of being + cascade deleted (unless it's not referenced anymore, in which case it's deleted as well) + - in order to actually destroy an object, it has to have no parent + + For features it's slightly different: + + - the feature is additionally referenced by the holder object so features + created in local scope stay alive even after exiting the scope + - deleting the holder object (either due to it going out of scope + or using :py:`del` in Python) will cause it to be without a holder + object (unless it's not referenced anymore, in which case it's deleted + as well) --- this makes any further operations on it impossible and + likely dangerous + - in order to actually destroy a feature, it has to have no holder object diff --git a/package/ci/travis-desktop.sh b/package/ci/travis-desktop.sh index 812aa68..6e70cab 100755 --- a/package/ci/travis-desktop.sh +++ b/package/ci/travis-desktop.sh @@ -30,7 +30,7 @@ cmake .. \ -DWITH_GL=ON \ -DWITH_MESHTOOLS=OFF \ -DWITH_PRIMITIVES=OFF \ - -DWITH_SCENEGRAPH=OFF \ + -DWITH_SCENEGRAPH=ON \ -DWITH_SHADERS=ON \ -DWITH_TEXT=OFF \ -DWITH_TEXTURETOOLS=OFF \ diff --git a/src/python/CMakeLists.txt b/src/python/CMakeLists.txt index 7c5831d..e51f608 100644 --- a/src/python/CMakeLists.txt +++ b/src/python/CMakeLists.txt @@ -41,6 +41,7 @@ add_subdirectory(magnum) # configure_file() and then replacing generator expressions with file(GENERATE) foreach(target magnum_gl + magnum_scenegraph magnum_shaders magnum_platform_egl magnum_platform_glx diff --git a/src/python/magnum/CMakeLists.txt b/src/python/magnum/CMakeLists.txt index bbf2a9b..28f3453 100644 --- a/src/python/magnum/CMakeLists.txt +++ b/src/python/magnum/CMakeLists.txt @@ -24,7 +24,7 @@ # # *Not* REQUIRED -find_package(Magnum COMPONENTS GL Shaders) +find_package(Magnum COMPONENTS GL Shaders SceneGraph) set(magnum_SRCS magnum.cpp @@ -55,6 +55,19 @@ if(Magnum_GL_FOUND) LIBRARY_OUTPUT_DIRECTORY ${output_dir}/magnum) endif() +if(Magnum_SceneGraph_FOUND) + set(magnum_scenegraph_SRCS + scenegraph.cpp) + + pybind11_add_module(magnum_scenegraph ${magnum_scenegraph_SRCS}) + target_include_directories(magnum_scenegraph PRIVATE ${PROJECT_SOURCE_DIR}/src/python) + target_link_libraries(magnum_scenegraph PRIVATE Magnum::SceneGraph) + set_target_properties(magnum_scenegraph PROPERTIES + FOLDER "python" + OUTPUT_NAME "scenegraph" + LIBRARY_OUTPUT_DIRECTORY ${output_dir}/magnum) +endif() + if(Magnum_Shaders_FOUND) set(magnum_shaders_SRCS shaders.cpp) diff --git a/src/python/magnum/scenegraph.cpp b/src/python/magnum/scenegraph.cpp new file mode 100644 index 0000000..2744649 --- /dev/null +++ b/src/python/magnum/scenegraph.cpp @@ -0,0 +1,388 @@ +/* + This file is part of Magnum. + + Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019 + 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. +*/ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "magnum/bootstrap.h" + +/* This is a variant of https://github.com/pybind/pybind11/issues/1389. If the + object has a parent, its refcount gets increased in order to avoid it being + deleted by Python too soon. The refcount gets decreased when the parent is + removed again or the parent gets deleted. I thought this would be doable + inside py::init() as + + .def(py::init([](SceneGraph::Scene* parent) { + auto self = new PyObject{parent}; + if(parent) py::cast(self).inc_ref(); + return self; + })) + + but FOR SOME REASON py::cast(self) inside py::init() returns a different + underlying PyObject pointer, so it only leads to crashes. */ +namespace magnum { namespace { + +template struct SceneGraphObjectHolder: std::unique_ptr { + explicit SceneGraphObjectHolder(T* object): std::unique_ptr{object} { + CORRADE_INTERNAL_ASSERT(object); + if(object->parent()) py::cast(object).inc_ref(); + } +}; + +template struct SceneGraphFeatureHolder: std::unique_ptr { + explicit SceneGraphFeatureHolder(T* object): std::unique_ptr{object} { + CORRADE_INTERNAL_ASSERT(object); + py::cast(object).inc_ref(); + } +}; + +}} + +PYBIND11_DECLARE_HOLDER_TYPE(T, magnum::SceneGraphObjectHolder) +PYBIND11_DECLARE_HOLDER_TYPE(T, magnum::SceneGraphFeatureHolder) + +namespace magnum { namespace { + +template class PyObject: public SceneGraph::Object { + public: + PyObject(SceneGraph::Object* parent): SceneGraph::Object{parent} {} + + private: + void doErase() override { + /* When deleting a parent, disconnect this from the parent instead + of deleting it. Deletion is then handled by Python itself. */ + CORRADE_INTERNAL_ASSERT(SceneGraph::Object::parent()); + SceneGraph::Object::setParent(nullptr); + py::cast(this).dec_ref(); + } +}; + +template struct PyDrawable: SceneGraph::Drawable { + explicit PyDrawable(SceneGraph::AbstractObject& object, SceneGraph::DrawableGroup* drawables): SceneGraph::Drawable{object, drawables} {} + + void draw(const MatrixTypeFor& transformationMatrix, SceneGraph::Camera& camera) override { + PYBIND11_OVERLOAD_PURE_NAME( + void, + PyDrawable, + "draw", + draw, + transformationMatrix, + camera + ); + } + + void doErase() override { + /* When deleting the holder object, disconnect this from that + object instead of deleting it. This makes it rather useless, but + better than having dangling memory or double deletion. This is of + course not allowed by the C++ API due to private inheritance so we + have to reinterpret self as the list instead. UGLY: */ + auto& listItem = reinterpret_cast, SceneGraph::AbstractObject>&>(*this); + + CORRADE_INTERNAL_ASSERT(listItem.list()); + listItem.list()->features().cut(this); + py::cast(this).dec_ref(); + } +}; + +template struct PyCamera: SceneGraph::Camera { + explicit PyCamera(SceneGraph::AbstractObject& object): SceneGraph::Camera{object} {} + + void doErase() override { + /* When deleting the holder object, disconnect this from that + object instead of deleting it. This makes it rather useless, but + better than having dangling memory or double deletion. This is of + course not allowed by the C++ API due to private inheritance so we + have to reinterpret self as the list instead. UGLY: */ + auto& listItem = reinterpret_cast, SceneGraph::AbstractObject>&>(*this); + + CORRADE_INTERNAL_ASSERT(listItem.list()); + listItem.list()->features().cut(this); + py::cast(this).dec_ref(); + } +}; + +template void scene(py::class_>& c) { + c.def(py::init(), "Constructor"); +} + +template void abstractObject(py::class_, SceneGraphObjectHolder>>& c) { + c + /* Matrix transformation APIs */ + .def("transformation_matrix", &SceneGraph::AbstractObject::transformationMatrix, + "Transformation matrix") + .def("absolute_transformation_matrix", &SceneGraph::AbstractObject::absoluteTransformationMatrix, + "Transformation matrix relative to the root object"); +} + +template void object(py::class_, SceneGraph::AbstractObject, SceneGraphObjectHolder>>& c) { + c + .def(py::init*>(), + "Constructor", py::arg("parent") = nullptr) + .def(py::init*>(), + "Constructor", py::arg("parent") = nullptr) + + /* Properties */ + .def_property_readonly("scene", [](PyObject& self) { + return static_cast*>(self.scene()); + }, "Scene or None if the object is not a part of any scene") + .def_property("parent", [](PyObject& self) { + return static_cast*>(self.parent()); + }, [](PyObject& self, py::object parentobj) { + SceneGraph::Object* parent; + if(py::isinstance>(parentobj)) + parent = py::cast*>(parentobj); + else if(py::isinstance>(parentobj)) + parent = py::cast*>(parentobj); + else if(py::isinstance(parentobj)) + parent = nullptr; + else throw py::type_error{Utility::formatString("expected Scene, Object or None, got {}", std::string(py::str{parentobj.get_type()}))}; + + /* Decrease refcount if a parent is removed, increase it if a + parent gets added */ + if(self.parent() && !parent) py::cast(&self).dec_ref(); + else if(!self.parent() && parent) py::cast(&self).inc_ref(); + + self.setParent(parent); + }, "Parent object or None if this is the root object") + + /* Transformation APIs common to all implementations */ + .def_property("transformation", + &PyObject::transformation, + &PyObject::setTransformation, + "Object transformation") + .def("absolute_transformation", &PyObject::absoluteTransformation, + "Transformation relative to the root object") + .def("reset_transformation", [](PyObject& self) { + self.resetTransformation(); + }, "Reset the transformation") + .def("transform", [](PyObject& self, const typename Transformation::DataType& transformation) { + self.transform(transformation); + }, "Transform the object") + .def("transform_local", [](PyObject& self, const typename Transformation::DataType& transformation) { + self.transformLocal(transformation); + }, "Transform the object as a local transformation") + .def("translate", [](PyObject& self, const VectorTypeFor& vector) { + self.translate(vector); + }, "Translate the object") + .def("translate_local", [](PyObject& self, const VectorTypeFor& vector) { + self.translateLocal(vector); + }, "Translate the object as a local transformation"); +} + +template void object2D(py::class_, SceneGraph::AbstractObject, SceneGraphObjectHolder>>& c) { + c + .def("rotate", [](PyObject& self, const Radd angle) { + self.rotate(static_cast>(angle)); + }, "Rotate the object") + .def("rotate_local", [](PyObject& self, const Radd angle) { + self.rotateLocal(static_cast>(angle)); + }, "Rotate the object as a local transformation"); +} + +template void object3D(py::class_, SceneGraph::AbstractObject, SceneGraphObjectHolder>>& c) { + c + .def("rotate", [](PyObject& self, const Radd angle, const Math::Vector3& normalizedAxis) { + self.rotate(static_cast>(angle), normalizedAxis); + }, "Rotate the object as a local transformation", py::arg("angle"), py::arg("normalized_axis")) + .def("rotate_local", [](PyObject& self, const Radd angle, const Math::Vector3& normalizedAxis) { + self.rotateLocal(static_cast>(angle), normalizedAxis); + }, "Rotate the object as a local transformation", py::arg("angle"), py::arg("normalized_axis")) + .def("rotate_x", [](PyObject& self, const Radd angle) { + self.rotateX(static_cast>(angle)); + }, "Rotate the object around X axis") + .def("rotate_x_local", [](PyObject& self, const Radd angle) { + self.rotateXLocal(static_cast>(angle)); + }, "Rotate the object around X axis as a local transformation") + .def("rotate_y", [](PyObject& self, const Radd angle) { + self.rotateY(static_cast>(angle)); + }, "Rotate the object around Y axis") + .def("rotate_y_local", [](PyObject& self, const Radd angle) { + self.rotateYLocal(static_cast>(angle)); + }, "Rotate the object around Y axis as a local transformation") + .def("rotate_z", [](PyObject& self, const Radd angle) { + self.rotateZ(static_cast>(angle)); + }, "Rotate the object around Z axis") + .def("rotate_z_local", [](PyObject& self, const Radd angle) { + self.rotateZLocal(static_cast>(angle)); + }, "Rotate the object around Z axis as a local transformation"); +} + +template void objectScale(py::class_, SceneGraph::AbstractObject, SceneGraphObjectHolder>>& c) { + c + .def("scale", [](PyObject& self, const VectorTypeFor& vector) { + self.scale(vector); + }, "Scale the object") + .def("scale_local", [](PyObject& self, const VectorTypeFor& vector) { + self.scaleLocal(vector); + }, "Scale the object as a local transformation") + .def("reflect", [](PyObject& self, const VectorTypeFor& vector) { + self.reflect(vector); + }, "Reflect the object") + .def("reflect_local", [](PyObject& self, const VectorTypeFor& vector) { + self.reflectLocal(vector); + }, "Reflect the object as a local transformation"); +} + +template void featureGroup(py::class_>& c) { + c + .def(py::init(), "Constructor") + .def("__len__", &SceneGraph::FeatureGroup::size, + "Count of features in the group") + /* Get item. Fetching the already registered instance and returning + that instead of wrapping the pointer again. Need to throw IndexError + in order to allow iteration: https://docs.python.org/3/reference/datamodel.html#object.__getitem__ */ + .def("__getitem__", [](SceneGraph::FeatureGroup& self, std::size_t index) -> PyFeature& { + if(index >= self.size()) throw pybind11::index_error{}; + return static_cast(self[index]); + }, "Feature at given index") + .def("add", [](SceneGraph::FeatureGroup& self, PyFeature& feature) { + self.add(feature); + }, "Add a feature to the group") + .def("remove", [](SceneGraph::FeatureGroup& self, PyFeature& feature) { + self.add(feature); + }, "Remove a feature from the group"); +} + +template void feature(py::class_>& c) { + c + .def_property_readonly("object", [](Feature& self) -> SceneGraph::AbstractObject& { + return self.object(); + }, "Object holding this feature"); +} + +template void drawable(py::class_, SceneGraphFeatureHolder>>& c) { + c + .def(py::init&, SceneGraph::DrawableGroup*>(), + "Constructor", py::arg("object"), py::arg("drawables") = nullptr) + .def_property_readonly("drawables", [](PyDrawable& self) { + return self.drawables(); + }, "Group containing this drawable") + .def("draw", [](PyDrawable& self, const MatrixTypeFor& transformationMatrix, PyCamera& camera) { + self.draw(transformationMatrix, camera); + }, "Draw the object using given camera", py::arg("transformation_matrix"), py::arg("camera")); +} + +template void camera(py::class_, SceneGraphFeatureHolder>>& c) { + c + .def(py::init&>(), + "Constructor", py::arg("object")) + .def_property("aspect_ratio_policy", &SceneGraph::Camera::aspectRatioPolicy, + /* Using a lambda because the setter has method chaining */ + [](PyCamera& self, SceneGraph::AspectRatioPolicy policy) { + self.setAspectRatioPolicy(policy); + }, "Aspect ratio policy") + .def_property_readonly("camera_matrix", &SceneGraph::Camera::cameraMatrix, + "Camera matrix") + .def_property("projection_matrix", &SceneGraph::Camera::projectionMatrix, + /* Using a lambda because the setter has method chaining */ + [](PyCamera& self, const MatrixTypeFor& matrix) { + self.setProjectionMatrix(matrix); + }, "Projection matrix") + .def("projection_size", &SceneGraph::Camera::projectionSize, + "Size of (near) XY plane in current projection") + .def_property("viewport", &SceneGraph::Camera::viewport, + &SceneGraph::Camera::setViewport, + "Viewport size") + .def("draw", static_cast::*)(SceneGraph::DrawableGroup&)>(&SceneGraph::Camera::draw), + "Draw"); +} + +void scenegraph(py::module& m) { + /* Abstract objects. Returned from feature.object, so need to be registered + as well. */ + { + py::class_> abstractObject2D{m, "AbstractObject2D", "Base object for two-dimensional scenes"}; + py::class_> abstractObject3D{m, "AbstractObject3D", "Base object for three-dimensional scenes"}; + abstractObject(abstractObject2D); + abstractObject(abstractObject3D); + } + + /* 2D/3D matrix-based implementation */ + { + py::module matrix = m.def_submodule("matrix"); + matrix.doc() = "General matrix-based scene graph implementation"; + + py::class_> scene2D_{matrix, "Scene2D", "Two-dimensional scene with matrix-based transformation implementation"}; + scene(scene2D_); + + py::class_> scene3D_{matrix, "Scene3D", "Three-dimensional scene with matrix-based transformation implementation"}; + scene(scene3D_); + + py::class_, SceneGraph::AbstractObject2D, SceneGraphObjectHolder>> object2D_{matrix, "Object2D", "Two-dimensional object with matrix-based transformation implementation"}; + object(object2D_); + object2D(object2D_); + objectScale(object2D_); + + py::class_, SceneGraph::AbstractObject3D, SceneGraphObjectHolder>> object3D_{matrix, "Object3D", "Three-dimensional object with matrix-based transformation implementation"}; + object(object3D_); + object3D(object3D_); + objectScale(object3D_); + } + + /* Drawables, camera */ + { + py::enum_{m, "AspectRatioPolicy", "Camera aspect ratio policy"} + .value("NOT_PRESERVED", SceneGraph::AspectRatioPolicy::NotPreserved) + .value("EXTEND", SceneGraph::AspectRatioPolicy::Extend) + .value("CLIP", SceneGraph::AspectRatioPolicy::Clip); + + py::class_ drawableGroup2D{m, "DrawableGroup2D", "Group of drawables for two-dimensional float scenes"}; + py::class_ drawableGroup3D{m, "DrawableGroup3D", "Group of drawables for three-dimensional float scenes"}; + + py::class_, SceneGraphFeatureHolder>> drawable2D{m, "Drawable2D", "Drawable for two-dimensional float scenes"}; + py::class_, SceneGraphFeatureHolder>> drawable3D{m, "Drawable3D", "Drawable for three-dimensional float scenes"}; + + py::class_, SceneGraphFeatureHolder>> camera2D{m, "Camera2D", "Camera for two-dimensional float scenes"}; + py::class_, SceneGraphFeatureHolder>> camera3D{m, "Camera3D", "Camera for three-dimensional float scenes"}; + + featureGroup>(drawableGroup2D); + featureGroup>(drawableGroup3D); + feature<2, Float>(drawable2D); + feature<3, Float>(drawable3D); + drawable(drawable2D); + drawable(drawable3D); + + feature<2, Float>(camera2D); + feature<3, Float>(camera3D); + camera(camera2D); + camera(camera3D); + } +} + +}} + +PYBIND11_MODULE(scenegraph, m) { + m.doc() = "Scene graph library"; + + magnum::scenegraph(m); +} + diff --git a/src/python/magnum/test/test_scenegraph.py b/src/python/magnum/test/test_scenegraph.py new file mode 100644 index 0000000..9d40e71 --- /dev/null +++ b/src/python/magnum/test/test_scenegraph.py @@ -0,0 +1,263 @@ +# +# This file is part of Magnum. +# +# Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019 +# 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. +# + +import sys +import unittest + +from magnum import * +from magnum import scenegraph +from magnum.scenegraph.matrix import Object3D, Scene3D + +class Object(unittest.TestCase): + def test_hierarchy(self): + scene = Scene3D() + scene_refcount = sys.getrefcount(scene) + + a = Object3D() + a_refcount = sys.getrefcount(a) + self.assertIs(a.scene, None) + + b = Object3D(parent=scene) + b_refcount = sys.getrefcount(b) + self.assertIs(b.scene, scene) + self.assertIs(b.parent, scene) + + # B should be referenced by the scene, but not cyclically + self.assertEqual(sys.getrefcount(b), scene_refcount + 1) + self.assertEqual(sys.getrefcount(scene), scene_refcount) + + c = Object3D(parent=b) + c_refcount = sys.getrefcount(c) + self.assertIs(c.scene, scene) + self.assertIs(c.parent, b) + + # C should be referenced by B + self.assertEqual(sys.getrefcount(b), scene_refcount + 1) + self.assertEqual(sys.getrefcount(c), scene_refcount + 1) + self.assertEqual(sys.getrefcount(scene), scene_refcount) + + # Delete B. Because B has a parent as well, it's not deleted yet + del b + self.assertIsNotNone(c.parent) + self.assertEqual(sys.getrefcount(c.parent), b_refcount - 1) + self.assertEqual(sys.getrefcount(c), scene_refcount + 1) + + # Delete a scene. That also makes B deleted and C is then orphaned + del scene + self.assertIsNone(c.parent) + self.assertEqual(sys.getrefcount(c), c_refcount - 1) + + def test_hierarchy_set_parent(self): + # Same as test_hierarchy, but setting the parent later + + scene = Scene3D() + scene_refcount = sys.getrefcount(scene) + + a = Object3D() + a_refcount = sys.getrefcount(a) + self.assertIs(a.scene, None) + + b = Object3D() + b.parent = scene + b_refcount = sys.getrefcount(b) + self.assertIs(b.scene, scene) + self.assertIs(b.parent, scene) + + # B should be referenced by the scene, but not cyclically + self.assertEqual(sys.getrefcount(b), scene_refcount + 1) + self.assertEqual(sys.getrefcount(scene), scene_refcount) + + c = Object3D() + c.parent = b + c_refcount = sys.getrefcount(c) + self.assertIs(c.scene, scene) + self.assertIs(c.parent, b) + + # C should be referenced by B + self.assertEqual(sys.getrefcount(b), scene_refcount + 1) + self.assertEqual(sys.getrefcount(c), scene_refcount + 1) + self.assertEqual(sys.getrefcount(scene), scene_refcount) + + # Delete B. Because B has a parent as well, it's not deleted yet + del b + self.assertIsNotNone(c.parent) + self.assertEqual(sys.getrefcount(c.parent), b_refcount - 1) + self.assertEqual(sys.getrefcount(c), scene_refcount + 1) + + # Delete a scene. That also makes B deleted and C is then orphaned + del scene + self.assertIsNone(c.parent) + self.assertEqual(sys.getrefcount(c), c_refcount - 1) + + def test_set_parent_invalid(self): + a = Object3D() + with self.assertRaisesRegex(TypeError, "expected Scene, Object or None, got "): + a.parent = "noo" + + def test_transformation(self): + scene = Scene3D() + + a = Object3D(scene) + a.rotate_local(Deg(35.0), Vector3.x_axis()) + self.assertEqual(a.transformation, Matrix4.rotation_x(Deg(35.0))) + self.assertEqual(a.absolute_transformation(), Matrix4.rotation_x(Deg(35.0))) + + b = Object3D(a) + b.translate((3.0, 4.0, 5.0)) + self.assertEqual(b.transformation, Matrix4.translation((3.0, 4.0, 5.0))) + self.assertEqual(b.absolute_transformation(), + Matrix4.rotation_x(Deg(35.0))@ + Matrix4.translation((3.0, 4.0, 5.0))) + + c = Object3D(scene) + self.assertEqual(c.transformation, Matrix4.identity_init()) + self.assertEqual(c.absolute_transformation(), Matrix4.identity_init()) + + def test_drawable(self): + object = Object3D() + object_refcount = sys.getrefcount(object) + + a = scenegraph.Drawable3D(object) + a_refcount = sys.getrefcount(a) + self.assertIs(a.object, object) + + b = scenegraph.Drawable3D(object) + b_refcount = sys.getrefcount(b) + self.assertIs(b.object, object) + + # Drawables should be referenced by the object, but not cyclically + self.assertEqual(sys.getrefcount(object), object_refcount) + self.assertEqual(sys.getrefcount(a), object_refcount + 1) + self.assertEqual(sys.getrefcount(b), object_refcount + 1) + + # Delete the object. The drawable should be still alive, but + # disconnected from the object (and thus useless). + del object + self.assertIsNone(a.object) + self.assertIsNone(b.object) + self.assertEqual(sys.getrefcount(a), a_refcount - 1) + self.assertEqual(sys.getrefcount(b), b_refcount - 1) + + def test_drawable_group(self): + object = Object3D() + drawables = scenegraph.DrawableGroup3D() + + deleted = 0 + class MyDrawable(scenegraph.Drawable3D): + def __del__(self): + nonlocal deleted + deleted += 1 + + a = MyDrawable(object, drawables) + b = MyDrawable(object, drawables) + + # The drawable group should have these listed + self.assertEqual([i for i in drawables], [a, b]) + + # Deleting each of them should do nothing, since they're still + # referenced by the object + del a, b + self.assertEqual(deleted, 0) + self.assertEqual(len(drawables), 2) + + # Deleting the holder object will, tho + del object + self.assertEqual(deleted, 2) + self.assertEqual(len(drawables), 0) + + def test_camera(self): + object = Object3D() + object.translate(Vector3.z_axis(5.0)) + object_refcount = sys.getrefcount(object) + + a = scenegraph.Camera3D(object) + a.viewport = (400, 300) + a.projection_matrix = Matrix4.perspective_projection( + fov=Deg(45.0), near=0.01, far=100.0, aspect_ratio=1.0) + a.aspect_ratio_policy = scenegraph.AspectRatioPolicy.EXTEND + a_refcount = sys.getrefcount(a) + self.assertEqual(a.viewport, Vector2i(400, 300)) + self.assertEqual(a.projection_matrix, Matrix4.perspective_projection( + fov=Deg(57.82240), near=0.01, far=100.0, aspect_ratio=1.33333333)) + self.assertEqual(a.camera_matrix, Matrix4.translation(-Vector3.z_axis(5.0))) + self.assertEqual(a.aspect_ratio_policy, scenegraph.AspectRatioPolicy.EXTEND) + self.assertIs(a.object, object) + + # Camera should be referenced by the object, but not cyclically + self.assertEqual(sys.getrefcount(object), object_refcount) + self.assertEqual(sys.getrefcount(a), object_refcount + 1) + + # Delete the object. The camera should be still alive, but disconnected + # from the object (and thus useless). + del object + self.assertIsNone(a.object) + self.assertEqual(sys.getrefcount(a), a_refcount - 1) + + def test_camera_draw(self): + scene = Scene3D() + drawables = scenegraph.DrawableGroup3D() + + camera_object = Object3D(scene) + camera_object.translate((0.0, 1.0, 5.0)) + + camera = scenegraph.Camera3D(camera_object) + + rendered = None, None + deleted = "no :)" + class MyDrawable(scenegraph.Drawable3D): + def draw(self, transformation_matrix: Matrix4, camera: scenegraph.Camera3D): + nonlocal rendered + rendered = (transformation_matrix, camera) + + def __del__(self): + nonlocal deleted + deleted = "yes :(" + + class MySilentDrawable(scenegraph.Drawable3D): + def draw(self, transformation_matrix: Matrix4, camera: scenegraph.Camera3D): + pass + + object = Object3D(scene) + object.translate(Vector3.x_axis(5.0)) + a = MyDrawable(object, drawables) + b = MySilentDrawable(object, drawables) + + # The drawable group should have these listed + self.assertEqual([i for i in drawables], [a, b]) + + # Deleting the object, the camera holder and drawable does nothing + del camera_object, object, a, b + + camera.draw(drawables) + self.assertEqual(rendered[0], + Matrix4.translation(Vector3.x_axis(5.0))@ + Matrix4.translation((0.0, -1.0, -5.0))) + self.assertIs(rendered[1], camera) + + # Deleting the scene will delete A and the drawable as well + del scene + self.assertEqual(deleted, "yes :(") + self.assertIsNone(camera.object) + self.assertIs(len(drawables), 0) diff --git a/src/python/setup.py.cmake b/src/python/setup.py.cmake index 138a289..ed7ee5e 100644 --- a/src/python/setup.py.cmake +++ b/src/python/setup.py.cmake @@ -34,6 +34,7 @@ extension_paths = { 'corrade.containers': '$', 'magnum._magnum': '$', 'magnum.gl': '${magnum_gl_file}', + 'magnum.scenegraph': '${magnum_scenegraph_file}', 'magnum.shaders': '${magnum_shaders_file}', 'magnum.platform.egl': '${magnum_platform_egl_file}', 'magnum.platform.glx': '${magnum_platform_glx_file}',