diff --git a/CHANGELOG.md b/CHANGELOG.md index 3495d78de47b..a6490d0720c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Added `inheritance` field to `__jsondump__` of `compas.datastructures.Datastructure` to allow for deserialization to closest available superclass of custom datastructures. +* Added `compas.scene.Scene.get_sceneobject_node` to get the TreeNode that corresponds to a scene object. +* Added `compas.scene.sceneobject_factory` method to create appropriate scene objects from data. ### Changed +* Changed `compas.scene.Scene` to use underlying `datastore`, `objectstore` and `tree` attributes for more transparent serialization and deserialization processes. +* Changed `compas.scene.SceneObject` to use object `guid` to retrieve the corresponding TreeNode from the scene tree, and use item `guid` to retrieve the corresponding data item from the scene datastore. + ### Removed +* Removed `compas.scene.SceneObject.__new__` method, explicitly use `compas.scene.sceneobject_factory` instead. +* Removed `frame` kwarg from `compas.scene.SceneObject` constructor, since it is now computed from the `worldtransformation` attribute. + ## [2.11.0] 2025-04-22 diff --git a/src/compas/datastructures/tree/tree.py b/src/compas/datastructures/tree/tree.py index f8bb56a3e574..e7a06245e056 100644 --- a/src/compas/datastructures/tree/tree.py +++ b/src/compas/datastructures/tree/tree.py @@ -412,7 +412,7 @@ def get_node_by_name(self, name): """ for node in self.nodes: - if node.name == name: + if str(node.name) == str(name): return node def get_nodes_by_name(self, name): @@ -436,7 +436,7 @@ def get_nodes_by_name(self, name): nodes.append(node) return nodes - def get_hierarchy_string(self, max_depth=None): + def get_hierarchy_string(self, max_depth=None, node_repr=None): """ Return string representation for the spatial hierarchy of the tree. @@ -445,6 +445,10 @@ def get_hierarchy_string(self, max_depth=None): max_depth : int, optional The maximum depth of the hierarchy to print. Default is ``None``, in which case the entire hierarchy is printed. + node_repr : callable, optional + A callable to represent the node string. + Default is ``None``, in which case the node.__repr__() is used. + Returns ------- @@ -455,18 +459,23 @@ def get_hierarchy_string(self, max_depth=None): hierarchy = [] - def traverse(node, hierarchy, prefix="", last=True, depth=0): + def traverse(node, hierarchy, prefix="", last=True, depth=0, node_repr=None): if max_depth is not None and depth > max_depth: return + if node_repr is None: + node_string = node.__repr__() + else: + node_string = node_repr(node) + connector = "└── " if last else "├── " - hierarchy.append("{}{}{}".format(prefix, connector, node)) + hierarchy.append("{}{}{}".format(prefix, connector, node_string)) prefix += " " if last else "│ " for i, child in enumerate(node.children): - traverse(child, hierarchy, prefix, i == len(node.children) - 1, depth + 1) + traverse(child, hierarchy, prefix, i == len(node.children) - 1, depth + 1, node_repr) if self.root: - traverse(self.root, hierarchy) + traverse(self.root, hierarchy, node_repr=node_repr) return "\n".join(hierarchy) diff --git a/src/compas/scene/__init__.py b/src/compas/scene/__init__.py index 8aaa2c03581d..2bbce37b507f 100644 --- a/src/compas/scene/__init__.py +++ b/src/compas/scene/__init__.py @@ -10,6 +10,7 @@ from .exceptions import SceneObjectNotRegisteredError from .sceneobject import SceneObject +from .sceneobject import sceneobject_factory from .meshobject import MeshObject from .graphobject import GraphObject from .geometryobject import GeometryObject @@ -43,6 +44,7 @@ def register_scene_objects_base(): __all__ = [ "SceneObjectNotRegisteredError", "SceneObject", + "sceneobject_factory", "MeshObject", "GraphObject", "GeometryObject", diff --git a/src/compas/scene/context.py b/src/compas/scene/context.py index d083023fa0fd..1ca86be66739 100644 --- a/src/compas/scene/context.py +++ b/src/compas/scene/context.py @@ -119,36 +119,53 @@ def detect_current_context(): return None -def _get_sceneobject_cls(data, **kwargs): - # in any case user gets to override the choice - context_name = kwargs.get("context") or detect_current_context() +def get_sceneobject_cls(item, context=None): + """Get the scene object class for a given item in the current context. If no context is provided, the current context is detected. + If the exact item type is not registered, a closest match in its inheritance hierarchy is used. - dtype = type(data) - cls = None - - if "sceneobject_type" in kwargs: - cls = kwargs["sceneobject_type"] - else: - context = ITEM_SCENEOBJECT[context_name] + Parameters + ---------- + item : :class:`~compas.data.Data` + The item to get the scene object class for. + context : Literal['Viewer', 'Rhino', 'Grasshopper', 'Blender'], optional + The visualization context in which the pair should be registered. - for type_ in inspect.getmro(dtype): - cls = context.get(type_, None) - if cls is not None: - break + Raises + ------ + ValueError + If the item is None. + SceneObjectNotRegisteredError + If no scene object is registered for the item type in the current context. - if cls is None: - raise SceneObjectNotRegisteredError("No scene object is registered for this data type: {} in this context: {}".format(dtype, context_name)) + Returns + ------- + :class:`~compas.scene.SceneObject` + The scene object class for the given item. - return cls + """ + if item is None: + raise ValueError("Cannot create a scene object for None. Please ensure you pass a instance of a supported class.") -def get_sceneobject_cls(item, **kwargs): if not ITEM_SCENEOBJECT: register_scene_objects() - if item is None: - raise ValueError("Cannot create a scene object for None. Please ensure you pass a instance of a supported class.") + if context is None: + context = detect_current_context() + + itemtype = type(item) + + context = ITEM_SCENEOBJECT[context] + + cls = None + + for inheritancetype in inspect.getmro(itemtype): + cls = context.get(inheritancetype, None) + if cls is not None: + break + + if cls is None: + raise SceneObjectNotRegisteredError("No scene object is registered for this data type: {} in this context: {}".format(itemtype, context)) - cls = _get_sceneobject_cls(item, **kwargs) PluginValidator.ensure_implementations(cls) return cls diff --git a/src/compas/scene/group.py b/src/compas/scene/group.py index af10b42d514e..7c02ccb9955a 100644 --- a/src/compas/scene/group.py +++ b/src/compas/scene/group.py @@ -27,16 +27,3 @@ class Group(SceneObject): └── """ - - def __new__(cls, *args, **kwargs): - # overwriting __new__ to revert to the default behavior of normal object, So an instance can be created directly without providing a registered item. - return object.__new__(cls) - - @property - def __data__(self): - # type: () -> dict - data = { - "settings": self.settings, - "children": [child.__data__ for child in self.children], - } - return data diff --git a/src/compas/scene/scene.py b/src/compas/scene/scene.py index 39f56499a6b8..11b0d060be7f 100644 --- a/src/compas/scene/scene.py +++ b/src/compas/scene/scene.py @@ -1,6 +1,7 @@ import compas.data # noqa: F401 import compas.datastructures # noqa: F401 import compas.geometry # noqa: F401 +from compas.datastructures import Datastructure from compas.datastructures import Tree from compas.datastructures import TreeNode @@ -8,11 +9,11 @@ from .context import before_draw from .context import clear from .context import detect_current_context -from .group import Group from .sceneobject import SceneObject +from .sceneobject import sceneobject_factory -class Scene(Tree): +class Scene(Datastructure): """A scene is a container for hierarchical scene objects which are to be visualised in a given context. Parameters @@ -43,43 +44,47 @@ class Scene(Tree): @property def __data__(self): # type: () -> dict - items = {str(object.item.guid): object.item for object in self.objects if object.item is not None} return { "name": self.name, - "root": self.root.__data__, # type: ignore - "items": list(items.values()), + "attributes": self.attributes, + "datastore": self.datastore, + "objectstore": self.objectstore, + "tree": self.tree, } - @classmethod - def __from_data__(cls, data): - # type: (dict) -> Scene - scene = cls(data["name"]) - items = {str(item.guid): item for item in data["items"]} - - def add(node, parent, items): - for child_node in node.get("children", []): - settings = child_node["settings"] - if "item" in child_node: - guid = child_node["item"] - sceneobject = parent.add(items[guid], **settings) - else: - sceneobject = parent.add(Group(**settings)) - add(child_node, sceneobject, items) - - add(data["root"], scene, items) - - return scene - - def __init__(self, name="Scene", context=None): - # type: (str, str | None) -> None - super(Scene, self).__init__(name=name) - super(Scene, self).add(TreeNode(name="ROOT")) + def __init__(self, context=None, datastore=None, objectstore=None, tree=None, **kwargs): + # type: (str | None, dict | None, dict | None, Tree | None, **kwargs) -> None + super(Scene, self).__init__(**kwargs) + self.context = context or detect_current_context() + self.datastore = datastore or {} + self.objectstore = objectstore or {} + self.tree = tree or Tree() + if self.tree.root is None: + self.tree.add(TreeNode(name=self.name)) + + def __repr__(self): + # type: () -> str + + def node_repr(node): + # type: (TreeNode) -> str + if node.is_root: + return node.name + else: + sceneobject = self.objectstore[node.name] + return str(sceneobject) + + return self.tree.get_hierarchy_string(node_repr=node_repr) + + @property + def items(self): + # type: () -> list[compas.data.Data] + return list(self.datastore.values()) @property def objects(self): # type: () -> list[SceneObject] - return [node for node in self.nodes if not node.is_root] # type: ignore + return list(self.objectstore.values()) @property def context_objects(self): @@ -108,19 +113,48 @@ def add(self, item, parent=None, **kwargs): The scene object associated with the item. """ - parent = parent or self.root + if "context" in kwargs: + if kwargs["context"] != self.context: + raise Exception("Object context should be the same as scene context: {} != {}".format(kwargs["context"], self.context)) + del kwargs["context"] # otherwist the SceneObject receives "context" twice, which results in an error + + # Create a corresponding new scene object + sceneobject = sceneobject_factory(item=item, context=self.context, scene=self, **kwargs) - if isinstance(item, SceneObject): - sceneobject = item + # Add the scene object and item to the data store + self.objectstore[str(sceneobject.guid)] = sceneobject + self.datastore[str(item.guid)] = item + + # Add the scene object to the hierarchical tree + if parent is None: + parent_node = self.tree.root else: - if "context" in kwargs: - if kwargs["context"] != self.context: - raise Exception("Object context should be the same as scene context: {} != {}".format(kwargs["context"], self.context)) - del kwargs["context"] # otherwist the SceneObject receives "context" twice, which results in an error - sceneobject = SceneObject(item=item, context=self.context, **kwargs) # type: ignore - super(Scene, self).add(sceneobject, parent=parent) + if not isinstance(parent, SceneObject): + raise ValueError("Parent is not a SceneObject.", parent) + parent_node = self.get_sceneobject_node(parent) + if parent_node is None: + raise ValueError("Parent is not part of the scene.", parent) + + self.tree.add(TreeNode(name=str(sceneobject.guid)), parent=parent_node) + return sceneobject + def remove(self, sceneobject): + """Remove a scene object along with all its descendants from the scene. + + Parameters + ---------- + sceneobject : :class:`compas.scene.SceneObject` + The scene object to remove. + """ + # type: (SceneObject) -> None + node = self.get_sceneobject_node(sceneobject) + if node: + for descendant in node.descendants: + self.objectstore.pop(descendant.name, None) + self.tree.remove(node) + self.objectstore.pop(str(sceneobject.guid), None) + def clear_context(self, guids=None): # type: (list | None) -> None """Clear the visualisation context. @@ -217,7 +251,7 @@ def redraw(self): self.draw() def find_by_name(self, name): - # type: (str) -> SceneObject + # type: (str) -> SceneObject | None """Find the first scene object with the given name. Parameters @@ -227,13 +261,13 @@ def find_by_name(self, name): Returns ------- - :class:`SceneObject` + :class:`SceneObject` | None """ - return self.get_node_by_name(name=name) + return next((obj for obj in self.objects if obj.name == name), None) def find_by_itemtype(self, itemtype): - # type: (...) -> SceneObject | None + # type: (type) -> SceneObject | None """Find the first scene object with a data item of the given type. Parameters @@ -243,7 +277,7 @@ def find_by_itemtype(self, itemtype): Returns ------- - :class:`SceneObject` or None + :class:`SceneObject` | None """ for obj in self.objects: @@ -251,7 +285,7 @@ def find_by_itemtype(self, itemtype): return obj def find_all_by_itemtype(self, itemtype): - # type: (...) -> list[SceneObject] + # type: (type) -> list[SceneObject] """Find all scene objects with a data item of the given type. Parameters @@ -269,3 +303,30 @@ def find_all_by_itemtype(self, itemtype): if isinstance(obj.item, itemtype): sceneobjects.append(obj) return sceneobjects + + def get_sceneobject_node(self, sceneobject): + # type: (SceneObject) -> TreeNode + """Get the TreeNode that corresponds to a scene object. + + Parameters + ---------- + sceneobject : :class:`compas.scene.SceneObject` + + Returns + ------- + :class:`compas.datastructures.TreeNode` + + Raises + ------ + TypeError + If the scene object is not a :class:`compas.scene.SceneObject`. + ValueError + If the scene object is not part of this scene. + """ + + if not isinstance(sceneobject, SceneObject): + raise TypeError("SceneObject expected.", sceneobject) + if sceneobject.scene is not self: + raise ValueError("SceneObject not part of this scene.", sceneobject) + + return self.tree.get_node_by_name(sceneobject.guid) diff --git a/src/compas/scene/sceneobject.py b/src/compas/scene/sceneobject.py index 371e00c8feb3..a423eec9f1f0 100644 --- a/src/compas/scene/sceneobject.py +++ b/src/compas/scene/sceneobject.py @@ -12,7 +12,6 @@ import compas.scene # noqa: F401 from compas.colors import Color from compas.data import Data -from compas.datastructures import TreeNode from compas.geometry import Frame from compas.geometry import Transformation @@ -22,7 +21,46 @@ from .descriptors.protocol import DescriptorProtocol -class SceneObject(TreeNode): +def sceneobject_factory(item=None, scene=None, context=None, **kwargs): + """Create appropriate SceneObject instance based on item type. + + Parameters + ---------- + item : :class:`compas.data.Data` + The data item to create a scene object for. + scene : :class:`compas.scene.Scene`, optional + The scene in which the scene object is created. + context : str, optional + The context in which the scene object is created. + **kwargs : dict + Additional keyword arguments to pass to the SceneObject constructor. + + Returns + ------- + :class:`compas.scene.SceneObject` + A SceneObject instance of the appropriate subclass for the given item. + + Raises + ------ + ValueError + If item is None. + SceneObjectNotRegisteredError + If no scene object is registered for the item type in the current context. + """ + if item is None: + raise ValueError("Cannot create a scene object for None. Please ensure you pass an instance of a supported class.") + + if isinstance(item, SceneObject): + item._scene = scene + return item + + sceneobject_cls = get_sceneobject_cls(item, context=context) + + # Create and return an instance of the appropriate scene object class + return sceneobject_cls(item=item, scene=scene, context=context, **kwargs) + + +class SceneObject(Data): """Base class for all scene objects. Parameters @@ -84,10 +122,6 @@ class SceneObject(TreeNode): color = ColorAttribute() - def __new__(cls, item=None, **kwargs): - sceneobject_cls = get_sceneobject_cls(item, **kwargs) - return super(SceneObject, cls).__new__(sceneobject_cls) - def __init__( self, item=None, # type: compas.data.Data | None @@ -95,22 +129,30 @@ def __init__( color=None, # type: compas.colors.Color | None opacity=1.0, # type: float show=True, # type: bool - frame=None, # type: compas.geometry.Frame | None transformation=None, # type: compas.geometry.Transformation | None context=None, # type: str | None + scene=None, # type: compas.scene.Scene | None **kwargs # type: dict ): # fmt: skip # type: (...) -> None - if item and not isinstance(item, Data): - raise ValueError("The item assigned to this scene object should be a data object: {}".format(type(item))) name = name or getattr(item, "name", None) super(SceneObject, self).__init__(name=name, **kwargs) # the scene object needs to store the context # because it has no access to the tree and/or the scene before it is added # which means that adding child objects will be added in context "None" + + if isinstance(item, Data): + self._item = str(item.guid) + elif isinstance(item, str): + self._item = item + elif item is None: + self._item = None + else: + raise ValueError("The item assigned to this scene object should be a data object or a str guid: {}".format(item)) + self.context = context - self._item = item + self._scene = scene self._guids = [] self._node = None self._transformation = transformation @@ -123,16 +165,14 @@ def __init__( def __data__(self): # type: () -> dict return { - "item": str(self.item.guid), - "settings": self.settings, - "children": [child.__data__ for child in self.children], + "item": self._item, + "name": self.name, + "color": self.color, + "opacity": self.opacity, + "show": self.show, + "transformation": self.transformation, } - @classmethod - def __from_data__(cls, data): - # type: (dict) -> None - raise TypeError("Serialisation outside Scene not allowed.") - def __repr__(self): # type: () -> str return "<{}: {}>".format(self.__class__.__name__, self.name) @@ -140,12 +180,56 @@ def __repr__(self): @property def scene(self): # type: () -> compas.scene.Scene | None - return self.tree + return self._scene @property def item(self): # type: () -> compas.data.Data - return self._item + return self.scene.datastore[self._item] + + @property + def node(self): + # type: () -> compas.datastructures.TreeNode + if self._node is None: + self._node = self.scene.get_sceneobject_node(self) + return self._node + + @property + def is_root(self): + # type: () -> bool + return self.node.is_root + + @property + def is_leaf(self): + # type: () -> bool + return self.node.is_leaf + + @property + def is_branch(self): + # type: () -> bool + return self.node.is_branch + + @property + def parentnode(self): + # type: () -> compas.datastructures.Node | None + return self.node.parent + + @property + def childnodes(self): + # type: () -> list[compas.datastructures.Node] + return self.node.children + + @property + def parent(self): + # type: () -> compas.scene.SceneObject | None + if self.parentnode and not self.parentnode.is_root: + return self.scene.objectstore[self.parentnode.name] + return None + + @property + def children(self): + # type: () -> list[compas.scene.SceneObject] + return [self.scene.objectstore[child.name] for child in self.childnodes] @property def guids(self): @@ -199,25 +283,8 @@ def contrastcolor(self, color): # type: (compas.colors.Color) -> None self._contrastcolor = Color.coerce(color) - @property - def settings(self): - # type: () -> dict - settings = { - "name": self.name, - "color": self.color, - "opacity": self.opacity, - "show": self.show, - } - - if self.frame: - settings["frame"] = self.frame - if self.transformation: - settings["transformation"] = self.transformation - - return settings - def add(self, item, **kwargs): - # type: (compas.data.Data, dict) -> SceneObject + # type: (compas.data.Data, dict) -> compas.scene.SceneObject """Add a child item to the scene object. Parameters @@ -225,29 +292,23 @@ def add(self, item, **kwargs): item : :class:`compas.data.Data` The item to add. **kwargs : dict - Additional keyword arguments to create the scene object for the item. + Additional keyword arguments to pass to the SceneObject constructor. Returns ------- :class:`compas.scene.SceneObject` - The scene object associated with the added item. - - Raises - ------ - ValueError - If the scene object does not have an associated scene node. + The added scene object. """ - if isinstance(item, SceneObject): - sceneobject = item - else: - if "context" in kwargs: - if kwargs["context"] != self.context: - raise Exception("Child context should be the same as parent context: {} != {}".format(kwargs["context"], self.context)) - del kwargs["context"] # otherwist the SceneObject receives "context" twice, which results in an error - sceneobject = SceneObject(item=item, context=self.context, **kwargs) # type: ignore - - super(SceneObject, self).add(sceneobject) - return sceneobject + return self.scene.add(item, parent=self, **kwargs) + + def remove(self): + """Remove this scene object along with all its descendants from the scene.""" + self.scene.remove(self) + + @contrastcolor.setter + def contrastcolor(self, color): + # type: (compas.colors.Color) -> None + self._contrastcolor = Color.coerce(color) def draw(self): """The main drawing method.""" diff --git a/src/compas_ghpython/components/Compas_ToRhinoGeometry/code.py b/src/compas_ghpython/components/Compas_ToRhinoGeometry/code.py index fcc03a1ca0b0..d85be514217f 100644 --- a/src/compas_ghpython/components/Compas_ToRhinoGeometry/code.py +++ b/src/compas_ghpython/components/Compas_ToRhinoGeometry/code.py @@ -4,7 +4,7 @@ from ghpythonlib.componentbase import executingcomponent as component -from compas.scene import SceneObject +from compas.scene import sceneobject_factory class CompasToRhinoGeometry(component): @@ -12,4 +12,4 @@ def RunScript(self, cg): if not cg: return None - return SceneObject(item=cg).draw() + return sceneobject_factory(item=cg).draw() diff --git a/src/compas_ghpython/components_cpython/Compas_ToRhinoGeometry/code.py b/src/compas_ghpython/components_cpython/Compas_ToRhinoGeometry/code.py index 93a00e65ad50..a05791018916 100644 --- a/src/compas_ghpython/components_cpython/Compas_ToRhinoGeometry/code.py +++ b/src/compas_ghpython/components_cpython/Compas_ToRhinoGeometry/code.py @@ -7,7 +7,7 @@ import Grasshopper -from compas.scene import SceneObject +from compas.scene import sceneobject_factory class CompasToRhinoGeometry(Grasshopper.Kernel.GH_ScriptInstance): @@ -15,4 +15,4 @@ def RunScript(self, cg: Any): if not cg: return None - return SceneObject(item=cg).draw() + return sceneobject_factory(item=cg).draw() diff --git a/tests/compas/datastructures/test_tree.py b/tests/compas/datastructures/test_tree.py index b04a42fa5663..3065436c4707 100644 --- a/tests/compas/datastructures/test_tree.py +++ b/tests/compas/datastructures/test_tree.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import pytest import compas import json @@ -206,3 +207,26 @@ def key_mapper(node): assert graph2.has_edge(("root", "branch2")) assert graph2.has_edge(("branch2", "leaf2_1")) assert graph2.has_edge(("branch2", "leaf2_2")) + + +# ============================================================================= +# TreeNode Representation +# ============================================================================= + + +def test_treenode_representation(simple_tree): + def node_repr(node): + return node.name + " CUSTOM STRING" + + print(simple_tree.get_hierarchy_string(node_repr=node_repr)) + + assert ( + simple_tree.get_hierarchy_string(node_repr=node_repr) + == """└── root CUSTOM STRING + ├── branch1 CUSTOM STRING + │ ├── leaf1_1 CUSTOM STRING + │ └── leaf1_2 CUSTOM STRING + └── branch2 CUSTOM STRING + ├── leaf2_1 CUSTOM STRING + └── leaf2_2 CUSTOM STRING""" + ) diff --git a/tests/compas/scene/test_scene.py b/tests/compas/scene/test_scene.py index ce7f42a7ee90..ea3f89a87ec4 100644 --- a/tests/compas/scene/test_scene.py +++ b/tests/compas/scene/test_scene.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import compas if not compas.IPY: @@ -6,11 +7,14 @@ from compas.scene import register from compas.scene import Scene from compas.scene import SceneObject + from compas.scene import sceneobject_factory from compas.scene import SceneObjectNotRegisteredError from compas.data import Data from compas.geometry import Box from compas.geometry import Frame from compas.geometry import Translation + from compas.scene import get_sceneobject_cls + from compas.datastructures import Tree @pytest.fixture(autouse=True) def reset_sceneobjects_registration(): @@ -46,39 +50,39 @@ def test_get_sceneobject_cls_with_orderly_registration(): register(FakeItem, FakeSceneObject, context="fake") register(FakeSubItem, FakeSubSceneObject, context="fake") item = FakeItem() - sceneobject = SceneObject(item, context="fake") + sceneobject = sceneobject_factory(item=item, context="fake") assert isinstance(sceneobject, FakeSceneObject) item = FakeSubItem() - sceneobject = SceneObject(item, context="fake") + sceneobject = sceneobject_factory(item=item, context="fake") assert isinstance(sceneobject, FakeSubSceneObject) def test_get_sceneobject_cls_with_out_of_order_registration(): register(FakeSubItem, FakeSubSceneObject, context="fake") register(FakeItem, FakeSceneObject, context="fake") item = FakeItem() - sceneobject = SceneObject(item, context="fake") + sceneobject = sceneobject_factory(item=item, context="fake") assert isinstance(sceneobject, FakeSceneObject) item = FakeSubItem() - sceneobject = SceneObject(item, context="fake") + sceneobject = sceneobject_factory(item=item, context="fake") assert isinstance(sceneobject, FakeSubSceneObject) def test_sceneobject_auto_context_discovery(mocker): register_fake_context() item = FakeItem() - sceneobject = SceneObject(item) + sceneobject = sceneobject_factory(item=item) assert isinstance(sceneobject, FakeSceneObject) def test_sceneobject_auto_context_discovery_no_context(mocker): - mocker.patch("compas.scene.context.compas.is_grasshopper", return_value=False) - mocker.patch("compas.scene.context.compas.is_rhino", return_value=False) + mocker.patch("compas.scene.scene.detect_current_context", return_value=False) + mocker.patch("compas.scene.scene.detect_current_context", return_value=False) with pytest.raises(SceneObjectNotRegisteredError): item = FakeSubItem() - _ = SceneObject(item) + _ = sceneobject_factory(item=item) def test_sceneobject_transform(): scene = Scene() @@ -114,3 +118,240 @@ def test_scene_clear(): scene.clear(clear_context=False, clear_scene=True) assert len(scene.objects) == 0 + + def test_scene_context_validation(): + # Register the fake context first + register(FakeItem, FakeSceneObject, context="fake") + + scene = Scene(context="fake") + item = FakeItem() + + # This should work since the context matches + sceneobj = scene.add(item, context="fake") + assert isinstance(sceneobj, FakeSceneObject) + + # This should raise an exception since the context doesn't match + with pytest.raises(Exception) as excinfo: + scene.add(item, context="different") + assert "Object context should be the same as scene context" in str(excinfo.value) + + def test_get_sceneobject_cls_none_item(): + with pytest.raises(ValueError) as excinfo: + get_sceneobject_cls(None) + assert "Cannot create a scene object for None" in str(excinfo.value) + + def test_get_sceneobject_cls_auto_registration(): + # Clear the registration + context.ITEM_SCENEOBJECT.clear() + + # This should trigger auto-registration + item = FakeItem() + register(FakeItem, FakeSceneObject, context="fake") + cls = get_sceneobject_cls(item, context="fake") + assert cls == FakeSceneObject + + def test_get_sceneobject_cls_inheritance(): + # Register base class + register(FakeItem, FakeSceneObject, context="fake") + + # Test that subclass uses base class's scene object + item = FakeSubItem() + cls = get_sceneobject_cls(item, context="fake") + assert cls == FakeSceneObject + + def test_get_sceneobject_cls_no_registration(): + # Clear the registration + context.ITEM_SCENEOBJECT.clear() + + # Try to get scene object for unregistered item + item = FakeItem() + with pytest.raises(SceneObjectNotRegisteredError) as excinfo: + get_sceneobject_cls(item, context="fake") + assert "No scene object is registered for this data type" in str(excinfo.value) + + def test_scene_representation(): + # Create a scene with a hierarchy of objects + scene = Scene(context="fake") + register(FakeItem, FakeSceneObject, context="fake") + + # Create items and add them to the scene + root_item = FakeItem() + child1_item = FakeItem() + child2_item = FakeItem() + grandchild1_item = FakeItem() + grandchild2_item = FakeItem() + + # Add objects to create a hierarchy + root = scene.add(root_item) + child1 = scene.add(child1_item, parent=root) + scene.add(child2_item, parent=root) + scene.add(grandchild1_item, parent=child1) + scene.add(grandchild2_item, parent=child1) + + # Get the string representation + scene_repr = str(scene) + + # The representation should show the hierarchy with scene objects + expected_lines = [ + "└── Scene", + " └── ", + " ├── ", + " │ ├── ", + " │ └── ", + " └── ", + ] + + # Compare each line + for expected, actual in zip(expected_lines, scene_repr.split("\n")): + assert expected == actual + + def test_scene_initialization(mocker): + # Mock context detection at the correct import path + mocker.patch("compas.scene.scene.detect_current_context", return_value="fake") + + # Test default initialization + scene = Scene() + assert scene.context == "fake" + assert scene.datastore == {} + assert scene.objectstore == {} + assert scene.tree is not None + assert scene.tree.root is not None + assert scene.tree.root.name == scene.name + + # Test initialization with custom parameters + custom_tree = Tree() + custom_datastore = {"test": "data"} + custom_objectstore = {"test": "object"} + scene = Scene(context="test", tree=custom_tree, datastore=custom_datastore, objectstore=custom_objectstore) + assert scene.context == "test" + assert scene.tree == custom_tree + assert scene.datastore == custom_datastore + assert scene.objectstore == custom_objectstore + + def test_scene_data(): + scene = Scene() + data = scene.__data__ + assert "name" in data + assert "attributes" in data + assert "datastore" in data + assert "objectstore" in data + assert "tree" in data + + def test_scene_items(): + scene = Scene(context="fake") + register(FakeItem, FakeSceneObject, context="fake") + + item1 = FakeItem() + item2 = FakeItem() + scene.add(item1) + scene.add(item2) + + items = scene.items + assert len(items) == 2 + assert item1 in items + assert item2 in items + + def test_scene_context_objects(mocker): + mocker.patch("compas.scene.scene.detect_current_context", return_value="fake") + scene = Scene(context="fake") + register(FakeItem, FakeSceneObject, context="fake") + + item = FakeItem() + sceneobj = scene.add(item) + + # Mock the _guids attribute to return test guids + sceneobj._guids = ["guid1", "guid2"] + + context_objects = scene.context_objects + assert len(context_objects) == 2 + assert "guid1" in context_objects + assert "guid2" in context_objects + + def test_scene_find_by_name(): + scene = Scene(context="fake") + register(FakeItem, FakeSceneObject, context="fake") + + item = FakeItem() + sceneobj = scene.add(item) + sceneobj.name = "test_object" + + found = scene.find_by_name("test_object") + assert found == sceneobj + + not_found = scene.find_by_name("nonexistent") + assert not_found is None + + def test_scene_find_by_itemtype(mocker): + mocker.patch("compas.scene.scene.detect_current_context", return_value="fake") + scene = Scene(context="fake") + register(FakeItem, FakeSceneObject, context="fake") + register(FakeSubItem, FakeSubSceneObject, context="fake") + + # Create items and add them to the scene + item1 = FakeItem() + item2 = FakeSubItem() + scene.add(item1) + scene.add(item2) + + # Find objects by type + found = scene.find_by_itemtype(FakeItem) + assert found is not None + assert found._item == str(item1.guid) + + found = scene.find_by_itemtype(FakeSubItem) + assert found is not None + assert found._item == str(item2.guid) + + not_found = scene.find_by_itemtype(str) # type that doesn't exist in scene + assert not_found is None + + def test_scene_find_all_by_itemtype(mocker): + mocker.patch("compas.scene.scene.detect_current_context", return_value="fake") + scene = Scene(context="fake") + register(FakeItem, FakeSceneObject, context="fake") + register(FakeSubItem, FakeSubSceneObject, context="fake") + + # Create items and add them to the scene + item1 = FakeItem() + item2 = FakeSubItem() + item3 = FakeItem() + scene.add(item1) + scene.add(item2) + scene.add(item3) + + # Find all objects by type + found = scene.find_all_by_itemtype(FakeItem) + assert len(found) == 3 + assert all(obj._item in [str(item1.guid), str(item2.guid), str(item3.guid)] for obj in found) + + found = scene.find_all_by_itemtype(FakeSubItem) + assert len(found) == 1 + assert all(obj._item == str(item2.guid) for obj in found) + + not_found = scene.find_all_by_itemtype(str) # type that doesn't exist in scene + assert len(not_found) == 0 + + def test_scene_get_sceneobject_node(): + scene = Scene(context="fake") + register(FakeItem, FakeSceneObject, context="fake") + + item = FakeItem() + sceneobj = scene.add(item) + + # Test successful case + node = scene.get_sceneobject_node(sceneobj) + assert node is not None + assert node.name == str(sceneobj.guid) + + # Test TypeError for non-SceneObject + with pytest.raises(TypeError) as excinfo: + scene.get_sceneobject_node("not a scene object") + assert "SceneObject expected" in str(excinfo.value) + + # Test ValueError for SceneObject from different scene + other_scene = Scene(context="fake") + other_item = FakeItem() + other_sceneobj = other_scene.add(other_item) + with pytest.raises(ValueError) as excinfo: + scene.get_sceneobject_node(other_sceneobj) + assert "SceneObject not part of this scene" in str(excinfo.value)