From 4cd58ca03b785b04fe08e0d35a268f7129aa4dbd Mon Sep 17 00:00:00 2001 From: tomvanmele Date: Sat, 7 Nov 2026 16:54:55 +0100 Subject: [PATCH 01/10] Redesign scene serialization around durable nodes --- docs/getting-started/basics.visualisation.md | 31 +- src/compas/scene/__init__.py | 8 + src/compas/scene/_scenenode.py | 151 +++- src/compas/scene/exceptions.py | 13 + src/compas/scene/geometryobject.py | 12 + src/compas/scene/graphobject.py | 12 +- src/compas/scene/meshobject.py | 14 +- src/compas/scene/scene.py | 645 ++++++++++-------- src/compas/scene/sceneobject.py | 189 ++++- src/compas/scene/volmeshobject.py | 16 +- .../compas/scene/test_scene_serialisation.py | 91 ++- 11 files changed, 877 insertions(+), 305 deletions(-) diff --git a/docs/getting-started/basics.visualisation.md b/docs/getting-started/basics.visualisation.md index 66618ac40df..f78aa3bb621 100644 --- a/docs/getting-started/basics.visualisation.md +++ b/docs/getting-started/basics.visualisation.md @@ -75,10 +75,10 @@ See the section about mesh visualisation for more information. ## Scene Hierarchy -Scene objects are organised in a hierarchy under a private root group. -The scene stores data and scene objects by GUID, while a COMPAS tree of -lightweight internal nodes records the hierarchy. Public hierarchy properties -on scene objects resolve these internal references. +Scene objects are organised in a hierarchy under a root group. The durable +hierarchy consists of serializable [`SceneNode`][compas.scene.SceneNode] +instances. Runtime [`SceneObject`][compas.scene.SceneObject] adapters connect +those nodes to a visualisation context. ```pycon >>> scene = Scene() @@ -96,6 +96,29 @@ True True ``` +## Saving Scenes and Data + +A serialized scene contains its hierarchy, presentation state, and references +to data objects by GUID. It does not embed or own those data objects. Serialize +the data separately when it should outlive the current Python process. + +After loading a scene, its nodes can be inspected immediately. Bind the +independently loaded data before requesting runtime adapters or drawing. + +```pycon +>>> description = scene.to_jsonstring() +>>> loaded_scene = Scene.from_jsonstring(description) +>>> loaded_scene.find_node_by_name(sceneobj.name).item_guid == box.guid +True +>>> loaded_scene.bind(box) +>>> loaded_scene.find_by_name(sceneobj.name).item is box +True +``` + +Use [`Scene.bind_many`][compas.scene.Scene.bind_many] when restoring several +referenced data objects at once. Bindings are runtime references: binding does +not copy the object or add it to the serialized scene description. + To use a different scene object as the parent, pass it to `Scene.add`. ```pycon diff --git a/src/compas/scene/__init__.py b/src/compas/scene/__init__.py index 7fdcb3299b9..6c9b6e14174 100644 --- a/src/compas/scene/__init__.py +++ b/src/compas/scene/__init__.py @@ -5,8 +5,12 @@ """ +from ._scenenode import SceneNode +from .exceptions import SceneItemNotBoundError from .exceptions import SceneObjectNotRegisteredError +from .exceptions import SceneStateError from .sceneobject import SceneObject +from .sceneobject import SceneStateField from .meshobject import MeshObject from .graphobject import GraphObject from .geometryobject import GeometryObject @@ -40,7 +44,11 @@ def register_scene_objects_base() -> None: __all__ = [ "SceneObjectNotRegisteredError", + "SceneItemNotBoundError", + "SceneStateError", + "SceneNode", "SceneObject", + "SceneStateField", "MeshObject", "GraphObject", "GeometryObject", diff --git a/src/compas/scene/_scenenode.py b/src/compas/scene/_scenenode.py index 4bf144926fd..e10651510a0 100644 --- a/src/compas/scene/_scenenode.py +++ b/src/compas/scene/_scenenode.py @@ -1,11 +1,154 @@ +from collections.abc import Mapping +from typing import Any +from typing import Literal +from typing import Self from uuid import UUID +from compas.colors import Color from compas.datastructures import TreeNode +from compas.geometry import Transformation class SceneNode(TreeNode["SceneNode"]): - """Private hierarchy node referencing a scene object by GUID.""" + """Serializable state of one entry in a scene hierarchy. - def __init__(self, object_guid: UUID) -> None: - super().__init__() - self.object_guid = object_guid + A scene node owns durable presentation state and an optional reference to a + separately serialized data item. Runtime scene-object adapters bind to a + node, but are not themselves serialized. + + Parameters + ---------- + kind + The node kind. + item_guid + The GUID of the referenced data item. + name + The node name. + transformation + The local transformation relative to the parent node. + color + The base display color. + opacity + The display opacity. + show + Whether the node is visible. + extensions + Namespaced durable state declared by scene-object adapter classes. + guid + The durable node GUID. A new GUID is generated when omitted. + + """ + + _ALLOWED_DATA_KEYS = { + "guid", + "kind", + "item", + "name", + "transformation", + "color", + "opacity", + "show", + "extensions", + "children", + } + + @property + def __data__(self) -> dict[str, Any]: + """Return the serializable node state.""" + data: dict[str, Any] = { + "guid": str(self.guid), + "kind": self.kind, + "name": self.name, + "opacity": self.opacity, + "show": self.show, + "extensions": self.extensions, + "children": [child.__data__ for child in self.children], + } + if self.item_guid is not None: + data["item"] = str(self.item_guid) + if self.transformation is not None: + data["transformation"] = self.transformation + if self.color is not None: + data["color"] = self.color + return data + + @classmethod + def __from_data__(cls, data: dict[str, Any]) -> Self: + """Construct a node and its descendants from serialized state.""" + unknown = set(data) - cls._ALLOWED_DATA_KEYS + if unknown: + raise ValueError("Unknown scene-node fields: {}.".format(", ".join(sorted(unknown)))) + + required = {"guid", "kind", "name", "opacity", "show", "extensions", "children"} + missing = required - set(data) + if missing: + raise ValueError("Missing scene-node fields: {}.".format(", ".join(sorted(missing)))) + + kind = data["kind"] + if kind not in ("group", "item"): + raise ValueError("Scene-node kind should be 'group' or 'item'.") + item_guid = UUID(data["item"]) if "item" in data else None + if kind == "item" and item_guid is None: + raise ValueError("An item scene node requires an item GUID.") + if kind == "group" and item_guid is not None: + raise ValueError("A group scene node cannot reference an item.") + + extensions = data["extensions"] + if not isinstance(extensions, dict) or not all(isinstance(namespace, str) and isinstance(values, dict) for namespace, values in extensions.items()): + raise TypeError("Scene-node extensions should map namespace strings to dictionaries.") + + node = cls( + kind=kind, + item_guid=item_guid, + name=data["name"], + transformation=data.get("transformation"), + color=data.get("color"), + opacity=data["opacity"], + show=data["show"], + extensions=extensions, + guid=UUID(data["guid"]), + ) + children = data["children"] + if not isinstance(children, list): + raise TypeError("Scene-node children should be a list.") + for child_data in children: + node.add(cls.__from_data__(child_data)) + return node + + def __init__( + self, + *, + kind: Literal["group", "item"], + item_guid: UUID | None = None, + name: str, + transformation: Transformation | None = None, + color: Color | None = None, + opacity: float = 1.0, + show: bool = True, + extensions: Mapping[str, Mapping[str, object]] | None = None, + guid: UUID | None = None, + ) -> None: + super().__init__(name=name) + if kind == "item" and item_guid is None: + raise ValueError("An item scene node requires an item GUID.") + if kind == "group" and item_guid is not None: + raise ValueError("A group scene node cannot reference an item.") + self.kind = kind + self.item_guid = item_guid + self.transformation = transformation + self.color = color + self.opacity = opacity + self.show = show + self.extensions = {namespace: dict(values) for namespace, values in (extensions or {}).items()} + if guid is not None: + self._guid = guid + + @property + def object_guid(self) -> UUID: + """Return the durable node GUID. + + This alias keeps tree consumers readable while runtime scene objects + transition from owning identity to exposing their node's identity. + + """ + return self.guid diff --git a/src/compas/scene/exceptions.py b/src/compas/scene/exceptions.py index 4851a67fe14..8a7633594c1 100644 --- a/src/compas/scene/exceptions.py +++ b/src/compas/scene/exceptions.py @@ -1,2 +1,15 @@ class SceneObjectNotRegisteredError(Exception): """Exception that is raised when no scene object is registered for a given data type.""" + + +class SceneItemNotBoundError(LookupError): + """Raised when an operation requires an unresolved scene item reference.""" + + def __init__(self, node_guid: object, item_guid: object) -> None: + super().__init__(f"Scene node {node_guid} requires unbound item {item_guid}.") + self.node_guid = node_guid + self.item_guid = item_guid + + +class SceneStateError(ValueError): + """Raised when declared durable scene state is invalid.""" diff --git a/src/compas/scene/geometryobject.py b/src/compas/scene/geometryobject.py index 416e9019950..4bf9c609777 100644 --- a/src/compas/scene/geometryobject.py +++ b/src/compas/scene/geometryobject.py @@ -6,6 +6,7 @@ from .descriptors.color import ColorAttribute from .sceneobject import SceneObject +from .sceneobject import SceneStateField class GeometryObject(SceneObject): @@ -14,6 +15,17 @@ class GeometryObject(SceneObject): pointcolor = ColorAttribute() linecolor = ColorAttribute() surfacecolor = ColorAttribute() + __scene_state_namespace__ = "compas.scene.GeometryObject" + __scene_state_fields__ = ( + SceneStateField("pointcolor", (Color, type(None))), + SceneStateField("linecolor", (Color, type(None))), + SceneStateField("surfacecolor", (Color, type(None))), + SceneStateField("pointsize", (int, float)), + SceneStateField("linewidth", (int, float)), + SceneStateField("show_points", bool), + SceneStateField("show_lines", bool), + SceneStateField("show_surfaces", bool), + ) # ========================================================================== # Dunder methods diff --git a/src/compas/scene/graphobject.py b/src/compas/scene/graphobject.py index 4937a2b95a8..347a33b3784 100644 --- a/src/compas/scene/graphobject.py +++ b/src/compas/scene/graphobject.py @@ -11,6 +11,7 @@ from .descriptors.colordict import ColorDictAttribute from .sceneobject import SceneObject +from .sceneobject import SceneStateField class GraphObject(SceneObject): @@ -25,6 +26,15 @@ class GraphObject(SceneObject): nodecolor = ColorDictAttribute() edgecolor = ColorDictAttribute() + __scene_state_namespace__ = "compas.scene.GraphObject" + __scene_state_fields__ = ( + SceneStateField("show_nodes", (bool, list)), + SceneStateField("show_edges", (bool, list)), + SceneStateField("nodecolor"), + SceneStateField("edgecolor"), + SceneStateField("nodesize", (int, float)), + SceneStateField("edgewidth", (int, float)), + ) # ========================================================================== # Dunder methods @@ -83,7 +93,7 @@ def graph(self, graph: Graph) -> None: self._detached_item = graph else: self.scene.replace_item(self, graph) - self._transformation = None + self.transformation = None self._node_xyz = None self._node_xyz_revision = None diff --git a/src/compas/scene/meshobject.py b/src/compas/scene/meshobject.py index c5595791ea3..912eb89df3f 100644 --- a/src/compas/scene/meshobject.py +++ b/src/compas/scene/meshobject.py @@ -8,6 +8,7 @@ from .descriptors.colordict import ColorDictAttribute from .sceneobject import SceneObject +from .sceneobject import SceneStateField class MeshObject(SceneObject): @@ -23,6 +24,17 @@ class MeshObject(SceneObject): vertexcolor = ColorDictAttribute() edgecolor = ColorDictAttribute() facecolor = ColorDictAttribute() + __scene_state_namespace__ = "compas.scene.MeshObject" + __scene_state_fields__ = ( + SceneStateField("show_vertices", (bool, list)), + SceneStateField("show_edges", (bool, list)), + SceneStateField("show_faces", (bool, list)), + SceneStateField("vertexcolor"), + SceneStateField("edgecolor"), + SceneStateField("facecolor"), + SceneStateField("vertexsize", (int, float)), + SceneStateField("edgewidth", (int, float)), + ) # ========================================================================== # Dunder methods @@ -86,7 +98,7 @@ def mesh(self, mesh: Mesh) -> None: self._detached_item = mesh else: self.scene.replace_item(self, mesh) - self._transformation = None + self.transformation = None # ========================================================================== # Methods diff --git a/src/compas/scene/scene.py b/src/compas/scene/scene.py index 9621339c11d..426aebfa29c 100644 --- a/src/compas/scene/scene.py +++ b/src/compas/scene/scene.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable from collections.abc import Iterator from collections.abc import Mapping from types import MappingProxyType @@ -10,14 +11,18 @@ import compas.data # noqa: F401 import compas.datastructures # noqa: F401 import compas.geometry # noqa: F401 +from compas.colors import Color from compas.data import Data from compas.datastructures import Tree +from compas.geometry import Transformation +from . import sceneobject as sceneobject_module from ._scenenode import SceneNode from .context import after_draw from .context import before_draw from .context import clear from .context import detect_current_context +from .exceptions import SceneItemNotBoundError from .group import Group from .sceneobject import SceneObject @@ -26,85 +31,89 @@ class Scene(Data): - """A hierarchical collection of objects to visualize in a context. + """A backend-neutral visual description with runtime item bindings. + + Scene serialization contains durable node state and item GUID references, + but never embeds the referenced data objects. Load data independently and + bind it explicitly before adapter or drawing operations that need it. Parameters ---------- name The name of the scene. context - The visualization context. If omitted, COMPAS selects the only + The runtime visualization context. If omitted, COMPAS selects the only registered named context when one is available. Examples -------- >>> from compas.scene import Scene >>> from compas.geometry import Box + >>> box = Box(1) >>> scene = Scene() - >>> box = Box.from_width_height_depth(1, 1, 1) - >>> boxobj = scene.add(box) - >>> scene.draw() # doctest: +SKIP + >>> boxobject = scene.add(box) + >>> boxobject.item is box + True """ + SCHEMA_VERSION = 1 + # ========================================================================== # Data # ========================================================================== @property def __data__(self) -> dict[str, Any]: - def node_data(node: SceneNode) -> dict[str, Any]: - sceneobject = self._objects[node.object_guid] - data = { - "settings": sceneobject.settings, - "children": [node_data(child) for child in node.children], - } - if sceneobject.item_guid is not None: - data["item"] = str(sceneobject.item_guid) - return data - - root = self.tree.root - if root is None: - raise RuntimeError("The scene tree has no root node.") - item_guids = {sceneobject.item_guid for sceneobject in self.objects if sceneobject.item_guid is not None} + """Return the reference-only scene description.""" + self._sync_adapters() return { + "version": self.SCHEMA_VERSION, "name": self.name, - "root": node_data(root), - "items": [item for guid, item in self._items.items() if guid in item_guids], + "root": self.root_node.__data__, } @classmethod def __from_data__(cls, data: dict[str, Any]) -> Self: - 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 = scene.add(items[guid], parent=parent, **settings) - else: - sceneobject = scene.add_group(parent=parent, **settings) - add(child_node, sceneobject, items) - - add(data["root"], scene.root, items) - + """Construct an unresolved scene description without binding adapters.""" + expected = {"version", "name", "root"} + unknown = set(data) - expected + if unknown: + raise ValueError("Unknown scene fields: {}.".format(", ".join(sorted(unknown)))) + missing = expected - set(data) + if missing: + raise ValueError("Missing scene fields: {}.".format(", ".join(sorted(missing)))) + if data["version"] != cls.SCHEMA_VERSION: + raise ValueError(f"Unsupported scene schema version: {data['version']}.") + + root = SceneNode.__from_data__(data["root"]) + if root.kind != "group": + raise ValueError("The scene root should be a group node.") + + scene = cls(data["name"], context=None, _detect_context=False, _root=root) return scene # ========================================================================== # Dunder methods # ========================================================================== - def __init__(self, name: str = "Scene", context: str | None = None) -> None: + def __init__( + self, + name: str = "Scene", + context: str | None = None, + *, + _detect_context: bool = True, + _root: SceneNode | None = None, + ) -> None: super().__init__(name=name) - self.context = context or detect_current_context() + self.context = context if context is not None or not _detect_context else detect_current_context() self._items: dict[UUID, Data] = {} self._objects: dict[UUID, SceneObject] = {} self._nodes: dict[UUID, SceneNode] = {} self.tree: Tree[SceneNode] = Tree(name=name) - root = Group(name="ROOT", context=self.context) - self._attach(root, parent=None) + root = _root or SceneNode(kind="group", name="ROOT") + self.tree.add(root) + self._index_nodes() def __str__(self) -> str: return str(self.tree) @@ -113,40 +122,58 @@ def __str__(self) -> str: # Properties # ========================================================================== + @property + def root_node(self) -> SceneNode: + """The durable root node of the scene hierarchy.""" + root = self.tree.root + if root is None: + raise RuntimeError("The scene tree has no root node.") + return root + @property def root(self) -> Group: - """The root group of the scene tree.""" - node = self.tree.root - if node is None: - raise RuntimeError("The scene tree has no root group.") - return cast(Group, self._objects[node.object_guid]) + """The runtime group adapter bound to the root node.""" + return cast(Group, self.object(self.root_node, object_type=Group)) @property - def nodes(self) -> Iterator[SceneObject]: - """An iterator over all nodes in the scene tree, including the root.""" - return (self._objects[node.object_guid] for node in self.tree.nodes) + def nodes(self) -> Iterator[SceneNode]: + """An iterator over durable scene nodes, including the root.""" + return iter(self.tree.nodes) @property def objects(self) -> list[SceneObject]: - """All scene objects except the private root group.""" - return [self._objects[node.object_guid] for node in self.tree.nodes if not node.is_root] + """Runtime adapters for all non-root scene nodes. + + Raises + ------ + SceneItemNotBoundError + If a node references data that has not been bound. + + """ + return [self.object(node) for node in self.tree.nodes if not node.is_root] @property def item_store(self) -> Mapping[UUID, Data]: - """The read-only GUID-to-data mapping owned by the scene.""" + """The read-only runtime item-binding table. + + Bindings are non-owning references and are not serialized with the + scene description. + + """ return MappingProxyType(self._items) @property def object_store(self) -> Mapping[UUID, SceneObject]: - """The read-only GUID-to-scene-object mapping owned by the scene.""" + """The read-only mapping of nodes to currently bound runtime adapters.""" return MappingProxyType(self._objects) @property def context_objects(self) -> list[object]: - """The identifiers of all objects drawn in the visualization context.""" + """The identifiers of objects already drawn in the runtime context.""" guids: list[object] = [] - for obj in self.objects: - guids += obj.guids + for sceneobject in self._objects.values(): + if sceneobject.node is not None and not sceneobject.node.is_root: + guids += sceneobject.guids return guids # ========================================================================== @@ -155,21 +182,7 @@ def context_objects(self) -> list[object]: @classmethod def from_gltf(cls, document: "GLTFDocument", scene_key: int | None = None) -> Self: - """Construct a COMPAS scene from a glTF document. - - Parameters - ---------- - document - Source glTF document. - scene_key - Scene to convert. By default, use the document's default or first scene. - - Returns - ------- - Scene - Converted scene. - - """ + """Construct a scene from a glTF document.""" from compas.files.gltf.gltf_conversions import gltf_to_scene return gltf_to_scene(document, scene_key, scene_type=cls) @@ -194,297 +207,361 @@ def to_gltf(self) -> "GLTFDocument": return scene_to_gltf(self) # ========================================================================== - # Methods + # Item bindings and runtime adapters # ========================================================================== - def add( - self, - item: Data | SceneObject, - parent: SceneObject | None = None, - **kwargs: Any, - ) -> SceneObject: - """Add an item to the scene. + def bind(self, item: Data) -> None: + """Bind an independently loaded data item to its referenced GUID. + + Binding the same instance repeatedly is idempotent. A different object + carrying an already bound GUID is rejected. Parameters ---------- item - The item to add. - parent - The parent scene object. The root group is used by default. - **kwargs - Additional keyword arguments to create the scene object for the item. + The independently loaded data item. + + Raises + ------ + KeyError + If no scene node references the item's GUID. + ValueError + If another object is already bound to the GUID. + + """ + if item.guid not in self._referenced_item_guids(): + raise KeyError(f"Scene does not reference item {item.guid}.") + existing = self._items.get(item.guid) + if existing is not None and existing is not item: + raise ValueError(f"A different data item with GUID {item.guid} is already bound.") + self._items[item.guid] = item + + def bind_many(self, items: Iterable[Data]) -> None: + """Bind multiple data items atomically. + + Parameters + ---------- + items + Independently loaded data items. + + """ + pending = list(items) + references = self._referenced_item_guids() + staged: dict[UUID, Data] = {} + for item in pending: + if item.guid not in references: + raise KeyError(f"Scene does not reference item {item.guid}.") + existing = self._items.get(item.guid) or staged.get(item.guid) + if existing is not None and existing is not item: + raise ValueError(f"A different data item with GUID {item.guid} is already bound.") + staged[item.guid] = item + self._items.update(staged) + + def bind_context(self, context: str | None = None) -> None: + """Bind runtime adapters after the complete scene hierarchy exists. + + Parameters + ---------- + context + The visualization context. `None` uses context-independent core + adapters. + + Raises + ------ + SceneItemNotBoundError + If any item node remains unresolved. + + """ + for node in self.tree.nodes: + if node.item_guid is not None and node.item_guid not in self._items: + raise SceneItemNotBoundError(node.guid, node.item_guid) + self._sync_adapters() + for sceneobject in self._objects.values(): + sceneobject._scene = None + sceneobject._node = None + self._objects.clear() + self.context = context + for node in self.tree.nodes: + self.object(node) + + def item(self, guid: UUID, *, node_guid: UUID | None = None) -> Data: + """Return a bound data item. + + Parameters + ---------- + guid + The referenced item GUID. + node_guid + The node requesting the item, used in a precise error message. Returns ------- - SceneObject - The scene object associated with the item. + Data + The bound data item. + + Raises + ------ + SceneItemNotBoundError + If the item has not been bound. """ + try: + return self._items[guid] + except KeyError: + raise SceneItemNotBoundError(node_guid or "unknown", guid) from None + + def object(self, node: SceneNode | UUID, *, object_type: type[SceneObject] | None = None) -> SceneObject: + """Return or create the runtime adapter for a durable node.""" + if isinstance(node, UUID): + node = self._nodes[node] + existing = self._objects.get(node.guid) + if existing is not None: + return existing + + if node.kind == "group": + sceneobject = Group( + name=node.name, + color=node.color, + opacity=node.opacity, + show=node.show, + transformation=node.transformation, + context=self.context, + ) + else: + if node.item_guid is None: + raise RuntimeError("An item node has no item GUID.") + item = self.item(node.item_guid, node_guid=node.guid) + sceneobject = SceneObject( + item=item, + context=self.context, + sceneobject_type=object_type, + name=node.name, + color=node.color, + opacity=node.opacity, + show=node.show, + transformation=node.transformation, + ) + sceneobject._bind(self, node) + self._objects[node.guid] = sceneobject + return sceneobject - parent = parent or self.root + # ========================================================================== + # Hierarchy mutation + # ========================================================================== - if parent.scene is not self: - raise ValueError("The parent scene object does not belong to this scene.") + def add( + self, + item: Data | SceneObject, + parent: SceneObject | SceneNode | None = None, + **kwargs: Any, + ) -> SceneObject: + """Add a bound data item or detached runtime adapter to the scene.""" + parent_node = self._parent_node(parent) if isinstance(item, SceneObject): sceneobject = item if sceneobject.scene is not None: raise ValueError("The scene object already belongs to a scene.") + bound_item = sceneobject.item else: - if isinstance(parent, Group): - # Use the parent group's settings as defaults for the child. - group_kwargs = parent.kwargs.copy() + bound_item = item + parent_object = self._objects.get(parent_node.guid) + if isinstance(parent_object, Group): + group_kwargs = parent_object.kwargs.copy() group_kwargs.update(kwargs) kwargs = group_kwargs - 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 + supplied_context = kwargs.pop("context", self.context) + if supplied_context != self.context: + raise ValueError(f"Object context should be the same as scene context: {supplied_context} != {self.context}") sceneobject = SceneObject(item=item, context=self.context, **kwargs) - self._attach(sceneobject, parent=parent) + + kind = "item" if bound_item is not None else "group" + node = SceneNode( + kind=kind, + item_guid=bound_item.guid if bound_item is not None else None, + name=sceneobject.name, + transformation=sceneobject.transformation, + color=sceneobject.color, + opacity=sceneobject.opacity, + show=sceneobject.show, + extensions=sceneobject._capture_extension_state(), + guid=sceneobject.guid, + ) + self._attach_node(node, parent_node) + if bound_item is not None: + try: + self.bind(bound_item) + except Exception: + self.tree.remove(node) + self._nodes.pop(node.guid) + raise + sceneobject._bind(self, node) + self._objects[node.guid] = sceneobject + if hasattr(sceneobject, "_detached_item"): + del sceneobject._detached_item return sceneobject + def add_reference( + self, + item_guid: UUID, + *, + name: str, + parent: SceneObject | SceneNode | None = None, + transformation: Transformation | None = None, + color: Color | None = None, + opacity: float = 1.0, + show: bool = True, + extensions: Mapping[str, Mapping[str, Any]] | None = None, + ) -> SceneNode: + """Add an unresolved item reference to the scene description.""" + node = SceneNode( + kind="item", + item_guid=item_guid, + name=name, + transformation=transformation, + color=color, + opacity=opacity, + show=show, + extensions=extensions, + ) + self._attach_node(node, self._parent_node(parent)) + return node + def add_group( self, name: str, - parent: SceneObject | None = None, + parent: SceneObject | SceneNode | None = None, **kwargs: Any, ) -> Group: - """Add a group to the scene. - - Parameters - ---------- - name - The name of the group. - parent - The parent scene object. The root group is used by default. - **kwargs - Default keyword arguments passed to children of the group. - - Returns - ------- - Group - The added group. - - """ + """Add a group to the scene.""" group = Group(name=name, **kwargs) - self.add(group, parent=parent) - return group - - def remove(self, sceneobject: SceneObject) -> None: - """Remove a scene object and its descendants from the scene. - - Parameters - ---------- - sceneobject - The scene object to remove. - - """ - if sceneobject.scene is not self: - raise ValueError("The scene object does not belong to this scene.") - if sceneobject is self.root: + return cast(Group, self.add(group, parent=parent)) + + def remove(self, sceneobject: SceneObject | SceneNode) -> None: + """Remove a scene node and all descendants.""" + node = sceneobject.node if isinstance(sceneobject, sceneobject_module.SceneObject) else sceneobject + if node is None or node.guid not in self._nodes: + raise ValueError("The scene object or node does not belong to this scene.") + if node.is_root: raise ValueError("The root group cannot be removed.") - node = self._nodes[sceneobject.guid] removed_nodes = list(node.traverse()) self.tree.remove(node) for removed_node in removed_nodes: - removed = self._objects.pop(removed_node.object_guid) - self._nodes.pop(removed.guid) - removed._detached_item = self._items.get(removed.item_guid) if removed.item_guid is not None else None - removed._scene = None + removed_object = self._objects.pop(removed_node.guid, None) + self._nodes.pop(removed_node.guid) + if removed_object is not None: + removed_object._detached_item = self._items.get(removed_node.item_guid) if removed_node.item_guid is not None else None + removed_object._scene = None + removed_object._node = None self._prune_items() def replace_item(self, sceneobject: SceneObject, item: Data) -> None: - """Replace the data item represented by a scene object. - - Parameters - ---------- - sceneobject - The scene object whose represented item should change. - item - The replacement data item. - - """ - if sceneobject.scene is not self: + """Replace the bound data item referenced by a scene node.""" + if sceneobject.scene is not self or sceneobject.node is None: raise ValueError("The scene object does not belong to this scene.") existing = self._items.get(item.guid) if existing is not None and existing is not item: - raise ValueError("A different data item with the same GUID is already stored.") - self._items[item.guid] = item + raise ValueError("A different data item with the same GUID is already bound.") + old_guid = sceneobject.node.item_guid + sceneobject.node.item_guid = item.guid sceneobject._item_guid = item.guid + self._items[item.guid] = item sceneobject.invalidate() - self._prune_items() - - def clear_context(self, guids: list[object] | None = None) -> None: - """Clear the visualization context. - - Parameters - ---------- - guids - The identifiers of the objects in the visualization context. - - Notes - ----- - If `guids` is `None`, clear all objects from the visualization context. + if old_guid is not None: + self._prune_items() - If `guids` is a list, only those objects in the list will be removed. - - `Scene.clear` uses this method to remove objects previously drawn by the - scene without removing unrelated context objects. + # ========================================================================== + # Drawing and queries + # ========================================================================== - """ + def clear_context(self, guids: list[Any] | None = None) -> None: + """Clear objects from the visualization context.""" clear(guids) def clear(self, clear_scene: bool = True, clear_context: bool = True) -> None: - """Clear the scene. - - Parameters - ---------- - clear_scene - If `True`, remove all scene objects from the scene tree. - clear_context - If `True`, remove all objects drawn by the scene from the - visualization context. - - Examples - -------- - Redraw the scene without modifying unrelated context objects. - - >>> scene.clear(clear_scene=False, clear_context=True) # doctest: +SKIP - >>> scene.draw() # doctest: +SKIP - - """ - guids = [] - - for sceneobject in self.objects: - guids += sceneobject.guids + """Clear the scene description and/or its visualization context.""" + guids = self.context_objects + for sceneobject in self._objects.values(): sceneobject._guids = None - if clear_scene: - for sceneobject in tuple(self.root.children): - self.remove(sceneobject) - + for node in tuple(self.root_node.children): + self.remove(node) if clear_context: self.clear_context(guids) - def draw(self) -> list[object]: - """Draw the scene. - - This will just draw all scene objects in the scene tree, - without making any modifications to the visualization context. - For example, it will not remove any of the previously drawn objects. - - Returns - ------- - list[object] - The objects created in the visualization context. - - """ - + def draw(self) -> list[Any]: + """Draw all visible scene nodes in the current context.""" if not self.context: raise ValueError("No context detected.") - + self.bind_context(self.context) before_draw() - - drawn_objects: list[object] = [] + drawn_objects: list[Any] = [] for sceneobject in self.objects: if sceneobject.show: drawn_objects += sceneobject.draw() - after_draw(drawn_objects) - return drawn_objects def redraw(self) -> None: - """Redraw the scene. - - This removes all previously drawn objects from the visualization context, - before drawing all scene objects in the scene tree. - - """ + """Clear and draw the scene in the current context.""" self.clear(clear_scene=False, clear_context=True) self.draw() - def find_by_name(self, name: str) -> SceneObject | None: - """Find the first scene object with the given name. - - Parameters - ---------- - name - Name of the object. - - Returns - ------- - SceneObject | None - The first matching scene object, or `None`. + def find_node_by_name(self, name: str) -> SceneNode | None: + """Find the first durable node with the given name.""" + return next((node for node in self.tree.nodes if not node.is_root and node.name == name), None) - """ - return next((sceneobject for sceneobject in self.objects if sceneobject.name == name), None) + def find_by_name(self, name: str) -> SceneObject | None: + """Find the runtime adapter for the first node with the given name.""" + node = self.find_node_by_name(name) + return self.object(node) if node is not None else None def find_by_itemtype(self, itemtype: type[Data]) -> SceneObject | None: - """Find the first scene object with a data item of the given type. - - Parameters - ---------- - itemtype - The type of the data item associated with the scene object. - - Returns - ------- - SceneObject | None - The first matching scene object, or `None`. - - """ - for obj in self.objects: - if isinstance(obj.item, itemtype): - return obj + """Find the first bound scene object with a data item of the given type.""" + return next((obj for obj in self.objects if isinstance(obj.item, itemtype)), None) def find_all_by_itemtype(self, itemtype: type[Data]) -> list[SceneObject]: - """Find all scene objects with a data item of the given type. - - Parameters - ---------- - itemtype - The type of the data item associated with the scene object. - - Returns - ------- - list[SceneObject] - All matching scene objects. - - """ - sceneobjects: list[SceneObject] = [] - for obj in self.objects: - if isinstance(obj.item, itemtype): - sceneobjects.append(obj) - return sceneobjects + """Find all bound scene objects with data items of the given type.""" + return [obj for obj in self.objects if isinstance(obj.item, itemtype)] # ========================================================================== # Private helpers # ========================================================================== - def _attach(self, sceneobject: SceneObject, parent: SceneObject | None) -> None: - if sceneobject.guid in self._objects: - raise ValueError("A scene object with the same GUID is already stored.") - - item = sceneobject.item - if item is not None: - existing = self._items.get(item.guid) - if existing is not None and existing is not item: - raise ValueError("A different data item with the same GUID is already stored.") - self._items[item.guid] = item - - node = SceneNode(sceneobject.guid) - parent_node = self._nodes[parent.guid] if parent else None - self.tree.add(node, parent=parent_node) - self._objects[sceneobject.guid] = sceneobject - self._nodes[sceneobject.guid] = node - sceneobject._scene = self - if hasattr(sceneobject, "_detached_item"): - del sceneobject._detached_item + def _attach_node(self, node: SceneNode, parent: SceneNode) -> None: + if node.guid in self._nodes: + raise ValueError(f"A scene node with GUID {node.guid} is already stored.") + self.tree.add(node, parent=parent) + self._nodes[node.guid] = node + + def _index_nodes(self) -> None: + indexed: dict[UUID, SceneNode] = {} + for node in self.tree.nodes: + if node.guid in indexed: + raise ValueError(f"Duplicate scene-node GUID: {node.guid}.") + indexed[node.guid] = node + self._nodes = indexed + + def _parent_node(self, parent: SceneObject | SceneNode | None) -> SceneNode: + if parent is None: + return self.root_node + if isinstance(parent, SceneObject): + if parent.scene is not self or parent.node is None: + raise ValueError("The parent scene object does not belong to this scene.") + return parent.node + if parent.guid not in self._nodes: + raise ValueError("The parent scene node does not belong to this scene.") + return parent def _prune_items(self) -> None: - referenced = {sceneobject.item_guid for sceneobject in self._objects.values()} + referenced = self._referenced_item_guids() for guid in set(self._items) - referenced: del self._items[guid] + + def _referenced_item_guids(self) -> set[UUID]: + return {node.item_guid for node in self.tree.nodes if node.item_guid is not None} + + def _sync_adapters(self) -> None: + for sceneobject in self._objects.values(): + sceneobject._sync_node_state() diff --git a/src/compas/scene/sceneobject.py b/src/compas/scene/sceneobject.py index b2ab4fd4ca7..3e6fd789ebe 100644 --- a/src/compas/scene/sceneobject.py +++ b/src/compas/scene/sceneobject.py @@ -1,8 +1,10 @@ from collections.abc import Iterator +from dataclasses import dataclass from functools import reduce from operator import mul from typing import TYPE_CHECKING from typing import Any +from typing import ClassVar from typing import Literal from typing import NoReturn from typing import Self @@ -17,6 +19,7 @@ from .context import clear from .context import get_sceneobject_cls from .descriptors.color import ColorAttribute +from .exceptions import SceneStateError if TYPE_CHECKING: from compas.datastructures import Tree @@ -25,6 +28,23 @@ from ._scenenode import SceneNode +@dataclass(frozen=True, slots=True) +class SceneStateField: + """Declaration of one durable adapter extension field. + + Parameters + ---------- + name + Stable serialized field name and adapter attribute name. + value_type + Runtime type or tuple of runtime types accepted for the field. + + """ + + name: str + value_type: type[object] | tuple[type[object], ...] = object + + class SceneObject(Data): """Base class for all scene objects. @@ -58,6 +78,8 @@ class SceneObject(Data): """ color = ColorAttribute() + __scene_state_namespace__: ClassVar[str | None] = None + __scene_state_fields__: ClassVar[tuple[SceneStateField, ...]] = () # ========================================================================== # Data @@ -102,12 +124,14 @@ def __init__( context: str | None = None, **kwargs: object, ) -> None: + self._node: SceneNode | None = 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))) # This factory-only option has already been handled by __new__. kwargs.pop("sceneobject_type", None) name = name or getattr(item, "name", None) + self._node: SceneNode | None = None super().__init__(name=name) # the scene object needs to store the context # because it has no access to the tree and/or the scene before it is added @@ -122,8 +146,8 @@ def __init__( self._transformation_revision = 0 self._contrastcolor = None self.color = color or self.color - self.opacity = opacity - self.show = show + self._opacity = opacity + self._show = show def __repr__(self) -> str: return "<{}: {}>".format(self.__class__.__name__, self.name) @@ -132,6 +156,32 @@ def __repr__(self) -> str: # Properties # ========================================================================== + @property + def guid(self) -> UUID: + """Return the durable GUID of the bound scene node. + + A detached adapter has its own temporary data-object GUID. Once bound, + this property exposes the node identity rather than an adapter identity. + + """ + if self._node is not None: + return self._node.guid + return super().guid + + @property + def name(self) -> str: + """Return the scene-node name.""" + if self._node is not None: + return self._node.name + return super().name + + @name.setter + def name(self, name: str) -> None: + if self._node is not None: + self._node.name = name + else: + self._name = name + @property def scene(self) -> "Scene | None": """The scene containing this object, if it has been added to one.""" @@ -144,30 +194,41 @@ def item(self) -> Data | None: return None if self.scene is None: return getattr(self, "_detached_item", None) - return self.scene.item_store[self._item_guid] + return self.scene.item(self._item_guid, node_guid=self.guid) @property def item_guid(self) -> UUID | None: """The GUID of the represented data item.""" + if self._node is not None: + return self._node.item_guid return self._item_guid + @property + def node(self) -> "SceneNode | None": + """The durable scene node bound to this adapter.""" + return self._node + @property def parent(self) -> "SceneObject | None": """The parent scene object.""" if self.scene is None: return None - node = self.scene._nodes[self.guid] + node = self._node + if node is None: + return None if node.parent is None: return None - return self.scene._objects[node.parent.object_guid] + return self.scene.object(node.parent) @property def children(self) -> tuple["SceneObject", ...]: """The child scene objects.""" if self.scene is None: return () - node = self.scene._nodes[self.guid] - return tuple(self.scene._objects[child.object_guid] for child in node.children) + node = self._node + if node is None: + return () + return tuple(self.scene.object(child) for child in node.children) @property def tree(self) -> "Tree[SceneNode] | None": @@ -232,14 +293,47 @@ def transformation(self) -> Transformation | None: on this object. Descendant caches detect the change lazily. """ + if self._node is not None: + return self._node.transformation return self._transformation @transformation.setter def transformation(self, transformation: Transformation | None) -> None: - self._transformation = transformation + if self._node is not None: + self._node.transformation = transformation + else: + self._transformation = transformation self._transformation_revision += 1 self.invalidate() + @property + def opacity(self) -> float: + """The display opacity of the scene node.""" + if self._node is not None: + return self._node.opacity + return self._opacity + + @opacity.setter + def opacity(self, opacity: float) -> None: + if self._node is not None: + self._node.opacity = opacity + else: + self._opacity = opacity + + @property + def show(self) -> bool: + """Whether the scene node is visible.""" + if self._node is not None: + return self._node.show + return self._show + + @show.setter + def show(self, show: bool) -> None: + if self._node is not None: + self._node.show = show + else: + self._show = show + @property def worldtransformation(self) -> Transformation: """The transformation from local coordinates to world coordinates. @@ -304,6 +398,37 @@ def settings(self) -> dict[str, object]: return settings + @classmethod + def scene_state_fields(cls) -> dict[str, tuple[SceneStateField, ...]]: + """Return durable extension declarations composed across the MRO. + + Returns + ------- + dict[str, tuple[SceneStateField, ...]] + Fields grouped by their stable owning namespace. + + Raises + ------ + SceneStateError + If declarations have no namespace or contain conflicting names. + + """ + declarations: dict[str, tuple[SceneStateField, ...]] = {} + for owner in reversed(cls.mro()): + fields = owner.__dict__.get("__scene_state_fields__", ()) + if not fields: + continue + namespace = owner.__dict__.get("__scene_state_namespace__") + if not namespace: + raise SceneStateError(f"{owner.__name__} declares scene state without a namespace.") + if namespace in declarations: + raise SceneStateError(f"Duplicate scene-state namespace: {namespace}.") + names = [field.name for field in fields] + if len(names) != len(set(names)): + raise SceneStateError(f"Duplicate scene-state field in namespace {namespace}.") + declarations[namespace] = tuple(fields) + return declarations + # ========================================================================== # Methods # ========================================================================== @@ -378,6 +503,54 @@ def clear(self) -> None: # Private helpers # ========================================================================== + def _bind(self, scene: "Scene", node: "SceneNode") -> None: + """Bind this runtime adapter to a durable scene node.""" + self._scene = scene + self._node = node + self._guid = None + self._item_guid = node.item_guid + self._transformation = node.transformation + self._apply_extension_state() + + def _capture_extension_state(self) -> dict[str, dict[str, object]]: + """Capture declared durable adapter state without inspecting other attributes.""" + state = {namespace: dict(values) for namespace, values in (self._node.extensions if self._node else {}).items()} + for namespace, fields in self.scene_state_fields().items(): + values = state.setdefault(namespace, {}) + for field in fields: + value = getattr(self, field.name) + if not isinstance(value, field.value_type): + raise SceneStateError(f"Invalid value for scene-state field {namespace}.{field.name}: {type(value).__name__}.") + values[field.name] = value + return state + + def _apply_extension_state(self) -> None: + """Apply known declared state while preserving unknown namespaced values.""" + if self._node is None: + return + for namespace, fields in self.scene_state_fields().items(): + values = self._node.extensions.get(namespace) + if values is None: + continue + for field in fields: + if field.name not in values: + raise SceneStateError(f"Missing scene-state field {namespace}.{field.name}.") + value = values[field.name] + if not isinstance(value, field.value_type): + raise SceneStateError(f"Invalid value for scene-state field {namespace}.{field.name}: {type(value).__name__}.") + setattr(self, field.name, value) + + def _sync_node_state(self) -> None: + """Synchronize mutable adapter-facing state to the durable node.""" + if self._node is None: + return + self._node.name = self.name + self._node.transformation = self.transformation + self._node.color = self.color + self._node.opacity = self.opacity + self._node.show = self.show + self._node.extensions = self._capture_extension_state() + def _invalidate_transformation_cache(self) -> None: """Invalidate cached data derived from the local transformation. diff --git a/src/compas/scene/volmeshobject.py b/src/compas/scene/volmeshobject.py index ce54bae96f2..1f627528f16 100644 --- a/src/compas/scene/volmeshobject.py +++ b/src/compas/scene/volmeshobject.py @@ -10,6 +10,7 @@ from .descriptors.colordict import ColorDictAttribute from .sceneobject import SceneObject +from .sceneobject import SceneStateField class VolMeshObject(SceneObject): @@ -26,6 +27,19 @@ class VolMeshObject(SceneObject): edgecolor = ColorDictAttribute() facecolor = ColorDictAttribute() cellcolor = ColorDictAttribute() + __scene_state_namespace__ = "compas.scene.VolMeshObject" + __scene_state_fields__ = ( + SceneStateField("show_vertices", (bool, list)), + SceneStateField("show_edges", (bool, list)), + SceneStateField("show_faces", (bool, list)), + SceneStateField("show_cells", (bool, list)), + SceneStateField("vertexcolor"), + SceneStateField("edgecolor"), + SceneStateField("facecolor"), + SceneStateField("cellcolor"), + SceneStateField("vertexsize", (int, float)), + SceneStateField("edgewidth", (int, float)), + ) # ========================================================================== # Dunder methods @@ -94,7 +108,7 @@ def volmesh(self, volmesh: VolMesh) -> None: self._detached_item = volmesh else: self.scene.replace_item(self, volmesh) - self._transformation = None + self.transformation = None self._vertex_xyz = None self._vertex_xyz_revision = None diff --git a/tests/compas/scene/test_scene_serialisation.py b/tests/compas/scene/test_scene_serialisation.py index 6f0cf5acf8c..8a222a1c466 100644 --- a/tests/compas/scene/test_scene_serialisation.py +++ b/tests/compas/scene/test_scene_serialisation.py @@ -1,4 +1,5 @@ import compas +from uuid import uuid4 import pytest # noqa: F401 from compas.data import Data @@ -24,6 +25,9 @@ from compas.datastructures import Graph from compas.datastructures import VolMesh from compas.scene import Group +from compas.scene import SceneItemNotBoundError +from compas.scene import SceneObject +from compas.scene import SceneStateField @pytest.fixture @@ -127,18 +131,22 @@ def test_scene_serialisation_preserves_shared_items(): scene.add(box, name="Second") reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) + reconstructed.bind(box) assert reconstructed.objects[0].item is reconstructed.objects[1].item def test_scene_serialisation_reconstructs_world_transform_from_local_transforms(): scene = Scene() - parent = scene.add(Box(), name="Parent") - child = scene.add(Box(), name="Child", parent=parent) + parent_box = Box() + child_box = Box() + parent = scene.add(parent_box, name="Parent") + child = scene.add(child_box, name="Child", parent=parent) parent.transformation = Translation.from_vector([10.0, 0.0, 0.0]) child.transformation = Translation.from_vector([0.0, 5.0, 0.0]) reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) + reconstructed.bind_many([parent_box, child_box]) reconstructed_parent = reconstructed.find_by_name("Parent") reconstructed_child = reconstructed.find_by_name("Child") @@ -163,6 +171,7 @@ def test_group_serialisation(): group.add(box) scene2 = Scene.from_jsonstring(scene1.to_jsonstring()) + scene2.bind(box) # Check if group was serialized correctly group2 = scene2.find_by_name("TestGroup") @@ -184,6 +193,7 @@ def test_nested_group_serialisation(): child_group.add(box) scene2 = Scene.from_jsonstring(scene1.to_jsonstring()) + scene2.bind(box) # Check parent group parent2 = scene2.find_by_name("ParentGroup") @@ -210,6 +220,7 @@ def test_group_kwargs_serialisation(): group.add(box) scene2 = Scene.from_jsonstring(scene1.to_jsonstring()) + scene2.bind(box) # Check if group kwargs were serialized correctly group2 = scene2.find_by_name("TestGroup") @@ -220,3 +231,79 @@ def test_group_kwargs_serialisation(): box2 = group2.children[0] assert not box2.show assert box2.opacity == 0.5 + + +def test_scene_serialisation_is_reference_only_and_loads_unresolved(): + box = Box(2) + scene = Scene("Reference only") + sceneobject = scene.add(box, name="Box reference") + + reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) + node = reconstructed.find_node_by_name("Box reference") + + assert node is not None + assert node.guid == sceneobject.guid + assert node.item_guid == box.guid + assert not reconstructed.item_store + with pytest.raises(SceneItemNotBoundError, match=f"{node.guid}.*{box.guid}"): + reconstructed.object(node) + + reconstructed.bind(box) + assert reconstructed.object(node).item is box + + +def test_scene_preserves_unknown_namespaced_extensions_without_an_adapter(): + scene = Scene() + scene.add_reference( + uuid4(), + name="External adapter", + extensions={"org.example.ViewerObject": {"overlay": True}}, + ) + + reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) + node = reconstructed.find_node_by_name("External adapter") + + assert node is not None + assert node.extensions == {"org.example.ViewerObject": {"overlay": True}} + + +def test_adapter_extension_fields_are_composed_through_the_mro(): + class BaseAdapter(SceneObject): + __scene_state_namespace__ = "org.example.BaseAdapter" + __scene_state_fields__ = (SceneStateField("base_value", int),) + + def __init__(self, *args, base_value=1, **kwargs): + super().__init__(*args, **kwargs) + self.base_value = base_value + + class DerivedAdapter(BaseAdapter): + __scene_state_namespace__ = "org.example.DerivedAdapter" + __scene_state_fields__ = (SceneStateField("derived_value", str),) + + def __init__(self, *args, derived_value="default", **kwargs): + super().__init__(*args, **kwargs) + self.derived_value = derived_value + + box = Box() + scene = Scene() + adapter = scene.add( + SceneObject( + box, + sceneobject_type=DerivedAdapter, + base_value=7, + derived_value="saved", + ) + ) + reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) + reconstructed.bind(box) + node = next(node for node in reconstructed.nodes if node.item_guid) + restored = reconstructed.object(node, object_type=DerivedAdapter) + + assert adapter.node is not None + assert adapter.node.extensions == { + "org.example.BaseAdapter": {"base_value": 7}, + "org.example.DerivedAdapter": {"derived_value": "saved"}, + } + assert isinstance(restored, DerivedAdapter) + assert restored.base_value == 7 + assert restored.derived_value == "saved" From f878cd102e87e7859030af96c7871a5b3345cf7d Mon Sep 17 00:00:00 2001 From: tomvanmele Date: Sat, 7 Nov 2026 17:06:54 +0100 Subject: [PATCH 02/10] Enforce durable scene node authority --- src/compas/scene/_scenenode.py | 63 ++++++- src/compas/scene/context.py | 13 +- src/compas/scene/descriptors/color.py | 12 +- src/compas/scene/geometryobject.py | 16 +- src/compas/scene/graphobject.py | 24 +-- src/compas/scene/group.py | 13 +- src/compas/scene/meshobject.py | 32 +--- src/compas/scene/scene.py | 10 +- src/compas/scene/sceneobject.py | 172 ++++++++++++------ src/compas/scene/volmeshobject.py | 34 +--- tests/compas/scene/test_scene.py | 42 ++++- .../compas/scene/test_scene_serialisation.py | 165 +++++++++++++++++ 12 files changed, 434 insertions(+), 162 deletions(-) diff --git a/src/compas/scene/_scenenode.py b/src/compas/scene/_scenenode.py index e10651510a0..4c1983405e0 100644 --- a/src/compas/scene/_scenenode.py +++ b/src/compas/scene/_scenenode.py @@ -55,6 +55,20 @@ class SceneNode(TreeNode["SceneNode"]): @property def __data__(self) -> dict[str, Any]: """Return the serializable node state.""" + if self.kind not in ("group", "item"): + raise ValueError("Scene-node kind should be 'group' or 'item'.") + if self.kind == "item" and not isinstance(self.item_guid, UUID): + raise TypeError("An item scene node requires a UUID item reference.") + if self.kind == "group" and self.item_guid is not None: + raise ValueError("A group scene node cannot reference an item.") + self._validate_base_state( + name=self.name, + transformation=self.transformation, + color=self.color, + opacity=self.opacity, + show=self.show, + extensions=self.extensions, + ) data: dict[str, Any] = { "guid": str(self.guid), "kind": self.kind, @@ -94,8 +108,14 @@ def __from_data__(cls, data: dict[str, Any]) -> Self: raise ValueError("A group scene node cannot reference an item.") extensions = data["extensions"] - if not isinstance(extensions, dict) or not all(isinstance(namespace, str) and isinstance(values, dict) for namespace, values in extensions.items()): - raise TypeError("Scene-node extensions should map namespace strings to dictionaries.") + cls._validate_base_state( + name=data["name"], + transformation=data.get("transformation"), + color=data.get("color"), + opacity=data["opacity"], + show=data["show"], + extensions=extensions, + ) node = cls( kind=kind, @@ -133,6 +153,14 @@ def __init__( raise ValueError("An item scene node requires an item GUID.") if kind == "group" and item_guid is not None: raise ValueError("A group scene node cannot reference an item.") + self._validate_base_state( + name=name, + transformation=transformation, + color=color, + opacity=opacity, + show=show, + extensions={} if extensions is None else extensions, + ) self.kind = kind self.item_guid = item_guid self.transformation = transformation @@ -143,6 +171,37 @@ def __init__( if guid is not None: self._guid = guid + @staticmethod + def _validate_base_state( + *, + name: object, + transformation: object, + color: object, + opacity: object, + show: object, + extensions: object, + ) -> None: + """Validate the closed durable base-node schema.""" + if not isinstance(name, str): + raise TypeError("Scene-node field 'name' should be a string.") + if transformation is not None and not isinstance(transformation, Transformation): + raise TypeError("Scene-node field 'transformation' should be a Transformation or None.") + if color is not None and not isinstance(color, Color): + raise TypeError("Scene-node field 'color' should be a Color or None.") + if isinstance(opacity, bool) or not isinstance(opacity, (int, float)): + raise TypeError("Scene-node field 'opacity' should be a number.") + if not 0.0 <= opacity <= 1.0: + raise ValueError("Scene-node field 'opacity' should be between 0.0 and 1.0.") + if not isinstance(show, bool): + raise TypeError("Scene-node field 'show' should be a boolean.") + if not isinstance(extensions, Mapping): + raise TypeError("Scene-node field 'extensions' should be a mapping.") + for namespace, values in extensions.items(): + if not isinstance(namespace, str) or not isinstance(values, Mapping): + raise TypeError("Scene-node extensions should map namespace strings to mappings.") + if not all(isinstance(field, str) for field in values): + raise TypeError(f"Scene-node extension fields in '{namespace}' should be strings.") + @property def object_guid(self) -> UUID: """Return the durable node GUID. diff --git a/src/compas/scene/context.py b/src/compas/scene/context.py index 4bdf63798ec..b6a51f77d59 100644 --- a/src/compas/scene/context.py +++ b/src/compas/scene/context.py @@ -87,9 +87,10 @@ def _get_sceneobject_cls( data: Data, context: str | None = None, sceneobject_type: "type[SceneObject] | None" = None, + detect_context: bool = True, ) -> "type[SceneObject]": # in any case user gets to override the choice - context_name = context or detect_current_context() + context_name = detect_current_context() if detect_context and context is None else context dtype = type(data) cls = None @@ -114,6 +115,7 @@ def get_sceneobject_cls( item: Data | None, context: str | None = None, sceneobject_type: "type[SceneObject] | None" = None, + detect_context: bool = True, **kwargs: object, ) -> "type[SceneObject]": """Get the registered scene object type for an item. @@ -126,6 +128,8 @@ def get_sceneobject_cls( The visualization context used for registration lookup. sceneobject_type An explicit scene object type overriding registration lookup. + detect_context + Whether `None` should trigger automatic named-context detection. **kwargs Additional scene object options, ignored during type selection. @@ -148,6 +152,11 @@ def get_sceneobject_cls( if item is None: raise ValueError("Cannot create a scene object for None. Please ensure you pass a instance of a supported class.") - cls = _get_sceneobject_cls(item, context=context, sceneobject_type=sceneobject_type) + cls = _get_sceneobject_cls( + item, + context=context, + sceneobject_type=sceneobject_type, + detect_context=detect_context, + ) validate_plugin_implementation(cls) return cls diff --git a/src/compas/scene/descriptors/color.py b/src/compas/scene/descriptors/color.py index 728060e625e..ed3615eafac 100644 --- a/src/compas/scene/descriptors/color.py +++ b/src/compas/scene/descriptors/color.py @@ -60,6 +60,10 @@ def __get__(self, obj: object | None, otype: type[object] | None = None) -> Self if obj is None: return self + node = getattr(obj, "_node", None) + if self.name == "color" and node is not None: + return node.color + if not hasattr(obj, self.private_name): setattr(obj, self.private_name, None) @@ -84,5 +88,9 @@ def __set__(self, obj: object, value: ColorInput) -> None: """ if value is None: return - - setattr(obj, self.private_name, Color.coerce(value)) + color = Color.coerce(value) + node = getattr(obj, "_node", None) + if self.name == "color" and node is not None: + node.color = color + else: + setattr(obj, self.private_name, color) diff --git a/src/compas/scene/geometryobject.py b/src/compas/scene/geometryobject.py index 4bf9c609777..ec80ab32a45 100644 --- a/src/compas/scene/geometryobject.py +++ b/src/compas/scene/geometryobject.py @@ -17,14 +17,14 @@ class GeometryObject(SceneObject): surfacecolor = ColorAttribute() __scene_state_namespace__ = "compas.scene.GeometryObject" __scene_state_fields__ = ( - SceneStateField("pointcolor", (Color, type(None))), - SceneStateField("linecolor", (Color, type(None))), - SceneStateField("surfacecolor", (Color, type(None))), - SceneStateField("pointsize", (int, float)), - SceneStateField("linewidth", (int, float)), - SceneStateField("show_points", bool), - SceneStateField("show_lines", bool), - SceneStateField("show_surfaces", bool), + SceneStateField("pointcolor", (Color, type(None)), default=None, required=False), + SceneStateField("linecolor", (Color, type(None)), default=None, required=False), + SceneStateField("surfacecolor", (Color, type(None)), default=None, required=False), + SceneStateField("pointsize", (int, float), default=1.0, required=False), + SceneStateField("linewidth", (int, float), default=1.0, required=False), + SceneStateField("show_points", bool, default=False, required=False), + SceneStateField("show_lines", bool, default=True, required=False), + SceneStateField("show_surfaces", bool, default=True, required=False), ) # ========================================================================== diff --git a/src/compas/scene/graphobject.py b/src/compas/scene/graphobject.py index 347a33b3784..5ea280023d4 100644 --- a/src/compas/scene/graphobject.py +++ b/src/compas/scene/graphobject.py @@ -28,12 +28,12 @@ class GraphObject(SceneObject): edgecolor = ColorDictAttribute() __scene_state_namespace__ = "compas.scene.GraphObject" __scene_state_fields__ = ( - SceneStateField("show_nodes", (bool, list)), - SceneStateField("show_edges", (bool, list)), - SceneStateField("nodecolor"), - SceneStateField("edgecolor"), - SceneStateField("nodesize", (int, float)), - SceneStateField("edgewidth", (int, float)), + SceneStateField("show_nodes", (bool, list), default=True, required=False), + SceneStateField("show_edges", (bool, list), default=True, required=False), + SceneStateField("nodecolor", required=False), + SceneStateField("edgecolor", required=False), + SceneStateField("nodesize", (int, float), default=1.0, required=False), + SceneStateField("edgewidth", (int, float), default=1.0, required=False), ) # ========================================================================== @@ -64,18 +64,6 @@ def __init__( # Properties # ========================================================================== - @property - def settings(self) -> dict[str, object]: - """The settings required to reconstruct the graph scene object.""" - settings = super().settings - settings["show_nodes"] = self.show_nodes - settings["show_edges"] = self.show_edges - settings["nodecolor"] = self.nodecolor - settings["edgecolor"] = self.edgecolor - settings["nodesize"] = self.nodesize - settings["edgewidth"] = self.edgewidth - return settings - @property def graph(self) -> Graph: """The graph represented by this scene object. diff --git a/src/compas/scene/group.py b/src/compas/scene/group.py index f3f725b4693..3dbc2ed56be 100644 --- a/src/compas/scene/group.py +++ b/src/compas/scene/group.py @@ -31,18 +31,6 @@ class Group(SceneObject): """ - # ========================================================================== - # Data - # ========================================================================== - - @property - def __data__(self) -> dict[str, object]: - data = { - "settings": self.settings, - "children": [child.__data__ for child in self.children], - } - return data - # ========================================================================== # Dunder methods # ========================================================================== @@ -52,6 +40,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: return object.__new__(cls) def __init__(self, name: str, **kwargs: Any) -> None: + kwargs.pop("_detect_context", None) super().__init__(name=name, **kwargs) self.kwargs = kwargs diff --git a/src/compas/scene/meshobject.py b/src/compas/scene/meshobject.py index 912eb89df3f..6cf26309f90 100644 --- a/src/compas/scene/meshobject.py +++ b/src/compas/scene/meshobject.py @@ -26,14 +26,14 @@ class MeshObject(SceneObject): facecolor = ColorDictAttribute() __scene_state_namespace__ = "compas.scene.MeshObject" __scene_state_fields__ = ( - SceneStateField("show_vertices", (bool, list)), - SceneStateField("show_edges", (bool, list)), - SceneStateField("show_faces", (bool, list)), - SceneStateField("vertexcolor"), - SceneStateField("edgecolor"), - SceneStateField("facecolor"), - SceneStateField("vertexsize", (int, float)), - SceneStateField("edgewidth", (int, float)), + SceneStateField("show_vertices", (bool, list), default=False, required=False), + SceneStateField("show_edges", (bool, list), default=False, required=False), + SceneStateField("show_faces", (bool, list), default=True, required=False), + SceneStateField("vertexcolor", required=False), + SceneStateField("edgecolor", required=False), + SceneStateField("facecolor", required=False), + SceneStateField("vertexsize", (int, float), default=1.0, required=False), + SceneStateField("edgewidth", (int, float), default=1.0, required=False), ) # ========================================================================== @@ -66,22 +66,6 @@ def __init__( # Properties # ========================================================================== - @property - def settings(self) -> dict[str, object]: - """The settings required to reconstruct the mesh scene object.""" - settings = super().settings - # perhaps this should be renamed to just "vertices", "edges", "faces" - settings["show_vertices"] = self.show_vertices - settings["show_edges"] = self.show_edges - settings["show_faces"] = self.show_faces - # end of perhaps - settings["vertexcolor"] = self.vertexcolor - settings["edgecolor"] = self.edgecolor - settings["facecolor"] = self.facecolor - settings["vertexsize"] = self.vertexsize - settings["edgewidth"] = self.edgewidth - return settings - @property def mesh(self) -> Mesh: """The mesh represented by this scene object. diff --git a/src/compas/scene/scene.py b/src/compas/scene/scene.py index 426aebfa29c..b1a7282e511 100644 --- a/src/compas/scene/scene.py +++ b/src/compas/scene/scene.py @@ -66,7 +66,6 @@ class Scene(Data): @property def __data__(self) -> dict[str, Any]: """Return the reference-only scene description.""" - self._sync_adapters() return { "version": self.SCHEMA_VERSION, "name": self.name, @@ -251,7 +250,7 @@ def bind_many(self, items: Iterable[Data]) -> None: for item in pending: if item.guid not in references: raise KeyError(f"Scene does not reference item {item.guid}.") - existing = self._items.get(item.guid) or staged.get(item.guid) + existing = self._items[item.guid] if item.guid in self._items else staged.get(item.guid) if existing is not None and existing is not item: raise ValueError(f"A different data item with GUID {item.guid} is already bound.") staged[item.guid] = item @@ -275,7 +274,6 @@ def bind_context(self, context: str | None = None) -> None: for node in self.tree.nodes: if node.item_guid is not None and node.item_guid not in self._items: raise SceneItemNotBoundError(node.guid, node.item_guid) - self._sync_adapters() for sceneobject in self._objects.values(): sceneobject._scene = None sceneobject._node = None @@ -326,6 +324,7 @@ def object(self, node: SceneNode | UUID, *, object_type: type[SceneObject] | Non show=node.show, transformation=node.transformation, context=self.context, + _detect_context=False, ) else: if node.item_guid is None: @@ -340,6 +339,7 @@ def object(self, node: SceneNode | UUID, *, object_type: type[SceneObject] | Non opacity=node.opacity, show=node.show, transformation=node.transformation, + _detect_context=False, ) sceneobject._bind(self, node) self._objects[node.guid] = sceneobject @@ -561,7 +561,3 @@ def _prune_items(self) -> None: def _referenced_item_guids(self) -> set[UUID]: return {node.item_guid for node in self.tree.nodes if node.item_guid is not None} - - def _sync_adapters(self) -> None: - for sceneobject in self._objects.values(): - sceneobject._sync_node_state() diff --git a/src/compas/scene/sceneobject.py b/src/compas/scene/sceneobject.py index 3e6fd789ebe..88556ceb938 100644 --- a/src/compas/scene/sceneobject.py +++ b/src/compas/scene/sceneobject.py @@ -1,3 +1,4 @@ +import inspect from collections.abc import Iterator from dataclasses import dataclass from functools import reduce @@ -6,7 +7,6 @@ from typing import Any from typing import ClassVar from typing import Literal -from typing import NoReturn from typing import Self from typing import cast from uuid import UUID @@ -28,6 +28,9 @@ from ._scenenode import SceneNode +_MISSING = object() + + @dataclass(frozen=True, slots=True) class SceneStateField: """Declaration of one durable adapter extension field. @@ -35,14 +38,31 @@ class SceneStateField: Parameters ---------- name - Stable serialized field name and adapter attribute name. + Stable serialized field name. value_type Runtime type or tuple of runtime types accepted for the field. + attribute_name + Adapter attribute carrying the value. Defaults to `name`. + default + Value applied when an omitted field is loaded. + required + Whether omission without a default is an error. + omit_default + Whether values equal to `default` are omitted when a node is created. """ name: str value_type: type[object] | tuple[type[object], ...] = object + attribute_name: str | None = None + default: object = _MISSING + required: bool = True + omit_default: bool = False + + @property + def attribute(self) -> str: + """Return the adapter attribute name carrying this field.""" + return self.attribute_name or self.name class SceneObject(Data): @@ -81,24 +101,6 @@ class SceneObject(Data): __scene_state_namespace__: ClassVar[str | None] = None __scene_state_fields__: ClassVar[tuple[SceneStateField, ...]] = () - # ========================================================================== - # Data - # ========================================================================== - - @property - def __data__(self) -> dict[str, object]: - if self.item_guid is None: - raise ValueError("A scene object without an item cannot be serialized independently.") - return { - "item": str(self.item_guid), - "settings": self.settings, - "children": [child.__data__ for child in self.children], - } - - @classmethod - def __from_data__(cls, data: dict[str, object]) -> NoReturn: - raise TypeError("Serialisation outside Scene not allowed.") - # ========================================================================== # Dunder methods # ========================================================================== @@ -108,9 +110,16 @@ def __new__( item: Data | None = None, context: str | None = None, sceneobject_type: type["SceneObject"] | None = None, + _detect_context: bool = True, **kwargs: Any, ) -> Self: - sceneobject_cls = get_sceneobject_cls(item, context=context, sceneobject_type=sceneobject_type, **kwargs) + sceneobject_cls = get_sceneobject_cls( + item, + context=context, + sceneobject_type=sceneobject_type, + detect_context=_detect_context, + **kwargs, + ) return cast(Self, object.__new__(sceneobject_cls)) def __init__( @@ -130,6 +139,7 @@ def __init__( # This factory-only option has already been handled by __new__. kwargs.pop("sceneobject_type", None) + kwargs.pop("_detect_context", None) name = name or getattr(item, "name", None) self._node: SceneNode | None = None super().__init__(name=name) @@ -152,6 +162,49 @@ def __init__( def __repr__(self) -> str: return "<{}: {}>".format(self.__class__.__name__, self.name) + def __getattribute__(self, name: str) -> object: + """Read declared durable extension attributes from the bound node.""" + if not name.startswith("_"): + try: + node = object.__getattribute__(self, "_node") + except AttributeError: + node = None + if node is not None: + for namespace, fields in type(self).scene_state_fields().items(): + for field in fields: + if field.attribute == name: + values = node.extensions.get(namespace, {}) + if field.name in values: + value = values[field.name] + if not isinstance(value, field.value_type): + raise SceneStateError(f"Invalid value for scene-state field {namespace}.{field.name}: {type(value).__name__}.") + return value + if field.default is not _MISSING: + return field.default + break + return object.__getattribute__(self, name) + + def __setattr__(self, name: str, value: object) -> None: + """Write declared durable extension attributes directly to the bound node.""" + if not name.startswith("_"): + try: + node = object.__getattribute__(self, "_node") + except AttributeError: + node = None + if node is not None: + for namespace, fields in type(self).scene_state_fields().items(): + for field in fields: + if field.attribute == name: + descriptor = inspect.getattr_static(type(self), name, None) + if hasattr(descriptor, "__set__"): + object.__setattr__(self, name, value) + value = object.__getattribute__(self, name) + if not isinstance(value, field.value_type): + raise SceneStateError(f"Invalid value for scene-state field {namespace}.{field.name}: {type(value).__name__}.") + node.extensions.setdefault(namespace, {})[field.name] = value + return + object.__setattr__(self, name, value) + # ========================================================================== # Properties # ========================================================================== @@ -383,21 +436,6 @@ def contrastcolor(self) -> Color | None: def contrastcolor(self, color: Color) -> None: self._contrastcolor = Color.coerce(color) - @property - def settings(self) -> dict[str, object]: - """The settings required to reconstruct the scene object.""" - settings = { - "name": self.name, - "color": self.color, - "opacity": self.opacity, - "show": self.show, - } - - if self.transformation: - settings["transformation"] = self.transformation - - return settings - @classmethod def scene_state_fields(cls) -> dict[str, tuple[SceneStateField, ...]]: """Return durable extension declarations composed across the MRO. @@ -414,6 +452,7 @@ def scene_state_fields(cls) -> dict[str, tuple[SceneStateField, ...]]: """ declarations: dict[str, tuple[SceneStateField, ...]] = {} + attributes: set[str] = set() for owner in reversed(cls.mro()): fields = owner.__dict__.get("__scene_state_fields__", ()) if not fields: @@ -426,6 +465,22 @@ def scene_state_fields(cls) -> dict[str, tuple[SceneStateField, ...]]: names = [field.name for field in fields] if len(names) != len(set(names)): raise SceneStateError(f"Duplicate scene-state field in namespace {namespace}.") + for field in fields: + if not field.name or not field.attribute: + raise SceneStateError(f"Scene-state field names in namespace {namespace} cannot be empty.") + try: + isinstance(None, field.value_type) + except TypeError: + raise SceneStateError(f"Invalid value type for scene-state field {namespace}.{field.name}.") from None + if field.default is not _MISSING and field.required: + raise SceneStateError(f"Scene-state field {namespace}.{field.name} cannot have a default and be required.") + if field.default is not _MISSING and not isinstance(field.default, field.value_type): + raise SceneStateError(f"Invalid default for scene-state field {namespace}.{field.name}.") + if field.omit_default and field.default is _MISSING: + raise SceneStateError(f"Scene-state field {namespace}.{field.name} cannot omit an undeclared default.") + if field.attribute in attributes: + raise SceneStateError(f"Duplicate scene-state adapter attribute: {field.attribute}.") + attributes.add(field.attribute) declarations[namespace] = tuple(fields) return declarations @@ -505,6 +560,7 @@ def clear(self) -> None: def _bind(self, scene: "Scene", node: "SceneNode") -> None: """Bind this runtime adapter to a durable scene node.""" + self._validate_declared_attributes() self._scene = scene self._node = node self._guid = None @@ -513,15 +569,22 @@ def _bind(self, scene: "Scene", node: "SceneNode") -> None: self._apply_extension_state() def _capture_extension_state(self) -> dict[str, dict[str, object]]: - """Capture declared durable adapter state without inspecting other attributes.""" - state = {namespace: dict(values) for namespace, values in (self._node.extensions if self._node else {}).items()} + """Capture declared state while creating a durable node.""" + state: dict[str, dict[str, object]] = {} for namespace, fields in self.scene_state_fields().items(): - values = state.setdefault(namespace, {}) + values: dict[str, object] = {} for field in fields: - value = getattr(self, field.name) + try: + value = getattr(self, field.attribute) + except AttributeError: + raise SceneStateError(f"Missing adapter attribute for scene-state field {namespace}.{field.name}: {field.attribute}.") from None if not isinstance(value, field.value_type): raise SceneStateError(f"Invalid value for scene-state field {namespace}.{field.name}: {type(value).__name__}.") + if field.omit_default and value == field.default: + continue values[field.name] = value + if values: + state[namespace] = values return state def _apply_extension_state(self) -> None: @@ -531,25 +594,28 @@ def _apply_extension_state(self) -> None: for namespace, fields in self.scene_state_fields().items(): values = self._node.extensions.get(namespace) if values is None: - continue + values = {} for field in fields: if field.name not in values: - raise SceneStateError(f"Missing scene-state field {namespace}.{field.name}.") + if field.default is not _MISSING: + continue + if field.required: + raise SceneStateError(f"Missing scene-state field {namespace}.{field.name}.") + continue value = values[field.name] if not isinstance(value, field.value_type): raise SceneStateError(f"Invalid value for scene-state field {namespace}.{field.name}: {type(value).__name__}.") - setattr(self, field.name, value) - def _sync_node_state(self) -> None: - """Synchronize mutable adapter-facing state to the durable node.""" - if self._node is None: - return - self._node.name = self.name - self._node.transformation = self.transformation - self._node.color = self.color - self._node.opacity = self.opacity - self._node.show = self.show - self._node.extensions = self._capture_extension_state() + def _validate_declared_attributes(self) -> None: + """Validate declared adapter attributes before binding to a node.""" + for namespace, fields in self.scene_state_fields().items(): + for field in fields: + try: + value = getattr(self, field.attribute) + except AttributeError: + raise SceneStateError(f"Missing adapter attribute for scene-state field {namespace}.{field.name}: {field.attribute}.") from None + if not isinstance(value, field.value_type): + raise SceneStateError(f"Invalid adapter attribute for scene-state field {namespace}.{field.name}: {type(value).__name__}.") def _invalidate_transformation_cache(self) -> None: """Invalidate cached data derived from the local transformation. diff --git a/src/compas/scene/volmeshobject.py b/src/compas/scene/volmeshobject.py index 1f627528f16..b3e075955f6 100644 --- a/src/compas/scene/volmeshobject.py +++ b/src/compas/scene/volmeshobject.py @@ -29,16 +29,16 @@ class VolMeshObject(SceneObject): cellcolor = ColorDictAttribute() __scene_state_namespace__ = "compas.scene.VolMeshObject" __scene_state_fields__ = ( - SceneStateField("show_vertices", (bool, list)), - SceneStateField("show_edges", (bool, list)), - SceneStateField("show_faces", (bool, list)), - SceneStateField("show_cells", (bool, list)), - SceneStateField("vertexcolor"), - SceneStateField("edgecolor"), - SceneStateField("facecolor"), - SceneStateField("cellcolor"), - SceneStateField("vertexsize", (int, float)), - SceneStateField("edgewidth", (int, float)), + SceneStateField("show_vertices", (bool, list), default=False, required=False), + SceneStateField("show_edges", (bool, list), default=False, required=False), + SceneStateField("show_faces", (bool, list), default=True, required=False), + SceneStateField("show_cells", (bool, list), default=True, required=False), + SceneStateField("vertexcolor", required=False), + SceneStateField("edgecolor", required=False), + SceneStateField("facecolor", required=False), + SceneStateField("cellcolor", required=False), + SceneStateField("vertexsize", (int, float), default=1.0, required=False), + SceneStateField("edgewidth", (int, float), default=1.0, required=False), ) # ========================================================================== @@ -77,20 +77,6 @@ def __init__( # Properties # ========================================================================== - @property - def settings(self) -> dict[str, object]: - """The settings required to reconstruct the volmesh scene object.""" - settings = super().settings - settings["show_vertices"] = self.show_vertices - settings["show_edges"] = self.show_edges - settings["show_faces"] = self.show_faces - settings["show_cells"] = self.show_cells - settings["vertexcolor"] = self.vertexcolor - settings["edgecolor"] = self.edgecolor - settings["facecolor"] = self.facecolor - settings["cellcolor"] = self.cellcolor - return settings - @property def volmesh(self) -> VolMesh: """The volmesh represented by this scene object. diff --git a/tests/compas/scene/test_scene.py b/tests/compas/scene/test_scene.py index b7b61ff1a53..8a098e47b48 100644 --- a/tests/compas/scene/test_scene.py +++ b/tests/compas/scene/test_scene.py @@ -10,6 +10,7 @@ from compas.geometry import Translation from compas.geometry import Transformation from compas.scene import Group +from compas.scene import GeometryObject from compas.datastructures import Tree from compas.datastructures import Graph from compas.datastructures import VolMesh @@ -104,6 +105,19 @@ def test_context_detection_does_not_guess_between_registered_contexts(): assert context.detect_current_context() is None +def test_bind_context_none_uses_core_registry_without_auto_detection(): + register(Box, FakeSceneObject, context="named") + register(Box, GeometryObject, context=None) + scene = Scene(context="named") + box = Box() + assert isinstance(scene.add(box), FakeSceneObject) + + scene.bind_context(None) + + assert scene.context is None + assert isinstance(scene.objects[0], GeometryObject) + + def test_sceneobject_transform(): scene = Scene() sceneobj1 = scene.add(Box()) @@ -128,17 +142,25 @@ def test_sceneobject_transform(): assert sceneobj3.frame.to_transformation() == Translation.from_vector([30.0, 20.0, 10.0]) -def test_sceneobject_settings_store_only_local_transformation(): +def test_scene_node_is_the_authority_for_adapter_base_state(): scene = Scene() - parent = scene.add(Box()) - child = scene.add(Box(), parent=parent) - parent.transformation = Translation.from_vector([10.0, 0.0, 0.0]) - child.transformation = Translation.from_vector([0.0, 5.0, 0.0]) - - assert "frame" not in parent.settings - assert "frame" not in child.settings - assert parent.settings["transformation"] == parent.transformation - assert child.settings["transformation"] == child.transformation + sceneobject = scene.add(Box(), name="Initial", opacity=0.5) + node = sceneobject.node + assert node is not None + + sceneobject.name = "Through adapter" + sceneobject.transformation = Translation.from_vector([10.0, 0.0, 0.0]) + sceneobject.show = False + assert node.name == "Through adapter" + assert node.transformation == sceneobject.transformation + assert not node.show + + node.name = "Through node" + node.transformation = Translation.from_vector([0.0, 5.0, 0.0]) + node.opacity = 0.25 + assert sceneobject.name == "Through node" + assert sceneobject.transformation == node.transformation + assert sceneobject.opacity == 0.25 def test_sceneobject_frame_and_worldtransformation_setters(): diff --git a/tests/compas/scene/test_scene_serialisation.py b/tests/compas/scene/test_scene_serialisation.py index 8a222a1c466..6dc446720c7 100644 --- a/tests/compas/scene/test_scene_serialisation.py +++ b/tests/compas/scene/test_scene_serialisation.py @@ -28,6 +28,8 @@ from compas.scene import SceneItemNotBoundError from compas.scene import SceneObject from compas.scene import SceneStateField +from compas.scene import SceneStateError +from compas.scene import SceneNode @pytest.fixture @@ -307,3 +309,166 @@ def __init__(self, *args, derived_value="default", **kwargs): assert isinstance(restored, DerivedAdapter) assert restored.base_value == 7 assert restored.derived_value == "saved" + + +def test_bound_extension_state_uses_the_node_as_sole_authority(): + box = Box() + scene = Scene() + adapter = scene.add(box, pointsize=2.0) + assert adapter.node is not None + + adapter.pointsize = 3.0 + assert adapter.node.extensions["compas.scene.GeometryObject"]["pointsize"] == 3.0 + + adapter.node.extensions["compas.scene.GeometryObject"]["pointsize"] = 4.0 + assert adapter.pointsize == 4.0 + + +def test_bound_extension_writes_preserve_property_setter_side_effects(): + class Adapter(SceneObject): + __scene_state_namespace__ = "org.example.SetterAdapter" + __scene_state_fields__ = (SceneStateField("resolution", int, attribute_name="resolution"),) + + def __init__(self, *args, resolution=4, **kwargs): + super().__init__(*args, **kwargs) + self.setter_calls = 0 + self.resolution = resolution + + @property + def resolution(self): + return self._resolution + + @resolution.setter + def resolution(self, value): + self.setter_calls += 1 + self._resolution = value + + adapter = Scene().add(SceneObject(Box(), sceneobject_type=Adapter)) + adapter.resolution = 8 + + assert adapter.setter_calls == 2 + assert adapter.resolution == 8 + assert adapter.node is not None + assert adapter.node.extensions["org.example.SetterAdapter"]["resolution"] == 8 + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("name", 1, "name"), + ("transformation", "invalid", "transformation"), + ("color", "red", "color"), + ("opacity", True, "opacity"), + ("opacity", 2.0, "opacity"), + ("show", 1, "show"), + ("extensions", [], "extensions"), + ("extensions", {"valid": {1: "invalid"}}, "extension fields"), + ], +) +def test_scene_node_rejects_invalid_closed_schema_types(field, value, message): + data = SceneNode(kind="group", name="Root").__data__ + data[field] = value + + with pytest.raises((TypeError, ValueError), match=message): + SceneNode.__from_data__(data) + + +def test_scene_state_field_supports_attribute_names_defaults_and_omission(): + class Adapter(SceneObject): + __scene_state_namespace__ = "org.example.Adapter" + __scene_state_fields__ = ( + SceneStateField( + "saved-size", + int, + attribute_name="size", + default=5, + required=False, + omit_default=True, + ), + ) + + def __init__(self, *args, size=5, **kwargs): + super().__init__(*args, **kwargs) + self.size = size + + box = Box() + scene = Scene() + adapter = scene.add(SceneObject(box, sceneobject_type=Adapter)) + + assert adapter.node is not None + assert "org.example.Adapter" not in adapter.node.extensions + + reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) + reconstructed.bind(box) + node = next(node for node in reconstructed.nodes if node.item_guid) + restored = reconstructed.object(node, object_type=Adapter) + assert restored.size == 5 + assert "org.example.Adapter" not in node.extensions + + +def test_scene_state_field_rejects_missing_required_state_and_attributes(): + class MissingAttributeAdapter(SceneObject): + __scene_state_namespace__ = "org.example.MissingAttribute" + __scene_state_fields__ = (SceneStateField("missing", int),) + + box = Box() + with pytest.raises(SceneStateError, match="Missing adapter attribute"): + Scene().add(SceneObject(box, sceneobject_type=MissingAttributeAdapter)) + + class RequiredAdapter(SceneObject): + __scene_state_namespace__ = "org.example.Required" + __scene_state_fields__ = (SceneStateField("required", int),) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.required = 1 + + scene = Scene() + node = scene.add_reference(box.guid, name="Required", extensions={}) + scene.bind(box) + with pytest.raises(SceneStateError, match="org.example.Required.required"): + scene.object(node, object_type=RequiredAdapter) + + +def test_scene_state_field_rejects_conflicting_declarations(): + class Base(SceneObject): + __scene_state_namespace__ = "org.example.Base" + __scene_state_fields__ = (SceneStateField("first", int, attribute_name="value"),) + + class Conflict(Base): + __scene_state_namespace__ = "org.example.Conflict" + __scene_state_fields__ = (SceneStateField("second", int, attribute_name="value"),) + + with pytest.raises(SceneStateError, match="Duplicate scene-state adapter attribute"): + Conflict.scene_state_fields() + + +def test_scene_objects_are_not_independently_serializable(): + adapter = Scene().add(Box()) + + with pytest.raises(NotImplementedError): + compas.json_dumps(adapter) + + +def test_bind_many_rejects_a_different_falsey_item_with_the_same_guid(): + class FalseyData(Data): + @property + def __data__(self): + return {} + + @classmethod + def __from_data__(cls, data): + return cls() + + def __bool__(self): + return False + + first = FalseyData() + second = FalseyData() + second._guid = first.guid + scene = Scene() + scene.add_reference(first.guid, name="Falsey") + scene.bind(first) + + with pytest.raises(ValueError, match="different data item"): + scene.bind_many([second]) From c21ad775932aeff9bbe64ddceaef630997de167a Mon Sep 17 00:00:00 2001 From: tomvanmele Date: Sat, 7 Nov 2026 17:13:21 +0100 Subject: [PATCH 03/10] Run pull request workflows against remove-future --- .github/workflows/build.yml | 2 +- .github/workflows/coverage.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/pr-checks.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 452045e4f53..d88b43d7cd1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main, initiative/repository-modernisation] + branches: [main, remove-future, initiative/repository-modernisation] permissions: contents: read diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 67a9eec63eb..655cf4d7b4d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main, initiative/repository-modernisation] + branches: [main, remove-future, initiative/repository-modernisation] permissions: contents: read diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 52afb1263a3..d9a1439a3e6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main, initiative/repository-modernisation] + branches: [main, remove-future, initiative/repository-modernisation] permissions: contents: read diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 2d52ab6842f..7d0d5f74790 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -3,7 +3,7 @@ name: verify pull request on: pull_request: types: [assigned, opened, synchronize, reopened, labeled, unlabeled] - branches: [main, initiative/repository-modernisation] + branches: [main, remove-future, initiative/repository-modernisation] permissions: contents: read From c586538728392bdeb310879b045f3f9a7aac4de1 Mon Sep 17 00:00:00 2001 From: tomvanmele Date: Sat, 7 Nov 2026 17:21:21 +0100 Subject: [PATCH 04/10] Document scene persistence changes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a551c39b20..d27848d5e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added durable, reference-only scene nodes with declarative persistent-state + fields, explicit data rebinding, and inspectable unresolved references. * Added a verified Linux Python 3.12 candidate wheel artifact for framework-wide pre-release integration. * Added the public `compas.typing` structural vocabulary with NumPy-aware `PointLike`, `VectorLike`, collection, and neutral `VerticesFaces` aliases. * Added the PEP 561 `py.typed` marker and installed-wheel static-consumer verification. @@ -23,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* Separated persistent scene state from runtime adapters and removed legacy + adapter-owned serialization and settings declarations. * Migrated core geometry constructors and plugin contracts from private overlapping type aliases to semantic structural inputs, including copied NumPy coordinates and integral face indices. * Added a committed uv development lock, documented direct quality commands while retaining Invoke convenience tasks, and migrated CI, release, artifact, and documentation mechanics to immutably pinned shared actions. * Standardized numerical triangle-mesh plugin inputs and outputs on structural `(vertices, triangles)` buffers. `Tessellation` remains an ephemeral discrete representation for geometry producers and scene handoff, but is not required at numerical backend boundaries. From 38d2457861ba7122f91d2107ce11e85053105165 Mon Sep 17 00:00:00 2001 From: tomvanmele Date: Sat, 7 Nov 2026 17:28:57 +0100 Subject: [PATCH 05/10] Cover invalid scene schema structures --- .../compas/scene/test_scene_serialisation.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/compas/scene/test_scene_serialisation.py b/tests/compas/scene/test_scene_serialisation.py index 6dc446720c7..4df1d79581e 100644 --- a/tests/compas/scene/test_scene_serialisation.py +++ b/tests/compas/scene/test_scene_serialisation.py @@ -373,6 +373,55 @@ def test_scene_node_rejects_invalid_closed_schema_types(field, value, message): SceneNode.__from_data__(data) +@pytest.mark.parametrize( + ("change", "message"), + [ + ({"unknown": True}, "Unknown scene-node fields"), + ({"name": None}, "Missing scene-node fields"), + ({"kind": "invalid"}, "kind"), + ({"kind": "item"}, "item GUID"), + ({"item": str(uuid4())}, "group scene node"), + ({"children": ()}, "children"), + ], +) +def test_scene_node_rejects_invalid_closed_schema_structure(change, message): + data = SceneNode(kind="group", name="Root").__data__ + if change == {"name": None}: + del data["name"] + else: + data.update(change) + + with pytest.raises((TypeError, ValueError), match=message): + SceneNode.__from_data__(data) + + +@pytest.mark.parametrize( + ("change", "message"), + [ + ({"unknown": True}, "Unknown scene fields"), + ({"name": None}, "Missing scene fields"), + ({"version": 2}, "Unsupported scene schema version"), + ], +) +def test_scene_rejects_invalid_closed_schema_structure(change, message): + data = Scene().__data__ + if change == {"name": None}: + del data["name"] + else: + data.update(change) + + with pytest.raises(ValueError, match=message): + Scene.__from_data__(data) + + +def test_scene_rejects_an_item_node_as_its_root(): + data = Scene().__data__ + data["root"] = SceneNode(kind="item", item_guid=uuid4(), name="Item").__data__ + + with pytest.raises(ValueError, match="root should be a group"): + Scene.__from_data__(data) + + def test_scene_state_field_supports_attribute_names_defaults_and_omission(): class Adapter(SceneObject): __scene_state_namespace__ = "org.example.Adapter" From e781cd22f3f230b7f2c367033b0e1d61d6b17739 Mon Sep 17 00:00:00 2001 From: tomvanmele Date: Sat, 7 Nov 2026 22:24:46 +0100 Subject: [PATCH 06/10] Rework scene objects and backend operations --- CHANGELOG.md | 10 +- docs/getting-started/basics.visualisation.md | 40 +- src/compas/files/gltf/gltf_conversions.py | 26 +- src/compas/scene/__init__.py | 32 +- src/compas/scene/_scenenode.py | 208 +----- src/compas/scene/context.py | 219 ++++--- src/compas/scene/descriptors/color.py | 10 +- src/compas/scene/exceptions.py | 16 +- src/compas/scene/graphobject.py | 53 +- src/compas/scene/group.py | 77 +-- src/compas/scene/meshobject.py | 68 +- src/compas/scene/scene.py | 564 ++++++---------- src/compas/scene/sceneobject.py | 619 ++++++------------ src/compas/scene/volmeshobject.py | 88 +-- tests/compas/files/test_gltf_conversions.py | 21 +- tests/compas/scene/test_scene.py | 526 +++++++-------- .../compas/scene/test_scene_serialisation.py | 618 +++++------------ 17 files changed, 984 insertions(+), 2211 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d27848d5e49..f363917e5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -* Added durable, reference-only scene nodes with declarative persistent-state - fields, explicit data rebinding, and inspectable unresolved references. +* Added durable, reference-only scene objects with declarative persistent-state + fields, explicit data rebinding, inspectable unresolved references, and + hierarchy-only scene nodes. * Added a verified Linux Python 3.12 candidate wheel artifact for framework-wide pre-release integration. * Added the public `compas.typing` structural vocabulary with NumPy-aware `PointLike`, `VectorLike`, collection, and neutral `VerticesFaces` aliases. * Added the PEP 561 `py.typed` marker and installed-wheel static-consumer verification. @@ -25,8 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -* Separated persistent scene state from runtime adapters and removed legacy - adapter-owned serialization and settings declarations. +* Made scene objects stable backend-neutral data records and replaced class + substitution with deterministic context- and MRO-keyed `draw` and `clear` + operations. Runtime resources remain outside durable scene objects. * Migrated core geometry constructors and plugin contracts from private overlapping type aliases to semantic structural inputs, including copied NumPy coordinates and integral face indices. * Added a committed uv development lock, documented direct quality commands while retaining Invoke convenience tasks, and migrated CI, release, artifact, and documentation mechanics to immutably pinned shared actions. * Standardized numerical triangle-mesh plugin inputs and outputs on structural `(vertices, triangles)` buffers. `Tessellation` remains an ephemeral discrete representation for geometry producers and scene handoff, but is not required at numerical backend boundaries. diff --git a/docs/getting-started/basics.visualisation.md b/docs/getting-started/basics.visualisation.md index f78aa3bb621..9598ffeea69 100644 --- a/docs/getting-started/basics.visualisation.md +++ b/docs/getting-started/basics.visualisation.md @@ -11,9 +11,9 @@ COMPAS (data) objects can be visualised by placing them into a "scene". >>> scene.draw() ``` -When a COMPAS object is added, the scene creates the corresponding scene object -for the active visualisation context. Visualisation backends are provided by -separate COMPAS packages. +When a COMPAS object is added, the scene creates a stable, backend-neutral scene +object for its semantic type. Visualisation backends are provided by separate +COMPAS packages and register operations for these stable objects. ## Scene Objects @@ -21,8 +21,8 @@ When a COMPAS object is added to a scene, a scene object is created automaticall ```pycon >>> sceneobj = scene.add(box) ->>> sceneobj -BoxObject +>>> type(sceneobj).__name__ +'GeometryObject' ``` The scene object holds a reference to the COMPAS data object, and stores its visualisation settings and transformation matrix. @@ -33,7 +33,7 @@ Multiple scene objects can be created for the same COMPAS data object, each with >>> sceneobj2 = scene.add(box) >>> sceneobj1 is sceneobj2 False ->>> sceneobj1.data is sceneobj2.data +>>> sceneobj1.item is sceneobj2.item True ``` @@ -45,7 +45,7 @@ All scene objects have a color attribute. ```pycon >>> sceneobj = scene.add(box) >>> sceneobj.color -Color(0.0, 0.0, 0.0, alpha=1.0) +Color(1.0, 1.0, 1.0, alpha=1.0) ``` Color attributes can be set using a COMPAS Color object, or a tuple of RGB values, with the color components specified as floats in the range 0.0-1.0 or as integers in the range 0-255. @@ -75,10 +75,11 @@ See the section about mesh visualisation for more information. ## Scene Hierarchy -Scene objects are organised in a hierarchy under a root group. The durable -hierarchy consists of serializable [`SceneNode`][compas.scene.SceneNode] -instances. Runtime [`SceneObject`][compas.scene.SceneObject] adapters connect -those nodes to a visualisation context. +Scene objects are organised in a hierarchy under an ordinary root group. A +[`SceneNode`][compas.scene.SceneNode] contains only an object GUID and child +nodes. Presentation state and the referenced data GUID belong to the stable +[`SceneObject`][compas.scene.SceneObject]. Each scene object has exactly one +node in a scene. ```pycon >>> scene = Scene() @@ -102,13 +103,14 @@ A serialized scene contains its hierarchy, presentation state, and references to data objects by GUID. It does not embed or own those data objects. Serialize the data separately when it should outlive the current Python process. -After loading a scene, its nodes can be inspected immediately. Bind the -independently loaded data before requesting runtime adapters or drawing. +After loading a scene, its objects and hierarchy can be inspected immediately. +Bind the independently loaded data explicitly before accessing an object's +`item` or drawing it. ```pycon >>> description = scene.to_jsonstring() >>> loaded_scene = Scene.from_jsonstring(description) ->>> loaded_scene.find_node_by_name(sceneobj.name).item_guid == box.guid +>>> loaded_scene.find_by_name(sceneobj.name).item_guid == box.guid True >>> loaded_scene.bind(box) >>> loaded_scene.find_by_name(sceneobj.name).item is box @@ -126,8 +128,8 @@ To use a different scene object as the parent, pass it to `Scene.add`. >>> point = Point(1, 2, 3) >>> pointobj = scene.add(point) >>> boxobj = scene.add(box, parent=pointobj) ->>> boxobj.parent -PointObject +>>> type(boxobj.parent).__name__ +'GeometryObject' ``` ## Object Frame And Transformation @@ -161,8 +163,10 @@ True ## Scene Context -Visualisation packages register their own scene contexts and scene object -implementations. Users can select a registered context explicitly: +Visualisation packages register `draw` and `clear` operations for stable scene +object types. Selecting another context changes operation lookup; it never +replaces the scene objects or changes their Python types. Users can select a +registered context explicitly: ```pycon >>> scene = Scene(context="MyContextName") diff --git a/src/compas/files/gltf/gltf_conversions.py b/src/compas/files/gltf/gltf_conversions.py index ea1630636ee..e7239bbfd0b 100644 --- a/src/compas/files/gltf/gltf_conversions.py +++ b/src/compas/files/gltf/gltf_conversions.py @@ -2,7 +2,6 @@ import warnings from collections.abc import Sequence -from contextlib import contextmanager from typing import TYPE_CHECKING from typing import Any from typing import cast @@ -28,8 +27,7 @@ from compas.scene import Group from compas.scene import MeshObject from compas.scene import Scene -from compas.scene.context import ITEM_SCENEOBJECT -from compas.scene.context import register +from compas.scene.context import register_sceneobject_type if TYPE_CHECKING: from compas.files.gltf.gltf_document import GLTFDocument @@ -37,20 +35,6 @@ from compas.scene import SceneObject # noqa: F401 -@contextmanager -def _base_scene_context(): - """Provide base scene objects without triggering visualization plugin discovery.""" - original = {context: registry.copy() for context, registry in ITEM_SCENEOBJECT.items()} - register(Geometry, GeometryObject, context=None) - register(Mesh, MeshObject, context=None) - try: - yield - finally: - ITEM_SCENEOBJECT.clear() - for context, registry in original.items(): - ITEM_SCENEOBJECT[context].update(registry) - - def _indexed_positions( positions: Sequence[Sequence[float]], indices: Sequence[int] | None, @@ -308,10 +292,10 @@ def add_node(node: "GLTFNode", parent: "SceneObject") -> None: for child_key in node.children: add_node(document.nodes[child_key], group) - with _base_scene_context(): - for root_key in source_scene.children: - assert scene.root is not None - add_node(document.nodes[root_key], scene.root) + register_sceneobject_type(Geometry, GeometryObject) + register_sceneobject_type(Mesh, MeshObject) + for root_key in source_scene.children: + add_node(document.nodes[root_key], scene.root) return scene diff --git a/src/compas/scene/__init__.py b/src/compas/scene/__init__.py index 6c9b6e14174..55f68141abb 100644 --- a/src/compas/scene/__init__.py +++ b/src/compas/scene/__init__.py @@ -1,7 +1,7 @@ """Visualize COMPAS geometry and data structures through scene objects. -Each supported item type is paired with a scene object type. Visualization -contexts register specialized scene object implementations through plugins. +Each supported item type is paired with one stable semantic scene-object type. +Visualization contexts register operations rather than replacement classes. """ @@ -9,6 +9,7 @@ from .exceptions import SceneItemNotBoundError from .exceptions import SceneObjectNotRegisteredError from .exceptions import SceneStateError +from .exceptions import SceneOperationNotRegisteredError from .sceneobject import SceneObject from .sceneobject import SceneStateField from .meshobject import MeshObject @@ -17,12 +18,12 @@ from .volmeshobject import VolMeshObject from .group import Group -from .context import clear -from .context import before_draw -from .context import after_draw from .context import register_scene_objects +from .context import register_scene_operations from .context import get_sceneobject_cls -from .context import register +from .context import get_scene_operation +from .context import register_sceneobject_type +from .context import register_scene_operation from .scene import Scene @@ -35,17 +36,18 @@ @plugin(category="factories", pluggable_name="register_scene_objects") def register_scene_objects_base() -> None: - """Register the context-independent core scene object types.""" - register(Geometry, GeometryObject, context=None) - register(Mesh, MeshObject, context=None) - register(Graph, GraphObject, context=None) - register(VolMesh, VolMeshObject, context=None) + """Register stable core scene-object types.""" + register_sceneobject_type(Geometry, GeometryObject) + register_sceneobject_type(Mesh, MeshObject) + register_sceneobject_type(Graph, GraphObject) + register_sceneobject_type(VolMesh, VolMeshObject) __all__ = [ "SceneObjectNotRegisteredError", "SceneItemNotBoundError", "SceneStateError", + "SceneOperationNotRegisteredError", "SceneNode", "SceneObject", "SceneStateField", @@ -54,11 +56,11 @@ def register_scene_objects_base() -> None: "GeometryObject", "VolMeshObject", "Group", - "clear", - "before_draw", - "after_draw", "register_scene_objects", + "register_scene_operations", "get_sceneobject_cls", - "register", + "get_scene_operation", + "register_sceneobject_type", + "register_scene_operation", "Scene", ] diff --git a/src/compas/scene/_scenenode.py b/src/compas/scene/_scenenode.py index 4c1983405e0..15c5d9b5cc2 100644 --- a/src/compas/scene/_scenenode.py +++ b/src/compas/scene/_scenenode.py @@ -1,213 +1,57 @@ -from collections.abc import Mapping from typing import Any -from typing import Literal from typing import Self from uuid import UUID -from compas.colors import Color from compas.datastructures import TreeNode -from compas.geometry import Transformation class SceneNode(TreeNode["SceneNode"]): - """Serializable state of one entry in a scene hierarchy. - - A scene node owns durable presentation state and an optional reference to a - separately serialized data item. Runtime scene-object adapters bind to a - node, but are not themselves serialized. + """One hierarchy entry referencing a stable scene object. Parameters ---------- - kind - The node kind. - item_guid - The GUID of the referenced data item. - name - The node name. - transformation - The local transformation relative to the parent node. - color - The base display color. - opacity - The display opacity. - show - Whether the node is visible. - extensions - Namespaced durable state declared by scene-object adapter classes. - guid - The durable node GUID. A new GUID is generated when omitted. + object_guid + The GUID of the referenced scene object. + + Notes + ----- + A scene node owns hierarchy only. Its referenced `SceneObject` owns all + durable presentation and item-reference state. """ - _ALLOWED_DATA_KEYS = { - "guid", - "kind", - "item", - "name", - "transformation", - "color", - "opacity", - "show", - "extensions", - "children", - } + _ALLOWED_DATA_KEYS = {"object", "children"} @property def __data__(self) -> dict[str, Any]: - """Return the serializable node state.""" - if self.kind not in ("group", "item"): - raise ValueError("Scene-node kind should be 'group' or 'item'.") - if self.kind == "item" and not isinstance(self.item_guid, UUID): - raise TypeError("An item scene node requires a UUID item reference.") - if self.kind == "group" and self.item_guid is not None: - raise ValueError("A group scene node cannot reference an item.") - self._validate_base_state( - name=self.name, - transformation=self.transformation, - color=self.color, - opacity=self.opacity, - show=self.show, - extensions=self.extensions, - ) - data: dict[str, Any] = { - "guid": str(self.guid), - "kind": self.kind, - "name": self.name, - "opacity": self.opacity, - "show": self.show, - "extensions": self.extensions, + """Return the recursive hierarchy representation.""" + return { + "object": str(self.object_guid), "children": [child.__data__ for child in self.children], } - if self.item_guid is not None: - data["item"] = str(self.item_guid) - if self.transformation is not None: - data["transformation"] = self.transformation - if self.color is not None: - data["color"] = self.color - return data @classmethod def __from_data__(cls, data: dict[str, Any]) -> Self: - """Construct a node and its descendants from serialized state.""" + """Construct a hierarchy entry and its descendants.""" unknown = set(data) - cls._ALLOWED_DATA_KEYS if unknown: raise ValueError("Unknown scene-node fields: {}.".format(", ".join(sorted(unknown)))) - - required = {"guid", "kind", "name", "opacity", "show", "extensions", "children"} - missing = required - set(data) + missing = cls._ALLOWED_DATA_KEYS - set(data) if missing: raise ValueError("Missing scene-node fields: {}.".format(", ".join(sorted(missing)))) - - kind = data["kind"] - if kind not in ("group", "item"): - raise ValueError("Scene-node kind should be 'group' or 'item'.") - item_guid = UUID(data["item"]) if "item" in data else None - if kind == "item" and item_guid is None: - raise ValueError("An item scene node requires an item GUID.") - if kind == "group" and item_guid is not None: - raise ValueError("A group scene node cannot reference an item.") - - extensions = data["extensions"] - cls._validate_base_state( - name=data["name"], - transformation=data.get("transformation"), - color=data.get("color"), - opacity=data["opacity"], - show=data["show"], - extensions=extensions, - ) - - node = cls( - kind=kind, - item_guid=item_guid, - name=data["name"], - transformation=data.get("transformation"), - color=data.get("color"), - opacity=data["opacity"], - show=data["show"], - extensions=extensions, - guid=UUID(data["guid"]), - ) - children = data["children"] - if not isinstance(children, list): + if not isinstance(data["children"], list): raise TypeError("Scene-node children should be a list.") - for child_data in children: + try: + object_guid = UUID(data["object"]) + except (AttributeError, TypeError, ValueError): + raise ValueError("Scene-node object should be a UUID string.") from None + node = cls(object_guid) + for child_data in data["children"]: node.add(cls.__from_data__(child_data)) return node - def __init__( - self, - *, - kind: Literal["group", "item"], - item_guid: UUID | None = None, - name: str, - transformation: Transformation | None = None, - color: Color | None = None, - opacity: float = 1.0, - show: bool = True, - extensions: Mapping[str, Mapping[str, object]] | None = None, - guid: UUID | None = None, - ) -> None: - super().__init__(name=name) - if kind == "item" and item_guid is None: - raise ValueError("An item scene node requires an item GUID.") - if kind == "group" and item_guid is not None: - raise ValueError("A group scene node cannot reference an item.") - self._validate_base_state( - name=name, - transformation=transformation, - color=color, - opacity=opacity, - show=show, - extensions={} if extensions is None else extensions, - ) - self.kind = kind - self.item_guid = item_guid - self.transformation = transformation - self.color = color - self.opacity = opacity - self.show = show - self.extensions = {namespace: dict(values) for namespace, values in (extensions or {}).items()} - if guid is not None: - self._guid = guid - - @staticmethod - def _validate_base_state( - *, - name: object, - transformation: object, - color: object, - opacity: object, - show: object, - extensions: object, - ) -> None: - """Validate the closed durable base-node schema.""" - if not isinstance(name, str): - raise TypeError("Scene-node field 'name' should be a string.") - if transformation is not None and not isinstance(transformation, Transformation): - raise TypeError("Scene-node field 'transformation' should be a Transformation or None.") - if color is not None and not isinstance(color, Color): - raise TypeError("Scene-node field 'color' should be a Color or None.") - if isinstance(opacity, bool) or not isinstance(opacity, (int, float)): - raise TypeError("Scene-node field 'opacity' should be a number.") - if not 0.0 <= opacity <= 1.0: - raise ValueError("Scene-node field 'opacity' should be between 0.0 and 1.0.") - if not isinstance(show, bool): - raise TypeError("Scene-node field 'show' should be a boolean.") - if not isinstance(extensions, Mapping): - raise TypeError("Scene-node field 'extensions' should be a mapping.") - for namespace, values in extensions.items(): - if not isinstance(namespace, str) or not isinstance(values, Mapping): - raise TypeError("Scene-node extensions should map namespace strings to mappings.") - if not all(isinstance(field, str) for field in values): - raise TypeError(f"Scene-node extension fields in '{namespace}' should be strings.") - - @property - def object_guid(self) -> UUID: - """Return the durable node GUID. - - This alias keeps tree consumers readable while runtime scene objects - transition from owning identity to exposing their node's identity. - - """ - return self.guid + def __init__(self, object_guid: UUID) -> None: + if not isinstance(object_guid, UUID): + raise TypeError("Scene-node object_guid should be a UUID.") + super().__init__(name=str(object_guid)) + self.object_guid = object_guid diff --git a/src/compas/scene/context.py b/src/compas/scene/context.py index b6a51f77d59..206b02db0b1 100644 --- a/src/compas/scene/context.py +++ b/src/compas/scene/context.py @@ -1,162 +1,167 @@ import inspect -from collections import defaultdict +from collections.abc import Callable from typing import TYPE_CHECKING +from typing import Any from compas.data import Data from compas.plugins import pluggable -from compas.plugins import validate_plugin_implementation from .exceptions import SceneObjectNotRegisteredError +from .exceptions import SceneOperationNotRegisteredError if TYPE_CHECKING: from .sceneobject import SceneObject -ITEM_SCENEOBJECT: defaultdict[str | None, dict[type[Data], type["SceneObject"]]] = defaultdict(dict) +SCENEOBJECT_TYPES: dict[type[Data], type["SceneObject"]] = {} +SCENE_OPERATIONS: dict[str, dict[type["SceneObject"], dict[str, Callable[..., Any]]]] = {} +_SCENEOBJECTS_DISCOVERED = False +_OPERATIONS_DISCOVERED = False -@pluggable(category="drawing-utils") -def clear(guids: list[object] | None = None) -> None: - """Clear all or selected objects from the current visualization context. - Parameters - ---------- - guids - The identifiers to clear. If `None`, clear the entire context. - - """ +@pluggable(category="factories", selector="collect_all") +def register_scene_objects() -> None: + """Register stable semantic scene-object types.""" raise NotImplementedError -@pluggable(category="drawing-utils") -def before_draw() -> None: - """Perform context-specific operations before drawing a scene.""" - pass - - -@pluggable(category="drawing-utils") -def after_draw(drawn_objects: list[object]) -> None: - """Perform context-specific operations after drawing a scene. - - Parameters - ---------- - drawn_objects - The objects that were drawn. - - """ - pass - - @pluggable(category="factories", selector="collect_all") -def register_scene_objects() -> None: - """Register scene object types available in the current context.""" +def register_scene_operations() -> None: + """Register context-specific scene operations.""" raise NotImplementedError -def register(item_type: type[Data], sceneobject_type: "type[SceneObject]", context: str | None = None) -> None: - """Register a scene object type to a data type. +def register_sceneobject_type(item_type: type[Data], sceneobject_type: type["SceneObject"]) -> None: + """Register the stable scene-object type for a data type. Parameters ---------- item_type - The type of data item. + The represented data type. sceneobject_type - The compatible scene object type. - context - The visualization context in which to register the pair. + Its stable semantic scene-object type. + + Raises + ------ + ValueError + If another scene-object type is already registered for `item_type`. """ - ITEM_SCENEOBJECT[context][item_type] = sceneobject_type + existing = SCENEOBJECT_TYPES.get(item_type) + if existing is not None and existing is not sceneobject_type: + raise ValueError(f"A scene-object type is already registered for {item_type.__name__}.") + SCENEOBJECT_TYPES[item_type] = sceneobject_type -def detect_current_context() -> str | None: - """Select the only registered named visualization context, if available. +def get_sceneobject_cls(item: Data) -> type["SceneObject"]: + """Return the stable semantic scene-object type for an item. + + Parameters + ---------- + item + The represented data item. Returns ------- - str | None - The context name when exactly one named context is registered. - Otherwise, `None`. - - """ - - contexts = [name for name, registry in ITEM_SCENEOBJECT.items() if name is not None and registry] - return contexts[0] if len(contexts) == 1 else None - - -def _get_sceneobject_cls( - data: Data, - context: str | None = None, - sceneobject_type: "type[SceneObject] | None" = None, - detect_context: bool = True, -) -> "type[SceneObject]": - # in any case user gets to override the choice - context_name = detect_current_context() if detect_context and context is None else context + type[SceneObject] - dtype = type(data) - cls = None + Raises + ------ + SceneObjectNotRegisteredError + If no compatible stable type is registered. - if sceneobject_type is not None: - cls = sceneobject_type - else: - registry = ITEM_SCENEOBJECT[context_name] + """ + _ensure_sceneobjects_discovered() + for item_type in inspect.getmro(type(item)): + sceneobject_type = SCENEOBJECT_TYPES.get(item_type) + if sceneobject_type is not None: + return sceneobject_type + raise SceneObjectNotRegisteredError(f"No stable scene-object type is registered for {type(item).__name__}.") + + +def register_scene_operation( + context: str, + sceneobject_type: type["SceneObject"], + operation: str, + implementation: Callable[..., Any], +) -> None: + """Register one context-specific scene operation. - for type_ in inspect.getmro(dtype): - cls = registry.get(type_, None) - if cls is not None: - break + Parameters + ---------- + context + Stable visualization-context name. + sceneobject_type + Stable scene-object type accepted by the implementation. + operation + Operation name. Initially only `draw` and `clear` are supported. + implementation + Operation function. - if cls is None: - raise SceneObjectNotRegisteredError("No scene object is registered for this data type: {} in this context: {}".format(dtype, context_name)) + Raises + ------ + ValueError + If the key is invalid or already has another implementation. - return cls + """ + if not context: + raise ValueError("A scene-operation context should be a non-empty string.") + if operation not in {"draw", "clear"}: + raise ValueError("A scene operation should be 'draw' or 'clear'.") + operations = SCENE_OPERATIONS.setdefault(context, {}).setdefault(sceneobject_type, {}) + existing = operations.get(operation) + if existing is not None and existing is not implementation: + raise ValueError(f"Scene operation already registered: {context}/{sceneobject_type.__name__}/{operation}.") + operations[operation] = implementation -def get_sceneobject_cls( - item: Data | None, - context: str | None = None, - sceneobject_type: "type[SceneObject] | None" = None, - detect_context: bool = True, - **kwargs: object, -) -> "type[SceneObject]": - """Get the registered scene object type for an item. +def get_scene_operation(context: str, sceneobject_type: type["SceneObject"], operation: str) -> Callable[..., Any]: + """Resolve a scene operation using stable-type MRO fallback. Parameters ---------- - item - The item to represent. context - The visualization context used for registration lookup. + Visualization-context name. sceneobject_type - An explicit scene object type overriding registration lookup. - detect_context - Whether `None` should trigger automatic named-context detection. - **kwargs - Additional scene object options, ignored during type selection. + Concrete stable scene-object type. + operation + Operation name. Returns ------- - type[SceneObject] - The selected scene object type. + Callable[..., Any] Raises ------ - ValueError - If `item` is `None`. - SceneObjectNotRegisteredError - If no compatible scene object type is registered. + SceneOperationNotRegisteredError + If the context has no compatible implementation. """ - if not ITEM_SCENEOBJECT: + _ensure_operations_discovered() + registry = SCENE_OPERATIONS.get(context, {}) + for candidate in inspect.getmro(sceneobject_type): + implementation = registry.get(candidate, {}).get(operation) + if implementation is not None: + return implementation + raise SceneOperationNotRegisteredError(context, sceneobject_type, operation) + + +def detect_current_context() -> str | None: + """Return the only registered visualization context, if unambiguous.""" + _ensure_operations_discovered() + contexts = [context for context, registry in SCENE_OPERATIONS.items() if registry] + return contexts[0] if len(contexts) == 1 else None + + +def _ensure_sceneobjects_discovered() -> None: + global _SCENEOBJECTS_DISCOVERED + if not _SCENEOBJECTS_DISCOVERED: register_scene_objects() + _SCENEOBJECTS_DISCOVERED = True + - if item is None: - raise ValueError("Cannot create a scene object for None. Please ensure you pass a instance of a supported class.") - - cls = _get_sceneobject_cls( - item, - context=context, - sceneobject_type=sceneobject_type, - detect_context=detect_context, - ) - validate_plugin_implementation(cls) - return cls +def _ensure_operations_discovered() -> None: + global _OPERATIONS_DISCOVERED + if not _OPERATIONS_DISCOVERED: + register_scene_operations() + _OPERATIONS_DISCOVERED = True diff --git a/src/compas/scene/descriptors/color.py b/src/compas/scene/descriptors/color.py index ed3615eafac..15db66d8b32 100644 --- a/src/compas/scene/descriptors/color.py +++ b/src/compas/scene/descriptors/color.py @@ -60,10 +60,6 @@ def __get__(self, obj: object | None, otype: type[object] | None = None) -> Self if obj is None: return self - node = getattr(obj, "_node", None) - if self.name == "color" and node is not None: - return node.color - if not hasattr(obj, self.private_name): setattr(obj, self.private_name, None) @@ -89,8 +85,4 @@ def __set__(self, obj: object, value: ColorInput) -> None: if value is None: return color = Color.coerce(value) - node = getattr(obj, "_node", None) - if self.name == "color" and node is not None: - node.color = color - else: - setattr(obj, self.private_name, color) + setattr(obj, self.private_name, color) diff --git a/src/compas/scene/exceptions.py b/src/compas/scene/exceptions.py index 8a7633594c1..03ede226232 100644 --- a/src/compas/scene/exceptions.py +++ b/src/compas/scene/exceptions.py @@ -5,11 +5,21 @@ class SceneObjectNotRegisteredError(Exception): class SceneItemNotBoundError(LookupError): """Raised when an operation requires an unresolved scene item reference.""" - def __init__(self, node_guid: object, item_guid: object) -> None: - super().__init__(f"Scene node {node_guid} requires unbound item {item_guid}.") - self.node_guid = node_guid + def __init__(self, object_guid: object, item_guid: object) -> None: + super().__init__(f"Scene object {object_guid} requires unbound item {item_guid}.") + self.object_guid = object_guid self.item_guid = item_guid class SceneStateError(ValueError): """Raised when declared durable scene state is invalid.""" + + +class SceneOperationNotRegisteredError(LookupError): + """Raised when a visualization context has no compatible operation.""" + + def __init__(self, context: str, sceneobject_type: type[object], operation: str) -> None: + super().__init__(f"No '{operation}' scene operation is registered for {sceneobject_type.__name__} in context '{context}'.") + self.context = context + self.sceneobject_type = sceneobject_type + self.operation = operation diff --git a/src/compas/scene/graphobject.py b/src/compas/scene/graphobject.py index 5ea280023d4..86f52d0792c 100644 --- a/src/compas/scene/graphobject.py +++ b/src/compas/scene/graphobject.py @@ -78,7 +78,7 @@ def graph(self) -> Graph: def graph(self, graph: Graph) -> None: if self.scene is None: self._item_guid = graph.guid - self._detached_item = graph + self._item = graph else: self.scene.replace_item(self, graph) self.transformation = None @@ -109,57 +109,6 @@ def node_xyz(self, node_xyz: dict[Hashable, list[RealInput]]) -> None: self._node_xyz = node_xyz self._node_xyz_revision = None - # ========================================================================== - # Methods - # ========================================================================== - - def draw_nodes(self) -> list[object]: - """Draw the nodes of the graph. - - Nodes are drawn based on the values of - - * `self.show_nodes` - * `self.nodecolor` - * `self.nodesize` - - Returns - ------- - list[object] - The identifiers of the objects representing the nodes in the visualization context. - - """ - raise NotImplementedError - - def draw_edges(self) -> list[object]: - """Draw the edges of the graph. - - Edges are drawn based on the values of - - * `self.show_edges` - * `self.edgecolor` - * `self.edgewidth` - - Returns - ------- - list[object] - The identifiers of the objects representing the edges in the visualization context. - - """ - raise NotImplementedError - - def clear_nodes(self) -> None: - """Clear the nodes of the graph.""" - raise NotImplementedError - - def clear_edges(self) -> None: - """Clear the edges of the graph.""" - raise NotImplementedError - - def clear(self) -> None: - """Clear the nodes and edges of the graph.""" - self.clear_nodes() - self.clear_edges() - # ========================================================================== # Private helpers # ========================================================================== diff --git a/src/compas/scene/group.py b/src/compas/scene/group.py index 3dbc2ed56be..4c38ea101ac 100644 --- a/src/compas/scene/group.py +++ b/src/compas/scene/group.py @@ -1,5 +1,4 @@ from typing import Any -from typing import Self from compas.data import Data @@ -7,86 +6,24 @@ class Group(SceneObject): - """A group of scene objects. + """Stable scene object used to group hierarchy entries. Parameters ---------- name - The name of the group. + The group name. **kwargs - Default keyword arguments passed to child scene objects. - - Examples - -------- - >>> from compas.scene import Scene - >>> from compas.scene import Group - >>> from compas.geometry import Point - >>> scene = Scene() - >>> group = Group(name="My Group") - >>> _ = scene.add(group) - >>> point = Point(0, 0, 0) - >>> _ = group.add(point) - >>> len(scene.objects) - 2 + Durable group presentation state. """ - # ========================================================================== - # Dunder methods - # ========================================================================== - - def __new__(cls, *args: Any, **kwargs: Any) -> Self: - # 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) - - def __init__(self, name: str, **kwargs: Any) -> None: - kwargs.pop("_detect_context", None) + def __init__(self, name: str = "Group", **kwargs: Any) -> None: super().__init__(name=name, **kwargs) - self.kwargs = kwargs - - # ========================================================================== - # Methods - # ========================================================================== def add(self, item: Data, **kwargs: Any) -> SceneObject: - """Add a child item to the group, using the group's kwargs as default values for the child sceneobject. - - Parameters - ---------- - item - The item to add. - **kwargs - Additional keyword arguments to create the scene object for the item. - - Returns - ------- - SceneObject - The scene object associated with the added item. - - """ - group_kwargs = self.kwargs.copy() - group_kwargs.update(kwargs) - kwargs = group_kwargs + """Add a child item with explicitly supplied presentation state.""" return super().add(item, **kwargs) def add_from_list(self, items: list[Data], **kwargs: Any) -> list[SceneObject]: - """Add a list of items to the group, using the group's kwargs as default values for the child sceneobject. - - Parameters - ---------- - items - The items to add. - **kwargs - Additional keyword arguments to create the scene object for the items. - - Returns - ------- - list[SceneObject] - The scene objects associated with the added items. - - """ - sceneobjects = [] - for item in items: - sceneobject = self.add(item, **kwargs) - sceneobjects.append(sceneobject) - return sceneobjects + """Add multiple child items with the same explicit presentation state.""" + return [self.add(item, **kwargs) for item in items] diff --git a/src/compas/scene/meshobject.py b/src/compas/scene/meshobject.py index 6cf26309f90..91ae74bd63e 100644 --- a/src/compas/scene/meshobject.py +++ b/src/compas/scene/meshobject.py @@ -79,73 +79,7 @@ def mesh(self) -> Mesh: def mesh(self, mesh: Mesh) -> None: if self.scene is None: self._item_guid = mesh.guid - self._detached_item = mesh + self._item = mesh else: self.scene.replace_item(self, mesh) self.transformation = None - - # ========================================================================== - # Methods - # ========================================================================== - - def draw_vertices(self) -> list[object]: - """Draw the vertices of the mesh. - - Vertices are drawn based on the values of - - * `self.show_vertices` - * `self.vertexcolor` - * `self.vertexsize` - - Returns - ------- - list[object] - The identifiers of the objects representing the vertices in the visualization context. - - """ - raise NotImplementedError - - def draw_edges(self) -> list[object]: - """Draw the edges of the mesh. - - Edges are drawn based on the values of - - * `self.show_edges` - * `self.edgecolor` - * `self.edgewidth` - - Returns - ------- - list[object] - The identifiers of the objects representing the edges in the visualization context. - - """ - raise NotImplementedError - - def draw_faces(self) -> list[object]: - """Draw the faces of the mesh. - - Faces are drawn based on the values of - - * `self.show_faces` - * `self.facecolor` - - Returns - ------- - list[object] - The identifiers of the objects representing the faces in the visualization context. - - """ - raise NotImplementedError - - def clear_vertices(self) -> None: - """Clear the vertices of the mesh.""" - raise NotImplementedError - - def clear_edges(self) -> None: - """Clear the edges of the mesh.""" - raise NotImplementedError - - def clear_faces(self) -> None: - """Clear the faces of the mesh.""" - raise NotImplementedError diff --git a/src/compas/scene/scene.py b/src/compas/scene/scene.py index b1a7282e511..781059f3211 100644 --- a/src/compas/scene/scene.py +++ b/src/compas/scene/scene.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections.abc import Iterable from collections.abc import Iterator from collections.abc import Mapping @@ -11,17 +13,13 @@ import compas.data # noqa: F401 import compas.datastructures # noqa: F401 import compas.geometry # noqa: F401 -from compas.colors import Color from compas.data import Data from compas.datastructures import Tree -from compas.geometry import Transformation -from . import sceneobject as sceneobject_module from ._scenenode import SceneNode -from .context import after_draw -from .context import before_draw -from .context import clear from .context import detect_current_context +from .context import get_scene_operation +from .context import get_sceneobject_cls from .exceptions import SceneItemNotBoundError from .group import Group from .sceneobject import SceneObject @@ -31,51 +29,37 @@ class Scene(Data): - """A backend-neutral visual description with runtime item bindings. + """Backend-neutral stable scene objects and their hierarchy. - Scene serialization contains durable node state and item GUID references, - but never embeds the referenced data objects. Load data independently and - bind it explicitly before adapter or drawing operations that need it. + Referenced data items remain independently serializable and are bound by + GUID. Visualization backends provide context-keyed `draw` and `clear` + operations without replacing scene-object classes. Parameters ---------- name - The name of the scene. + Scene name. context - The runtime visualization context. If omitted, COMPAS selects the only - registered named context when one is available. - - Examples - -------- - >>> from compas.scene import Scene - >>> from compas.geometry import Box - >>> box = Box(1) - >>> scene = Scene() - >>> boxobject = scene.add(box) - >>> boxobject.item is box - True + Optional default runtime visualization context. """ - SCHEMA_VERSION = 1 - - # ========================================================================== - # Data - # ========================================================================== + SCHEMA_VERSION = 2 @property def __data__(self) -> dict[str, Any]: - """Return the reference-only scene description.""" + """Return stable scene objects plus hierarchy references.""" return { "version": self.SCHEMA_VERSION, "name": self.name, - "root": self.root_node.__data__, + "objects": [self._objects[node.object_guid] for node in self.tree.nodes], + "hierarchy": self.root_node.__data__, } @classmethod def __from_data__(cls, data: dict[str, Any]) -> Self: - """Construct an unresolved scene description without binding adapters.""" - expected = {"version", "name", "root"} + """Construct an unresolved scene without visualization plugins.""" + expected = {"version", "name", "objects", "hierarchy"} unknown = set(data) - expected if unknown: raise ValueError("Unknown scene fields: {}.".format(", ".join(sorted(unknown)))) @@ -84,17 +68,17 @@ def __from_data__(cls, data: dict[str, Any]) -> Self: raise ValueError("Missing scene fields: {}.".format(", ".join(sorted(missing)))) if data["version"] != cls.SCHEMA_VERSION: raise ValueError(f"Unsupported scene schema version: {data['version']}.") - - root = SceneNode.__from_data__(data["root"]) - if root.kind != "group": - raise ValueError("The scene root should be a group node.") - - scene = cls(data["name"], context=None, _detect_context=False, _root=root) - return scene - - # ========================================================================== - # Dunder methods - # ========================================================================== + if not isinstance(data["name"], str): + raise TypeError("Scene name should be a string.") + if not isinstance(data["objects"], list) or not all(isinstance(obj, SceneObject) for obj in data["objects"]): + raise TypeError("Scene objects should be a list of SceneObject values.") + objects: dict[UUID, SceneObject] = {} + for obj in data["objects"]: + if obj.guid in objects: + raise ValueError(f"Duplicate scene-object GUID: {obj.guid}.") + objects[obj.guid] = obj + root = SceneNode.__from_data__(data["hierarchy"]) + return cls(data["name"], context=None, _detect_context=False, _objects=objects, _root=root) def __init__( self, @@ -102,83 +86,64 @@ def __init__( context: str | None = None, *, _detect_context: bool = True, + _objects: dict[UUID, SceneObject] | None = None, _root: SceneNode | None = None, ) -> None: super().__init__(name=name) self.context = context if context is not None or not _detect_context else detect_current_context() self._items: dict[UUID, Data] = {} - self._objects: dict[UUID, SceneObject] = {} + self._objects: dict[UUID, SceneObject] = _objects or {} self._nodes: dict[UUID, SceneNode] = {} + self._resources: dict[str, dict[UUID, tuple[object | None, list[object]]]] = {} self.tree: Tree[SceneNode] = Tree(name=name) - root = _root or SceneNode(kind="group", name="ROOT") - self.tree.add(root) + if _root is None: + root_object = Group(name="ROOT") + self._objects[root_object.guid] = root_object + _root = SceneNode(root_object.guid) + self.tree.add(_root) self._index_nodes() + if set(self._objects) != set(self._nodes): + raise ValueError("Scene objects and hierarchy references should match exactly.") + if not isinstance(self.root, Group): + raise ValueError("The scene root should reference a Group.") + for obj in self._objects.values(): + obj._scene = self def __str__(self) -> str: return str(self.tree) - # ========================================================================== - # Properties - # ========================================================================== - @property def root_node(self) -> SceneNode: - """The durable root node of the scene hierarchy.""" - root = self.tree.root - if root is None: - raise RuntimeError("The scene tree has no root node.") - return root + """Return the hierarchy root node.""" + if self.tree.root is None: + raise RuntimeError("The scene hierarchy has no root.") + return self.tree.root @property def root(self) -> Group: - """The runtime group adapter bound to the root node.""" - return cast(Group, self.object(self.root_node, object_type=Group)) + """Return the stable root group.""" + return cast(Group, self._objects[self.root_node.object_guid]) @property def nodes(self) -> Iterator[SceneNode]: - """An iterator over durable scene nodes, including the root.""" + """Iterate over hierarchy nodes, including the root.""" return iter(self.tree.nodes) @property def objects(self) -> list[SceneObject]: - """Runtime adapters for all non-root scene nodes. - - Raises - ------ - SceneItemNotBoundError - If a node references data that has not been bound. - - """ - return [self.object(node) for node in self.tree.nodes if not node.is_root] + """Return non-root scene objects in hierarchy order.""" + return [self._objects[node.object_guid] for node in self.tree.nodes if not node.is_root] @property def item_store(self) -> Mapping[UUID, Data]: - """The read-only runtime item-binding table. - - Bindings are non-owning references and are not serialized with the - scene description. - - """ + """Return read-only runtime item bindings.""" return MappingProxyType(self._items) @property def object_store(self) -> Mapping[UUID, SceneObject]: - """The read-only mapping of nodes to currently bound runtime adapters.""" + """Return read-only durable scene-object storage.""" return MappingProxyType(self._objects) - @property - def context_objects(self) -> list[object]: - """The identifiers of objects already drawn in the runtime context.""" - guids: list[object] = [] - for sceneobject in self._objects.values(): - if sceneobject.node is not None and not sceneobject.node.is_root: - guids += sceneobject.guids - return guids - - # ========================================================================== - # Constructors - # ========================================================================== - @classmethod def from_gltf(cls, document: "GLTFDocument", scene_key: int | None = None) -> Self: """Construct a scene from a glTF document.""" @@ -186,48 +151,14 @@ def from_gltf(cls, document: "GLTFDocument", scene_key: int | None = None) -> Se return gltf_to_scene(document, scene_key, scene_type=cls) - # ========================================================================== - # Conversions - # ========================================================================== - def to_gltf(self) -> "GLTFDocument": - """Convert this scene to a glTF document. - - Unsupported scene items are omitted with a warning. - - Returns - ------- - GLTFDocument - Converted document. - - """ + """Convert the scene to a glTF document.""" from compas.files.gltf.gltf_conversions import scene_to_gltf return scene_to_gltf(self) - # ========================================================================== - # Item bindings and runtime adapters - # ========================================================================== - def bind(self, item: Data) -> None: - """Bind an independently loaded data item to its referenced GUID. - - Binding the same instance repeatedly is idempotent. A different object - carrying an already bound GUID is rejected. - - Parameters - ---------- - item - The independently loaded data item. - - Raises - ------ - KeyError - If no scene node references the item's GUID. - ValueError - If another object is already bound to the GUID. - - """ + """Bind an independently loaded item to referenced scene objects.""" if item.guid not in self._referenced_item_guids(): raise KeyError(f"Scene does not reference item {item.guid}.") existing = self._items.get(item.guid) @@ -236,169 +167,64 @@ def bind(self, item: Data) -> None: self._items[item.guid] = item def bind_many(self, items: Iterable[Data]) -> None: - """Bind multiple data items atomically. - - Parameters - ---------- - items - Independently loaded data items. - - """ - pending = list(items) + """Bind multiple referenced items atomically.""" + staged = dict(self._items) references = self._referenced_item_guids() - staged: dict[UUID, Data] = {} - for item in pending: + for item in items: if item.guid not in references: raise KeyError(f"Scene does not reference item {item.guid}.") - existing = self._items[item.guid] if item.guid in self._items else staged.get(item.guid) + existing = staged.get(item.guid) if existing is not None and existing is not item: raise ValueError(f"A different data item with GUID {item.guid} is already bound.") staged[item.guid] = item - self._items.update(staged) - - def bind_context(self, context: str | None = None) -> None: - """Bind runtime adapters after the complete scene hierarchy exists. - - Parameters - ---------- - context - The visualization context. `None` uses context-independent core - adapters. - - Raises - ------ - SceneItemNotBoundError - If any item node remains unresolved. - - """ - for node in self.tree.nodes: - if node.item_guid is not None and node.item_guid not in self._items: - raise SceneItemNotBoundError(node.guid, node.item_guid) - for sceneobject in self._objects.values(): - sceneobject._scene = None - sceneobject._node = None - self._objects.clear() - self.context = context - for node in self.tree.nodes: - self.object(node) - - def item(self, guid: UUID, *, node_guid: UUID | None = None) -> Data: - """Return a bound data item. - - Parameters - ---------- - guid - The referenced item GUID. - node_guid - The node requesting the item, used in a precise error message. - - Returns - ------- - Data - The bound data item. + self._items = staged - Raises - ------ - SceneItemNotBoundError - If the item has not been bound. - - """ + def item(self, guid: UUID, *, object_guid: UUID | None = None) -> Data: + """Return a bound item or raise a precise unresolved-reference error.""" try: return self._items[guid] except KeyError: - raise SceneItemNotBoundError(node_guid or "unknown", guid) from None - - def object(self, node: SceneNode | UUID, *, object_type: type[SceneObject] | None = None) -> SceneObject: - """Return or create the runtime adapter for a durable node.""" - if isinstance(node, UUID): - node = self._nodes[node] - existing = self._objects.get(node.guid) - if existing is not None: - return existing - - if node.kind == "group": - sceneobject = Group( - name=node.name, - color=node.color, - opacity=node.opacity, - show=node.show, - transformation=node.transformation, - context=self.context, - _detect_context=False, - ) - else: - if node.item_guid is None: - raise RuntimeError("An item node has no item GUID.") - item = self.item(node.item_guid, node_guid=node.guid) - sceneobject = SceneObject( - item=item, - context=self.context, - sceneobject_type=object_type, - name=node.name, - color=node.color, - opacity=node.opacity, - show=node.show, - transformation=node.transformation, - _detect_context=False, - ) - sceneobject._bind(self, node) - self._objects[node.guid] = sceneobject - return sceneobject - - # ========================================================================== - # Hierarchy mutation - # ========================================================================== - - def add( - self, - item: Data | SceneObject, - parent: SceneObject | SceneNode | None = None, - **kwargs: Any, - ) -> SceneObject: - """Add a bound data item or detached runtime adapter to the scene.""" + raise SceneItemNotBoundError(object_guid or "unknown", guid) from None + + def object(self, reference: SceneNode | UUID) -> SceneObject: + """Return a stable scene object by node or GUID.""" + guid = reference.object_guid if isinstance(reference, SceneNode) else reference + return self._objects[guid] + + def node(self, object_guid: UUID) -> SceneNode: + """Return the hierarchy node for a scene-object GUID.""" + return self._nodes[object_guid] + + def add(self, item: Data, parent: SceneObject | SceneNode | None = None, **kwargs: Any) -> SceneObject: + """Create and attach the stable semantic scene object for an item.""" + if not isinstance(item, Data): + raise TypeError("Scene.add expects a Data item. Use add_object for an existing scene object.") + sceneobject_type = get_sceneobject_cls(item) + return self.add_object(sceneobject_type(item=item, **kwargs), parent=parent) + + def add_object(self, sceneobject: SceneObject, parent: SceneObject | SceneNode | None = None) -> SceneObject: + """Attach an existing stable scene object explicitly.""" + if not isinstance(sceneobject, SceneObject): + raise TypeError("Expected a SceneObject.") + if sceneobject.scene is not None: + raise ValueError("The scene object already belongs to a scene.") + if sceneobject.guid in self._objects: + raise ValueError(f"Scene object {sceneobject.guid} is already stored.") parent_node = self._parent_node(parent) - - if isinstance(item, SceneObject): - sceneobject = item - if sceneobject.scene is not None: - raise ValueError("The scene object already belongs to a scene.") - bound_item = sceneobject.item - else: - bound_item = item - parent_object = self._objects.get(parent_node.guid) - if isinstance(parent_object, Group): - group_kwargs = parent_object.kwargs.copy() - group_kwargs.update(kwargs) - kwargs = group_kwargs - supplied_context = kwargs.pop("context", self.context) - if supplied_context != self.context: - raise ValueError(f"Object context should be the same as scene context: {supplied_context} != {self.context}") - sceneobject = SceneObject(item=item, context=self.context, **kwargs) - - kind = "item" if bound_item is not None else "group" - node = SceneNode( - kind=kind, - item_guid=bound_item.guid if bound_item is not None else None, - name=sceneobject.name, - transformation=sceneobject.transformation, - color=sceneobject.color, - opacity=sceneobject.opacity, - show=sceneobject.show, - extensions=sceneobject._capture_extension_state(), - guid=sceneobject.guid, - ) - self._attach_node(node, parent_node) - if bound_item is not None: + node = SceneNode(sceneobject.guid) + self.tree.add(node, parent=parent_node) + self._nodes[sceneobject.guid] = node + self._objects[sceneobject.guid] = sceneobject + sceneobject._scene = self + if sceneobject.item_guid is not None and sceneobject._item is not None: try: - self.bind(bound_item) + self.bind(sceneobject._item) except Exception: self.tree.remove(node) - self._nodes.pop(node.guid) + del self._nodes[sceneobject.guid] + del self._objects[sceneobject.guid] + sceneobject._scene = None raise - sceneobject._bind(self, node) - self._objects[node.guid] = sceneobject - if hasattr(sceneobject, "_detached_item"): - del sceneobject._detached_item return sceneobject def add_reference( @@ -407,157 +233,139 @@ def add_reference( *, name: str, parent: SceneObject | SceneNode | None = None, - transformation: Transformation | None = None, - color: Color | None = None, - opacity: float = 1.0, - show: bool = True, - extensions: Mapping[str, Mapping[str, Any]] | None = None, - ) -> SceneNode: - """Add an unresolved item reference to the scene description.""" - node = SceneNode( - kind="item", - item_guid=item_guid, - name=name, - transformation=transformation, - color=color, - opacity=opacity, - show=show, - extensions=extensions, - ) - self._attach_node(node, self._parent_node(parent)) - return node - - def add_group( - self, - name: str, - parent: SceneObject | SceneNode | None = None, + object_type: type[SceneObject] = SceneObject, **kwargs: Any, - ) -> Group: - """Add a group to the scene.""" - group = Group(name=name, **kwargs) - return cast(Group, self.add(group, parent=parent)) + ) -> SceneObject: + """Create and attach an unresolved stable scene object.""" + return self.add_object(object_type(item_guid=item_guid, name=name, **kwargs), parent=parent) + + def add_group(self, name: str, parent: SceneObject | SceneNode | None = None, **kwargs: Any) -> Group: + """Create and attach a stable group object.""" + return cast(Group, self.add_object(Group(name=name, **kwargs), parent=parent)) def remove(self, sceneobject: SceneObject | SceneNode) -> None: - """Remove a scene node and all descendants.""" - node = sceneobject.node if isinstance(sceneobject, sceneobject_module.SceneObject) else sceneobject - if node is None or node.guid not in self._nodes: - raise ValueError("The scene object or node does not belong to this scene.") + """Remove an object hierarchy while preserving stable object identity.""" + node = sceneobject if isinstance(sceneobject, SceneNode) else self.node(sceneobject.guid) + if node.object_guid not in self._nodes: + raise ValueError("The scene object does not belong to this scene.") if node.is_root: raise ValueError("The root group cannot be removed.") - removed_nodes = list(node.traverse()) + for removed_node in removed_nodes: + obj = self._objects[removed_node.object_guid] + for context_name, resources in tuple(self._resources.items()): + if obj.guid in resources: + self.clear_object(obj, context=context_name) self.tree.remove(node) for removed_node in removed_nodes: - removed_object = self._objects.pop(removed_node.guid, None) - self._nodes.pop(removed_node.guid) - if removed_object is not None: - removed_object._detached_item = self._items.get(removed_node.item_guid) if removed_node.item_guid is not None else None - removed_object._scene = None - removed_object._node = None + obj = self._objects.pop(removed_node.object_guid) + self._nodes.pop(removed_node.object_guid) + obj._item = self._items.get(obj.item_guid) if obj.item_guid is not None else None + obj._scene = None self._prune_items() def replace_item(self, sceneobject: SceneObject, item: Data) -> None: - """Replace the bound data item referenced by a scene node.""" - if sceneobject.scene is not self or sceneobject.node is None: + """Replace a scene object's represented item.""" + if sceneobject.scene is not self: raise ValueError("The scene object does not belong to this scene.") existing = self._items.get(item.guid) if existing is not None and existing is not item: raise ValueError("A different data item with the same GUID is already bound.") - old_guid = sceneobject.node.item_guid - sceneobject.node.item_guid = item.guid + old_guid = sceneobject.item_guid sceneobject._item_guid = item.guid + sceneobject._item = item self._items[item.guid] = item sceneobject.invalidate() if old_guid is not None: self._prune_items() - # ========================================================================== - # Drawing and queries - # ========================================================================== - - def clear_context(self, guids: list[Any] | None = None) -> None: - """Clear objects from the visualization context.""" - clear(guids) - - def clear(self, clear_scene: bool = True, clear_context: bool = True) -> None: - """Clear the scene description and/or its visualization context.""" - guids = self.context_objects - for sceneobject in self._objects.values(): - sceneobject._guids = None - if clear_scene: - for node in tuple(self.root_node.children): - self.remove(node) - if clear_context: - self.clear_context(guids) - - def draw(self) -> list[Any]: - """Draw all visible scene nodes in the current context.""" - if not self.context: - raise ValueError("No context detected.") - self.bind_context(self.context) - before_draw() - drawn_objects: list[Any] = [] + def draw_object(self, sceneobject: SceneObject, context: str | None = None, runtime: object | None = None) -> list[object]: + """Draw one stable scene object through a registered operation.""" + context_name = self._context_name(context) + if sceneobject.guid in self._resources.get(context_name, {}): + self.clear_object(sceneobject, context=context_name) + implementation = get_scene_operation(context_name, type(sceneobject), "draw") + result = implementation(self, sceneobject, runtime) + handles = list(result or []) + self._resources.setdefault(context_name, {})[sceneobject.guid] = (runtime, handles) + return handles + + def clear_object(self, sceneobject: SceneObject, context: str | None = None, runtime: object | None = None) -> None: + """Clear one object's transient context resources.""" + context_name = self._context_name(context) + stored_runtime, resources = self._resources.get(context_name, {}).get(sceneobject.guid, (runtime, [])) + implementation = get_scene_operation(context_name, type(sceneobject), "clear") + implementation(self, sceneobject, runtime if runtime is not None else stored_runtime, tuple(resources)) + self._resources.get(context_name, {}).pop(sceneobject.guid, None) + + def draw(self, context: str | None = None, runtime: object | None = None) -> list[object]: + """Draw all visible non-root objects through one explicit context.""" + context_name = self._context_name(context) + drawn: list[object] = [] for sceneobject in self.objects: if sceneobject.show: - drawn_objects += sceneobject.draw() - after_draw(drawn_objects) - return drawn_objects + drawn.extend(self.draw_object(sceneobject, context=context_name, runtime=runtime)) + return drawn + + def clear(self, clear_scene: bool = True, clear_context: bool = True, context: str | None = None, runtime: object | None = None) -> None: + """Clear durable scene content and/or transient context resources.""" + if clear_context: + contexts = [context] if context is not None else ([self.context] if self.context is not None else list(self._resources)) + for context_name in contexts: + for sceneobject in tuple(self.objects): + if sceneobject.guid in self._resources.get(context_name, {}): + self.clear_object(sceneobject, context=context_name, runtime=runtime) + if clear_scene: + for child in tuple(self.root_node.children): + self.remove(child) - def redraw(self) -> None: - """Clear and draw the scene in the current context.""" - self.clear(clear_scene=False, clear_context=True) - self.draw() + def redraw(self, context: str | None = None, runtime: object | None = None) -> None: + """Clear transient resources and draw the durable scene again.""" + self.clear(clear_scene=False, clear_context=True, context=context, runtime=runtime) + self.draw(context=context, runtime=runtime) def find_node_by_name(self, name: str) -> SceneNode | None: - """Find the first durable node with the given name.""" - return next((node for node in self.tree.nodes if not node.is_root and node.name == name), None) + """Find the first hierarchy node whose scene object has a name.""" + return next((node for node in self.tree.nodes if not node.is_root and self.object(node).name == name), None) def find_by_name(self, name: str) -> SceneObject | None: - """Find the runtime adapter for the first node with the given name.""" + """Find the first scene object with a name.""" node = self.find_node_by_name(name) return self.object(node) if node is not None else None def find_by_itemtype(self, itemtype: type[Data]) -> SceneObject | None: - """Find the first bound scene object with a data item of the given type.""" - return next((obj for obj in self.objects if isinstance(obj.item, itemtype)), None) + """Find the first resolved object representing an item type.""" + return next((obj for obj in self.objects if obj.item_guid in self._items and isinstance(obj.item, itemtype)), None) def find_all_by_itemtype(self, itemtype: type[Data]) -> list[SceneObject]: - """Find all bound scene objects with data items of the given type.""" - return [obj for obj in self.objects if isinstance(obj.item, itemtype)] - - # ========================================================================== - # Private helpers - # ========================================================================== - - def _attach_node(self, node: SceneNode, parent: SceneNode) -> None: - if node.guid in self._nodes: - raise ValueError(f"A scene node with GUID {node.guid} is already stored.") - self.tree.add(node, parent=parent) - self._nodes[node.guid] = node + """Find all resolved objects representing an item type.""" + return [obj for obj in self.objects if obj.item_guid in self._items and isinstance(obj.item, itemtype)] def _index_nodes(self) -> None: indexed: dict[UUID, SceneNode] = {} for node in self.tree.nodes: - if node.guid in indexed: - raise ValueError(f"Duplicate scene-node GUID: {node.guid}.") - indexed[node.guid] = node + if node.object_guid in indexed: + raise ValueError(f"Scene object {node.object_guid} appears more than once in the hierarchy.") + indexed[node.object_guid] = node self._nodes = indexed def _parent_node(self, parent: SceneObject | SceneNode | None) -> SceneNode: if parent is None: return self.root_node - if isinstance(parent, SceneObject): - if parent.scene is not self or parent.node is None: - raise ValueError("The parent scene object does not belong to this scene.") - return parent.node - if parent.guid not in self._nodes: - raise ValueError("The parent scene node does not belong to this scene.") - return parent + node = self.node(parent.guid) if isinstance(parent, SceneObject) else parent + if node.object_guid not in self._nodes: + raise ValueError("The parent does not belong to this scene.") + return node + + def _referenced_item_guids(self) -> set[UUID]: + return {obj.item_guid for obj in self._objects.values() if obj.item_guid is not None} def _prune_items(self) -> None: - referenced = self._referenced_item_guids() - for guid in set(self._items) - referenced: + for guid in set(self._items) - self._referenced_item_guids(): del self._items[guid] - def _referenced_item_guids(self) -> set[UUID]: - return {node.item_guid for node in self.tree.nodes if node.item_guid is not None} + def _context_name(self, context: str | None) -> str: + context_name = context or self.context or detect_current_context() + if context_name is None: + raise ValueError("No unambiguous visualization context is available.") + return context_name diff --git a/src/compas/scene/sceneobject.py b/src/compas/scene/sceneobject.py index 88556ceb938..794e1ca23eb 100644 --- a/src/compas/scene/sceneobject.py +++ b/src/compas/scene/sceneobject.py @@ -1,14 +1,11 @@ -import inspect from collections.abc import Iterator from dataclasses import dataclass from functools import reduce from operator import mul -from typing import TYPE_CHECKING from typing import Any from typing import ClassVar from typing import Literal from typing import Self -from typing import cast from uuid import UUID from compas.colors import Color @@ -16,39 +13,32 @@ from compas.geometry import Frame from compas.geometry import Transformation -from .context import clear -from .context import get_sceneobject_cls +from .context import get_scene_operation from .descriptors.color import ColorAttribute +from .exceptions import SceneItemNotBoundError from .exceptions import SceneStateError -if TYPE_CHECKING: - from compas.datastructures import Tree - from compas.scene import Scene - - from ._scenenode import SceneNode - - _MISSING = object() @dataclass(frozen=True, slots=True) class SceneStateField: - """Declaration of one durable adapter extension field. + """Declaration of one durable semantic scene-object field. Parameters ---------- name Stable serialized field name. value_type - Runtime type or tuple of runtime types accepted for the field. + Runtime type or tuple of accepted runtime types. attribute_name - Adapter attribute carrying the value. Defaults to `name`. + Object attribute carrying the value. Defaults to `name`. default - Value applied when an omitted field is loaded. + Value used when an optional serialized field is omitted. required Whether omission without a default is an error. omit_default - Whether values equal to `default` are omitted when a node is created. + Whether a value equal to `default` is omitted. """ @@ -61,39 +51,36 @@ class SceneStateField: @property def attribute(self) -> str: - """Return the adapter attribute name carrying this field.""" + """Return the ordinary object attribute carrying the value.""" return self.attribute_name or self.name class SceneObject(Data): - """Base class for all scene objects. + """Stable backend-neutral description of one visualized item. Parameters ---------- item - The item to visualize. + The independently serializable item represented by this object. + item_guid + The GUID of an unresolved represented item. name - The name of the scene object. This is independent of the name of the - underlying data item because multiple scene objects can refer to the - same item. + The scene-object name. color - The color of the object. + Base display color. opacity - The opacity of the object. + Display opacity between zero and one. show - Whether to show the object. + Whether the object is visible. transformation - The local transformation relative to the parent scene object. - context - The context in which the scene object is created. - **kwargs - Additional keyword arguments to create the scene object for the item. + Local transformation relative to the parent scene object. + extensions + Unknown namespaced state preserved for forward round trips. Notes ----- - `SceneObject(...)` may dispatch to a registered subclass. The internal - `sceneobject_type` factory override is accepted by `__new__`, but is not an - initialization parameter of constructed scene objects. + Visualization backends provide context-keyed `draw` and `clear` + operations. They never replace this object's Python type. """ @@ -101,335 +88,238 @@ class SceneObject(Data): __scene_state_namespace__: ClassVar[str | None] = None __scene_state_fields__: ClassVar[tuple[SceneStateField, ...]] = () - # ========================================================================== - # Dunder methods - # ========================================================================== + @property + def __data__(self) -> dict[str, Any]: + """Return the backend-neutral durable representation.""" + extensions = {namespace: dict(values) for namespace, values in self._extensions.items()} + for namespace, fields in self.scene_state_fields().items(): + values: dict[str, object] = {} + for field in fields: + value = getattr(self, field.attribute) + if not isinstance(value, field.value_type): + raise SceneStateError(f"Invalid scene-state field {namespace}.{field.name}: {type(value).__name__}.") + if field.omit_default and value == field.default: + continue + values[field.name] = value + if values: + extensions.setdefault(namespace, {}).update(values) + elif namespace not in extensions: + extensions.pop(namespace, None) + return { + "item": str(self.item_guid) if self.item_guid is not None else None, + "transformation": self.transformation, + "color": self.color, + "opacity": self.opacity, + "show": self.show, + "extensions": extensions, + } - def __new__( - cls, - item: Data | None = None, - context: str | None = None, - sceneobject_type: type["SceneObject"] | None = None, - _detect_context: bool = True, - **kwargs: Any, - ) -> Self: - sceneobject_cls = get_sceneobject_cls( - item, - context=context, - sceneobject_type=sceneobject_type, - detect_context=_detect_context, + @classmethod + def __from_data__(cls, data: dict[str, Any]) -> Self: + """Construct a stable scene object from its closed schema.""" + expected = {"item", "transformation", "color", "opacity", "show", "extensions"} + unknown = set(data) - expected + if unknown: + raise ValueError("Unknown scene-object fields: {}.".format(", ".join(sorted(unknown)))) + missing = expected - set(data) + if missing: + raise ValueError("Missing scene-object fields: {}.".format(", ".join(sorted(missing)))) + extensions = data["extensions"] + if not isinstance(extensions, dict): + raise TypeError("Scene-object extensions should be a mapping.") + kwargs: dict[str, Any] = {} + unknown_extensions: dict[str, dict[str, object]] = {} + for namespace, fields in cls.scene_state_fields().items(): + values = extensions.get(namespace, {}) + if not isinstance(values, dict): + raise TypeError(f"Scene-object extension '{namespace}' should be a mapping.") + field_names = {field.name for field in fields} + extra = set(values) - field_names + if extra: + unknown_extensions[namespace] = {name: values[name] for name in extra} + for field in fields: + if field.name in values: + value = values[field.name] + if not isinstance(value, field.value_type): + raise SceneStateError(f"Invalid scene-state field {namespace}.{field.name}: {type(value).__name__}.") + kwargs[field.attribute] = value + elif field.default is not _MISSING: + kwargs[field.attribute] = field.default + elif field.required: + raise SceneStateError(f"Missing scene-state field {namespace}.{field.name}.") + item_guid = UUID(data["item"]) if data["item"] is not None else None + for namespace, values in extensions.items(): + if namespace not in cls.scene_state_fields(): + unknown_extensions[namespace] = dict(values) + obj = cls( + item_guid=item_guid, + transformation=data["transformation"], + color=data["color"], + opacity=data["opacity"], + show=data["show"], + extensions=unknown_extensions, **kwargs, ) - return cast(Self, object.__new__(sceneobject_cls)) + return obj def __init__( self, item: Data | None = None, + *, + item_guid: UUID | None = None, name: str | None = None, color: Color | None = None, opacity: float = 1.0, show: bool = True, transformation: Transformation | None = None, - context: str | None = None, + extensions: dict[str, dict[str, object]] | None = None, **kwargs: object, ) -> None: - self._node: SceneNode | None = 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))) - - # This factory-only option has already been handled by __new__. - kwargs.pop("sceneobject_type", None) - kwargs.pop("_detect_context", None) - name = name or getattr(item, "name", None) - self._node: SceneNode | None = None - super().__init__(name=name) - # 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" - self.context = context - self.attributes = kwargs - self._item_guid: UUID | None = item.guid if item else None - self._detached_item = item - self._scene: Scene | None = None - self._guids: list[object] | None = [] + if item is not None and not isinstance(item, Data): + raise TypeError("A scene-object item should be a Data object.") + if item is not None and item_guid is not None and item.guid != item_guid: + raise ValueError("The supplied item and item GUID do not match.") + if isinstance(opacity, bool) or not isinstance(opacity, (int, float)): + raise TypeError("Scene-object opacity should be a number.") + if not 0.0 <= opacity <= 1.0: + raise ValueError("Scene-object opacity should be between 0.0 and 1.0.") + if not isinstance(show, bool): + raise TypeError("Scene-object show should be a boolean.") + if transformation is not None and not isinstance(transformation, Transformation): + raise TypeError("Scene-object transformation should be a Transformation or None.") + if kwargs: + raise TypeError(f"Unexpected scene-object arguments: {', '.join(sorted(kwargs))}.") + super().__init__(name=name or getattr(item, "name", None)) + self._item_guid = item.guid if item is not None else item_guid + self._item = item + self._scene: Any = None self._transformation = transformation self._transformation_revision = 0 - self._contrastcolor = None - self.color = color or self.color - self._opacity = opacity - self._show = show - - def __repr__(self) -> str: - return "<{}: {}>".format(self.__class__.__name__, self.name) - - def __getattribute__(self, name: str) -> object: - """Read declared durable extension attributes from the bound node.""" - if not name.startswith("_"): - try: - node = object.__getattribute__(self, "_node") - except AttributeError: - node = None - if node is not None: - for namespace, fields in type(self).scene_state_fields().items(): - for field in fields: - if field.attribute == name: - values = node.extensions.get(namespace, {}) - if field.name in values: - value = values[field.name] - if not isinstance(value, field.value_type): - raise SceneStateError(f"Invalid value for scene-state field {namespace}.{field.name}: {type(value).__name__}.") - return value - if field.default is not _MISSING: - return field.default - break - return object.__getattribute__(self, name) - - def __setattr__(self, name: str, value: object) -> None: - """Write declared durable extension attributes directly to the bound node.""" - if not name.startswith("_"): - try: - node = object.__getattribute__(self, "_node") - except AttributeError: - node = None - if node is not None: - for namespace, fields in type(self).scene_state_fields().items(): - for field in fields: - if field.attribute == name: - descriptor = inspect.getattr_static(type(self), name, None) - if hasattr(descriptor, "__set__"): - object.__setattr__(self, name, value) - value = object.__getattribute__(self, name) - if not isinstance(value, field.value_type): - raise SceneStateError(f"Invalid value for scene-state field {namespace}.{field.name}: {type(value).__name__}.") - node.extensions.setdefault(namespace, {})[field.name] = value - return - object.__setattr__(self, name, value) - - # ========================================================================== - # Properties - # ========================================================================== - - @property - def guid(self) -> UUID: - """Return the durable GUID of the bound scene node. - - A detached adapter has its own temporary data-object GUID. Once bound, - this property exposes the node identity rather than an adapter identity. - - """ - if self._node is not None: - return self._node.guid - return super().guid + self._contrastcolor: Color | None = None + self.color = color or Color.white() + self.opacity = float(opacity) + self.show = show + self._extensions = {namespace: dict(values) for namespace, values in (extensions or {}).items()} @property - def name(self) -> str: - """Return the scene-node name.""" - if self._node is not None: - return self._node.name - return super().name - - @name.setter - def name(self, name: str) -> None: - if self._node is not None: - self._node.name = name - else: - self._name = name - - @property - def scene(self) -> "Scene | None": - """The scene containing this object, if it has been added to one.""" - return self._scene + def item_guid(self) -> UUID | None: + """Return the GUID of the represented item.""" + return self._item_guid @property def item(self) -> Data | None: - """The data item represented by this scene object.""" - if self._item_guid is None: + """Return the represented item, or raise if its reference is unresolved.""" + if self.item_guid is None: return None - if self.scene is None: - return getattr(self, "_detached_item", None) - return self.scene.item(self._item_guid, node_guid=self.guid) + if self.scene is not None: + return self.scene.item(self.item_guid, object_guid=self.guid) + if self._item is None: + raise SceneItemNotBoundError(self.guid, self.item_guid) + return self._item @property - def item_guid(self) -> UUID | None: - """The GUID of the represented data item.""" - if self._node is not None: - return self._node.item_guid - return self._item_guid + def scene(self) -> Any: + """Return the scene containing this object, if attached.""" + return self._scene @property - def node(self) -> "SceneNode | None": - """The durable scene node bound to this adapter.""" - return self._node + def node(self) -> Any: + """Return the hierarchy node, if attached.""" + return self.scene.node(self.guid) if self.scene is not None else None @property - def parent(self) -> "SceneObject | None": - """The parent scene object.""" - if self.scene is None: - return None - node = self._node - if node is None: - return None - if node.parent is None: + def parent(self) -> Self | None: + """Return the parent scene object, if any.""" + node = self.node + if node is None or node.parent is None: return None - return self.scene.object(node.parent) + return self.scene.object(node.parent.object_guid) @property def children(self) -> tuple["SceneObject", ...]: - """The child scene objects.""" - if self.scene is None: - return () - node = self._node + """Return child scene objects in hierarchy order.""" + node = self.node if node is None: return () - return tuple(self.scene.object(child) for child in node.children) + return tuple(self.scene.object(child.object_guid) for child in node.children) @property - def tree(self) -> "Tree[SceneNode] | None": - """The hierarchy tree containing this scene object.""" - return self.scene.tree if self.scene else None + def tree(self) -> Any: + """Return the owning scene hierarchy, if attached.""" + return self.scene.tree if self.scene is not None else None @property def is_root(self) -> bool: - """Whether this scene object is the root group.""" - return bool(self.scene and self.scene._nodes[self.guid].is_root) + """Return whether this object is the hierarchy root.""" + return bool(self.node and self.node.is_root) @property def is_leaf(self) -> bool: - """Whether this scene object has no children.""" + """Return whether this object has no children.""" return not self.children @property def is_branch(self) -> bool: - """Whether this scene object is neither the root nor a leaf.""" + """Return whether this object is neither root nor leaf.""" return not self.is_root and not self.is_leaf @property def ancestors(self) -> Iterator["SceneObject"]: - """An iterator over the ancestor scene objects.""" - if self.scene is None: + """Iterate over ancestor scene objects.""" + if self.node is None: return iter(()) - node = self.scene._nodes[self.guid] - return (self.scene._objects[ancestor.object_guid] for ancestor in node.ancestors) + return (self.scene.object(node.object_guid) for node in self.node.ancestors) @property def descendants(self) -> Iterator["SceneObject"]: - """An iterator over the descendant scene objects.""" - if self.scene is None: + """Iterate over descendant scene objects.""" + if self.node is None: return iter(()) - node = self.scene._nodes[self.guid] - return (self.scene._objects[descendant.object_guid] for descendant in node.descendants) - - @property - def guids(self) -> list[object]: - """The identifiers of objects created in the visualization context.""" - return self._guids or [] - - @property - def frame(self) -> Frame: - """The frame represented by the world transformation. - - Setting the frame updates the local transformation relative to the - parent scene object. - - """ - return Frame.from_transformation(self.worldtransformation) - - @frame.setter - def frame(self, frame: Frame) -> None: - self.worldtransformation = Transformation.from_frame(frame) + return (self.scene.object(node.object_guid) for node in self.node.descendants) @property def transformation(self) -> Transformation | None: - """The local transformation relative to the parent scene object. - - Setting the transformation invalidates transformation-dependent caches - on this object. Descendant caches detect the change lazily. - - """ - if self._node is not None: - return self._node.transformation + """Return the local transformation.""" return self._transformation @transformation.setter def transformation(self, transformation: Transformation | None) -> None: - if self._node is not None: - self._node.transformation = transformation - else: - self._transformation = transformation - self._transformation_revision += 1 - self.invalidate() + if transformation is not None and not isinstance(transformation, Transformation): + raise TypeError("Scene-object transformation should be a Transformation or None.") + self._transformation = transformation + if hasattr(self, "_transformation_revision"): + self._transformation_revision += 1 + self.invalidate() @property - def opacity(self) -> float: - """The display opacity of the scene node.""" - if self._node is not None: - return self._node.opacity - return self._opacity - - @opacity.setter - def opacity(self, opacity: float) -> None: - if self._node is not None: - self._node.opacity = opacity - else: - self._opacity = opacity + def frame(self) -> Frame: + """Return the frame represented by the world transformation.""" + return Frame.from_transformation(self.worldtransformation) - @property - def show(self) -> bool: - """Whether the scene node is visible.""" - if self._node is not None: - return self._node.show - return self._show - - @show.setter - def show(self, show: bool) -> None: - if self._node is not None: - self._node.show = show - else: - self._show = show + @frame.setter + def frame(self, frame: Frame) -> None: + self.worldtransformation = Transformation.from_frame(frame) @property def worldtransformation(self) -> Transformation: - """The transformation from local coordinates to world coordinates. - - The transformation is computed from this object's local transformation - and those of its ancestors. Setting it derives the corresponding local - transformation relative to the parent. - - """ + """Return the transformation from local to world coordinates.""" transformations = [self.transformation] if self.transformation else [] parent = self.parent while parent and not parent.is_root: if parent.transformation: transformations.append(parent.transformation) parent = parent.parent - if transformations: - worldtransformation = reduce(mul, transformations[::-1]) - else: - worldtransformation = Transformation() - - return worldtransformation + return reduce(mul, transformations[::-1]) if transformations else Transformation() @worldtransformation.setter - def worldtransformation(self, worldtransformation: Transformation) -> None: - if isinstance(self.parent, SceneObject): - self.transformation = self.parent.worldtransformation.inverse() * worldtransformation - else: - self.transformation = worldtransformation + def worldtransformation(self, transformation: Transformation) -> None: + self.transformation = self.parent.worldtransformation.inverse() * transformation if self.parent else transformation @property def contrastcolor(self) -> Color | None: - """A lighter or darker color contrasting with the object color. - - The computed color is cached. Setting this property coerces the value to - a `Color`. - - """ - if not self._contrastcolor: - if self.color: - if self.color.is_light: - self._contrastcolor = self.color.darkened(50) - else: - self._contrastcolor = self.color.lightened(50) + """Return a cached color contrasting with the base color.""" + if self._contrastcolor is None and self.color: + self._contrastcolor = self.color.darkened(50) if self.color.is_light else self.color.lightened(50) return self._contrastcolor @contrastcolor.setter @@ -438,19 +328,7 @@ def contrastcolor(self, color: Color) -> None: @classmethod def scene_state_fields(cls) -> dict[str, tuple[SceneStateField, ...]]: - """Return durable extension declarations composed across the MRO. - - Returns - ------- - dict[str, tuple[SceneStateField, ...]] - Fields grouped by their stable owning namespace. - - Raises - ------ - SceneStateError - If declarations have no namespace or contain conflicting names. - - """ + """Return durable field declarations composed across the MRO.""" declarations: dict[str, tuple[SceneStateField, ...]] = {} attributes: set[str] = set() for owner in reversed(cls.mro()): @@ -484,38 +362,10 @@ def scene_state_fields(cls) -> dict[str, tuple[SceneStateField, ...]]: declarations[namespace] = tuple(fields) return declarations - # ========================================================================== - # Methods - # ========================================================================== - - def invalidate(self) -> None: - """Clear cached data derived from the represented item or transformation. - - Call this after mutating an underlying item through a path that the scene - object cannot observe directly. Descendants detect ancestor - transformation changes automatically and do not need to be invalidated. - - """ - self._invalidate_transformation_cache() - def add(self, item: Data, **kwargs: Any) -> "SceneObject": - """Add a child item to the scene object. - - Parameters - ---------- - item - The item to add. - **kwargs - Additional keyword arguments to create the scene object for the item. - - Returns - ------- - SceneObject - The scene object associated with the added item. - - """ + """Add a child item through the owning scene.""" if self.scene is None: - raise RuntimeError("A detached scene object cannot own child objects.") + raise RuntimeError("A detached scene object cannot own children.") return self.scene.add(item, parent=self, **kwargs) def traverse( @@ -523,111 +373,36 @@ def traverse( strategy: Literal["depthfirst", "breadthfirst"] = "depthfirst", order: Literal["preorder", "postorder"] = "preorder", ) -> Iterator["SceneObject"]: - """Traverse this scene-object subtree. - - Parameters - ---------- - strategy - The traversal strategy. - order - The depth-first traversal order. - - Yields - ------ - SceneObject - The next scene object in the traversal. - - """ - if self.scene is None: + """Iterate over this hierarchy subtree.""" + if self.node is None: yield self return - node = self.scene._nodes[self.guid] - for descendant in node.traverse(strategy=strategy, order=order): - yield self.scene._objects[descendant.object_guid] - - def draw(self) -> list[object]: - """The main drawing method.""" - raise NotImplementedError - - def clear(self) -> None: - """The main clearing method.""" - clear(guids=self.guids) - self._guids = None - - # ========================================================================== - # Private helpers - # ========================================================================== - - def _bind(self, scene: "Scene", node: "SceneNode") -> None: - """Bind this runtime adapter to a durable scene node.""" - self._validate_declared_attributes() - self._scene = scene - self._node = node - self._guid = None - self._item_guid = node.item_guid - self._transformation = node.transformation - self._apply_extension_state() - - def _capture_extension_state(self) -> dict[str, dict[str, object]]: - """Capture declared state while creating a durable node.""" - state: dict[str, dict[str, object]] = {} - for namespace, fields in self.scene_state_fields().items(): - values: dict[str, object] = {} - for field in fields: - try: - value = getattr(self, field.attribute) - except AttributeError: - raise SceneStateError(f"Missing adapter attribute for scene-state field {namespace}.{field.name}: {field.attribute}.") from None - if not isinstance(value, field.value_type): - raise SceneStateError(f"Invalid value for scene-state field {namespace}.{field.name}: {type(value).__name__}.") - if field.omit_default and value == field.default: - continue - values[field.name] = value - if values: - state[namespace] = values - return state + for node in self.node.traverse(strategy=strategy, order=order): + yield self.scene.object(node.object_guid) - def _apply_extension_state(self) -> None: - """Apply known declared state while preserving unknown namespaced values.""" - if self._node is None: - return - for namespace, fields in self.scene_state_fields().items(): - values = self._node.extensions.get(namespace) - if values is None: - values = {} - for field in fields: - if field.name not in values: - if field.default is not _MISSING: - continue - if field.required: - raise SceneStateError(f"Missing scene-state field {namespace}.{field.name}.") - continue - value = values[field.name] - if not isinstance(value, field.value_type): - raise SceneStateError(f"Invalid value for scene-state field {namespace}.{field.name}: {type(value).__name__}.") + def draw(self, context: str | None = None, runtime: object | None = None) -> list[object]: + """Draw this object through a context-keyed operation.""" + if self.scene is None: + raise RuntimeError("A detached scene object cannot be drawn.") + return self.scene.draw_object(self, context=context, runtime=runtime) - def _validate_declared_attributes(self) -> None: - """Validate declared adapter attributes before binding to a node.""" - for namespace, fields in self.scene_state_fields().items(): - for field in fields: - try: - value = getattr(self, field.attribute) - except AttributeError: - raise SceneStateError(f"Missing adapter attribute for scene-state field {namespace}.{field.name}: {field.attribute}.") from None - if not isinstance(value, field.value_type): - raise SceneStateError(f"Invalid adapter attribute for scene-state field {namespace}.{field.name}: {type(value).__name__}.") + def clear(self, context: str | None = None, runtime: object | None = None) -> None: + """Clear this object's transient context resources.""" + if self.scene is None: + raise RuntimeError("A detached scene object has no context resources.") + self.scene.clear_object(self, context=context, runtime=runtime) - def _invalidate_transformation_cache(self) -> None: - """Invalidate cached data derived from the local transformation. + def invalidate(self) -> None: + """Clear caches derived from the represented item or transformation.""" + self._invalidate_transformation_cache() - Scene object types with transformation-dependent caches override this - hook. The base scene object has no such cache. + def _operation(self, context: str, operation: str) -> Any: + return get_scene_operation(context, type(self), operation) - """ + def _invalidate_transformation_cache(self) -> None: pass def _worldtransformation_revision(self) -> tuple[int, ...]: - """Return the revisions that determine the current world transformation.""" revisions = [self._transformation_revision] parent = self.parent while parent and not parent.is_root: diff --git a/src/compas/scene/volmeshobject.py b/src/compas/scene/volmeshobject.py index b3e075955f6..5eb1e29b351 100644 --- a/src/compas/scene/volmeshobject.py +++ b/src/compas/scene/volmeshobject.py @@ -91,7 +91,7 @@ def volmesh(self) -> VolMesh: def volmesh(self, volmesh: VolMesh) -> None: if self.scene is None: self._item_guid = volmesh.guid - self._detached_item = volmesh + self._item = volmesh else: self.scene.replace_item(self, volmesh) self.transformation = None @@ -122,92 +122,6 @@ def vertex_xyz(self, vertex_xyz: dict[int, list[RealInput]]) -> None: self._vertex_xyz = vertex_xyz self._vertex_xyz_revision = None - # ========================================================================== - # Methods - # ========================================================================== - - def draw_vertices(self) -> list[object]: - """Draw the vertices of the volmesh. - - The vertices are drawn based on the values of - - * `self.show_vertices` - * `self.vertexcolor` - * `self.vertexsize` - - Returns - ------- - list[object] - The identifiers of the objects representing the vertices in the visualization context. - - """ - raise NotImplementedError - - def draw_edges(self) -> list[object]: - """Draw the edges of the volmesh. - - The edges are drawn based on the values of - - * `self.show_edges` - * `self.edgecolor` - * `self.edgewidth` - - Returns - ------- - list[object] - The identifiers of the objects representing the edges in the visualization context. - - """ - raise NotImplementedError - - def draw_faces(self) -> list[object]: - """Draw the faces of the volmesh. - - The faces are drawn based on the values of - - * `self.show_faces` - * `self.facecolor` - - Returns - ------- - list[object] - The identifiers of the objects representing the faces in the visualization context. - - """ - raise NotImplementedError - - def draw_cells(self) -> list[object]: - """Draw the cells of the volmesh. - - The cells are drawn based on the values of - - * `self.show_cells` - * `self.cellcolor` - - Returns - ------- - list[object] - The identifiers of the objects representing the cells in the visualization context. - - """ - raise NotImplementedError - - def clear_vertices(self) -> None: - """Clear the vertices of the volmesh.""" - raise NotImplementedError - - def clear_edges(self) -> None: - """Clear the edges of the volmesh.""" - raise NotImplementedError - - def clear_faces(self) -> None: - """Clear the faces of the volmesh.""" - raise NotImplementedError - - def clear_cells(self) -> None: - """Clear the cells of the volmesh.""" - raise NotImplementedError - # ========================================================================== # Private helpers # ========================================================================== diff --git a/tests/compas/files/test_gltf_conversions.py b/tests/compas/files/test_gltf_conversions.py index be010e02687..2a033add47a 100644 --- a/tests/compas/files/test_gltf_conversions.py +++ b/tests/compas/files/test_gltf_conversions.py @@ -9,7 +9,6 @@ from compas.files.gltf.constants import MODE_TRIANGLE_STRIP from compas.files.gltf.data_classes import PrimitiveData from compas.files.gltf.gltf_conversions import GLTFConversionWarning -from compas.files.gltf.gltf_conversions import _base_scene_context from compas.files.gltf.gltf_conversions import gltf_line_loop_to_polyline from compas.files.gltf.gltf_conversions import gltf_lines_to_lines from compas.files.gltf.gltf_conversions import gltf_points_to_pointcloud @@ -108,13 +107,12 @@ def test_gltf_to_scene_expands_loop_and_fan_modes(mode): def test_scene_to_gltf_preserves_hierarchy_and_supported_geometry(): - with _base_scene_context(): - scene = Scene(name="Model") - group = scene.add_group("Group", transformation=Translation.from_vector([1, 2, 3])) - mesh = Mesh.from_vertices_and_faces([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], [[0, 1, 2, 3]]) - scene.add(mesh, parent=group, name="Surface") - scene.add(Pointcloud([[0, 0, 0], [1, 1, 1]]), parent=group, name="Points") - scene.add(Polyline([[0, 0, 0], [1, 0, 0], [1, 1, 0]]), parent=group, name="Path") + scene = Scene(name="Model") + group = scene.add_group("Group", transformation=Translation.from_vector([1, 2, 3])) + mesh = Mesh.from_vertices_and_faces([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], [[0, 1, 2, 3]]) + scene.add(mesh, parent=group, name="Surface") + scene.add(Pointcloud([[0, 0, 0], [1, 1, 1]]), parent=group, name="Points") + scene.add(Polyline([[0, 0, 0], [1, 0, 0], [1, 1, 0]]), parent=group, name="Path") document = scene.to_gltf() @@ -131,10 +129,9 @@ def test_scene_to_gltf_preserves_hierarchy_and_supported_geometry(): def test_scene_to_gltf_warns_for_unsupported_items_and_preserves_children(): - with _base_scene_context(): - scene = Scene(name="Model") - unsupported = scene.add(Vector(1, 0, 0), name="Direction") - scene.add(Pointcloud([[0, 0, 0]]), parent=unsupported, name="Child") + scene = Scene(name="Model") + unsupported = scene.add(Vector(1, 0, 0), name="Direction") + scene.add(Pointcloud([[0, 0, 0]]), parent=unsupported, name="Child") with pytest.warns(GLTFConversionWarning, match="Vector"): document = scene.to_gltf() diff --git a/tests/compas/scene/test_scene.py b/tests/compas/scene/test_scene.py index 8a098e47b48..46756289ff0 100644 --- a/tests/compas/scene/test_scene.py +++ b/tests/compas/scene/test_scene.py @@ -1,384 +1,308 @@ -import pytest # noqa: F401 -from compas.scene import context -from compas.scene import register -from compas.scene import Scene -from compas.scene import SceneObject -from compas.scene import SceneObjectNotRegisteredError +from uuid import uuid4 + +import pytest + from compas.data import Data +from compas.datastructures import Graph +from compas.datastructures import VolMesh from compas.geometry import Box from compas.geometry import Frame from compas.geometry import Translation -from compas.geometry import Transformation -from compas.scene import Group +from compas.scene import GraphObject from compas.scene import GeometryObject -from compas.datastructures import Tree -from compas.datastructures import Graph -from compas.datastructures import VolMesh - - -@pytest.fixture(autouse=True) -def reset_sceneobjects_registration(): - # before each test +from compas.scene import Scene +from compas.scene import SceneNode +from compas.scene import SceneObject +from compas.scene import SceneOperationNotRegisteredError +from compas.scene import SceneStateError +from compas.scene import SceneStateField +from compas.scene import VolMeshObject +from compas.scene import get_sceneobject_cls +from compas.scene import register_scene_operation +from compas.scene.context import SCENEOBJECT_TYPES +from compas.scene.context import SCENE_OPERATIONS +import compas.scene.context as scene_context + + +@pytest.fixture +def operation_registry(): + original = {context: {cls: dict(operations) for cls, operations in types.items()} for context, types in SCENE_OPERATIONS.items()} + discovered = scene_context._OPERATIONS_DISCOVERED + SCENE_OPERATIONS.clear() + scene_context._OPERATIONS_DISCOVERED = True yield - # after each test, reset scene objects - context.ITEM_SCENEOBJECT.clear() + SCENE_OPERATIONS.clear() + SCENE_OPERATIONS.update(original) + scene_context._OPERATIONS_DISCOVERED = discovered -def register_fake_context(): - register(FakeItem, FakeSceneObject, context="fake") +def test_scene_add_returns_a_stable_semantic_type(): + box = Box() + scene = Scene(context="Viewer") + sceneobject = scene.add(box) -class FakeSceneObject(SceneObject): - def draw(self): - pass + assert type(sceneobject) is GeometryObject + assert get_sceneobject_cls(box) is GeometryObject + assert sceneobject.item is box - def clear(self): - pass +def test_scene_node_contains_hierarchy_only(): + scene = Scene() + sceneobject = scene.add(Box(), name="Box", show=False) -class FakeSubSceneObject(SceneObject): - def draw(self): - pass + assert sceneobject.node.__data__ == {"object": str(sceneobject.guid), "children": []} + assert not hasattr(sceneobject.node, "item_guid") + assert not hasattr(sceneobject.node, "show") - def clear(self): - pass +def test_scene_uses_sceneobject_guid_as_sole_hierarchy_key(): + scene = Scene() + parent = scene.add(Box()) + child = scene.add(Box(), parent=parent) -class FakeItem(Data): - pass + assert scene.node(parent.guid).object_guid == parent.guid + assert scene.node(child.guid).parent.object_guid == parent.guid + assert child.parent is parent + assert parent.children == (child,) -class FakeSubItem(FakeItem): - pass +def test_scene_rejects_a_second_node_for_one_object(): + scene = Scene() + sceneobject = scene.add(Box()) + with pytest.raises(ValueError, match="already belongs"): + scene.add_object(sceneobject) -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") - assert isinstance(sceneobject, FakeSceneObject) - item = FakeSubItem() - sceneobject = SceneObject(item, context="fake") - assert isinstance(sceneobject, FakeSubSceneObject) +def test_remove_and_explicit_reattach_preserve_identity_and_state(): + scene = Scene() + sceneobject = scene.add(Box(), name="Box", opacity=0.25, show=False) + guid = sceneobject.guid + scene.remove(sceneobject) + assert sceneobject.scene is None + assert sceneobject.guid == guid + assert sceneobject.name == "Box" + assert sceneobject.opacity == 0.25 + assert sceneobject.show is False -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") - assert isinstance(sceneobject, FakeSceneObject) + other = Scene() + other.add_object(sceneobject) + assert sceneobject.scene is other + assert sceneobject.guid == guid - item = FakeSubItem() - sceneobject = SceneObject(item, context="fake") - assert isinstance(sceneobject, FakeSubSceneObject) +def test_group_children_use_only_explicit_state(): + scene = Scene() + group = scene.add_group("Group", show=False) + child = group.add(Box()) -def test_sceneobject_auto_context_discovery(): - register_fake_context() + assert group.show is False + assert child.show is True - item = FakeItem() - sceneobject = SceneObject(item) - assert isinstance(sceneobject, FakeSceneObject) +def test_draw_and_clear_resolve_context_operations_by_mro(operation_registry): + calls = [] + def draw(scene, sceneobject, runtime): + calls.append(("draw", sceneobject.guid, runtime)) + return ["handle"] -def test_sceneobject_auto_context_discovery_no_context(): - with pytest.raises(SceneObjectNotRegisteredError): - item = FakeSubItem() - _ = SceneObject(item) + def clear(scene, sceneobject, runtime, resources): + calls.append(("clear", sceneobject.guid, runtime, resources)) + register_scene_operation("Test", SceneObject, "draw", draw) + register_scene_operation("Test", SceneObject, "clear", clear) + scene = Scene(context="Test") + sceneobject = scene.add(Box()) -def test_explicit_sceneobject_type_is_only_a_factory_option(): - sceneobject = SceneObject(FakeItem(), sceneobject_type=FakeSceneObject) + assert sceneobject.draw(runtime="runtime") == ["handle"] + sceneobject.clear(runtime="runtime") + assert calls == [ + ("draw", sceneobject.guid, "runtime"), + ("clear", sceneobject.guid, "runtime", ("handle",)), + ] - assert isinstance(sceneobject, FakeSceneObject) - assert "sceneobject_type" not in sceneobject.attributes +def test_context_changes_do_not_replace_scene_objects(operation_registry): + def draw(scene, sceneobject, runtime): + return [] -def test_context_detection_does_not_guess_between_registered_contexts(): - register(FakeItem, FakeSceneObject, context="first") - register(FakeItem, FakeSceneObject, context="second") + register_scene_operation("A", SceneObject, "draw", draw) + register_scene_operation("B", SceneObject, "draw", draw) + scene = Scene(context="A") + sceneobject = scene.add(Box()) - assert context.detect_current_context() is None + scene.draw(context="A") + scene.draw(context="B") + assert scene.object(sceneobject.guid) is sceneobject -def test_bind_context_none_uses_core_registry_without_auto_detection(): - register(Box, FakeSceneObject, context="named") - register(Box, GeometryObject, context=None) - scene = Scene(context="named") - box = Box() - assert isinstance(scene.add(box), FakeSceneObject) - scene.bind_context(None) +def test_duplicate_scene_operation_registration_fails(operation_registry): + def first(*args): + return [] - assert scene.context is None - assert isinstance(scene.objects[0], GeometryObject) + def second(*args): + return [] + register_scene_operation("Test", SceneObject, "draw", first) + with pytest.raises(ValueError, match="already registered"): + register_scene_operation("Test", SceneObject, "draw", second) -def test_sceneobject_transform(): - scene = Scene() - sceneobj1 = scene.add(Box()) - sceneobj1.transformation = Translation.from_vector([10.0, 0.0, 0.0]) - assert sceneobj1.worldtransformation == sceneobj1.transformation - assert sceneobj1.worldtransformation == Translation.from_vector([10.0, 0.0, 0.0]) - assert sceneobj1.frame == Frame([10.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) - assert sceneobj1.frame.to_transformation() == Translation.from_vector([10.0, 0.0, 0.0]) - - sceneobj2 = scene.add(Box(), parent=sceneobj1) - sceneobj2.transformation = Translation.from_vector([10.0, 10.0, 0.0]) - assert sceneobj2.worldtransformation == sceneobj1.transformation * sceneobj2.transformation - assert sceneobj2.worldtransformation == Translation.from_vector([20.0, 10.0, 0.0]) - assert sceneobj2.frame == Frame([20.0, 10.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) - assert sceneobj2.frame.to_transformation() == Translation.from_vector([20.0, 10.0, 0.0]) - - sceneobj3 = scene.add(Box(), parent=sceneobj2) - sceneobj3.transformation = Translation.from_vector([10.0, 10.0, 10.0]) - assert sceneobj3.worldtransformation == sceneobj1.transformation * sceneobj2.transformation * sceneobj3.transformation - assert sceneobj3.worldtransformation == Translation.from_vector([30.0, 20.0, 10.0]) - assert sceneobj3.frame == Frame([30.0, 20.0, 10.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) - assert sceneobj3.frame.to_transformation() == Translation.from_vector([30.0, 20.0, 10.0]) - - -def test_scene_node_is_the_authority_for_adapter_base_state(): - scene = Scene() - sceneobject = scene.add(Box(), name="Initial", opacity=0.5) - node = sceneobject.node - assert node is not None - - sceneobject.name = "Through adapter" - sceneobject.transformation = Translation.from_vector([10.0, 0.0, 0.0]) - sceneobject.show = False - assert node.name == "Through adapter" - assert node.transformation == sceneobject.transformation - assert not node.show - - node.name = "Through node" - node.transformation = Translation.from_vector([0.0, 5.0, 0.0]) - node.opacity = 0.25 - assert sceneobject.name == "Through node" - assert sceneobject.transformation == node.transformation - assert sceneobject.opacity == 0.25 +def test_missing_scene_operation_names_context_type_and_operation(operation_registry): + scene = Scene(context="Missing") + sceneobject = scene.add(Box()) -def test_sceneobject_frame_and_worldtransformation_setters(): - """Test that frame and worldtransformation setters work correctly.""" - scene = Scene() + with pytest.raises(SceneOperationNotRegisteredError, match="draw.*GeometryObject.*Missing"): + sceneobject.draw() - # Test on root object - root_obj = scene.add(Box()) - test_frame = Frame([10.0, 20.0, 30.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) - root_obj.frame = test_frame - assert root_obj.frame == test_frame - assert root_obj.worldtransformation == Transformation.from_frame(test_frame) - - # Test worldtransformation setter on root object - test_transform = Translation.from_vector([5.0, 10.0, 15.0]) - root_obj.worldtransformation = test_transform - assert root_obj.worldtransformation == test_transform - assert root_obj.transformation == test_transform - - # Test with parent-child relationship - parent_obj = scene.add(Box()) - child_obj = scene.add(Box(), parent=parent_obj) - parent_obj.transformation = Translation.from_vector([10.0, 0.0, 0.0]) - - # Test frame setter with parent - child_frame = Frame([30.0, 20.0, 10.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) - child_obj.frame = child_frame - assert child_obj.frame == child_frame - expected_local = parent_obj.worldtransformation.inverse() * Transformation.from_frame(child_frame) - assert child_obj.transformation == expected_local - - # Test worldtransformation setter with parent - child_world_transform = Translation.from_vector([50.0, 30.0, 20.0]) - child_obj.worldtransformation = child_world_transform - assert child_obj.worldtransformation == child_world_transform - expected_local = parent_obj.worldtransformation.inverse() * child_world_transform - assert child_obj.transformation == expected_local - - -def test_graphobject_coordinate_cache_tracks_ancestor_transformations(): - scene = Scene() - parent = scene.add_group("Parent") - graph = Graph.from_nodes_and_edges([(0, 0, 0)], []) - graphobject = scene.add(graph, parent=parent) - assert graphobject.node_xyz[0] == [0.0, 0.0, 0.0] +def test_scene_node_rejects_invalid_data(): + with pytest.raises(ValueError, match="UUID string"): + SceneNode.__from_data__({"object": "invalid", "children": []}) + with pytest.raises(TypeError, match="children"): + SceneNode.__from_data__({"object": str(uuid4()), "children": ()}) - parent.transformation = Translation.from_vector([1.0, 2.0, 3.0]) - assert graphobject.node_xyz[0] == [1.0, 2.0, 3.0] +def test_remove_clears_resources_for_every_context(operation_registry): + calls = [] + def draw(scene, sceneobject, runtime): + return [f"{runtime}-handle"] -def test_graphobject_explicit_coordinates_are_not_ancestor_transform_cache(): - scene = Scene() - parent = scene.add_group("Parent") - graph = Graph.from_nodes_and_edges([(0, 0, 0)], []) - graphobject = scene.add(graph, parent=parent) - graphobject.node_xyz = {0: [4.0, 5.0, 6.0]} + def clear(scene, sceneobject, runtime, resources): + calls.append((sceneobject.guid, runtime, resources)) - parent.transformation = Translation.from_vector([1.0, 2.0, 3.0]) + for context in ("A", "B"): + register_scene_operation(context, SceneObject, "draw", draw) + register_scene_operation(context, SceneObject, "clear", clear) + scene = Scene(context=None, _detect_context=False) + sceneobject = scene.add(Box()) + sceneobject.draw(context="A", runtime="a") + sceneobject.draw(context="B", runtime="b") - assert graphobject.node_xyz[0] == [4.0, 5.0, 6.0] + scene.remove(sceneobject) + assert calls == [ + (sceneobject.guid, "a", ("a-handle",)), + (sceneobject.guid, "b", ("b-handle",)), + ] -def test_graphobject_invalidate_refreshes_coordinates_after_graph_mutation(): - scene = Scene() - graph = Graph.from_nodes_and_edges([(0, 0, 0)], []) - graphobject = scene.add(graph) - assert graphobject.node_xyz[0] == [0.0, 0.0, 0.0] +def test_repeated_draw_clears_the_previous_resource_before_replacing_it(operation_registry): + calls = [] - graph.node_attributes(0, ["x", "y", "z"], [1.0, 2.0, 3.0]) - assert graphobject.node_xyz[0] == [0.0, 0.0, 0.0] + def draw(scene, sceneobject, runtime): + calls.append(("draw", runtime)) + return [f"{runtime}-handle"] - graphobject.invalidate() + def clear(scene, sceneobject, runtime, resources): + calls.append(("clear", runtime, resources)) - assert graphobject.node_xyz[0] == [1.0, 2.0, 3.0] + register_scene_operation("Test", SceneObject, "draw", draw) + register_scene_operation("Test", SceneObject, "clear", clear) + scene = Scene(context="Test") + sceneobject = scene.add(Box()) + sceneobject.draw(runtime="first") + sceneobject.draw(runtime="second") + sceneobject.clear() -def test_volmeshobject_coordinate_cache_tracks_ancestor_transformations(): - scene = Scene() - parent = scene.add_group("Parent") - volmesh = VolMesh.from_meshgrid(1, 1, 1, 1, 1, 1) - volmeshobject = scene.add(volmesh, parent=parent) - vertex = next(volmesh.vertices()) - xyz = volmeshobject.vertex_xyz[vertex] - - parent.transformation = Translation.from_vector([1.0, 2.0, 3.0]) - - assert volmeshobject.vertex_xyz[vertex] == [xyz[0] + 1.0, xyz[1] + 2.0, xyz[2] + 3.0] - - -def test_volmeshobject_invalidate_refreshes_coordinates_after_volmesh_mutation(): - scene = Scene() - volmesh = VolMesh.from_meshgrid(1, 1, 1, 1, 1, 1) - volmeshobject = scene.add(volmesh) - vertex = next(volmesh.vertices()) - _ = volmeshobject.vertex_xyz - - volmesh.vertex_attributes(vertex, ["x", "y", "z"], [1.0, 2.0, 3.0]) - volmeshobject.invalidate() - - assert volmeshobject.vertex_xyz[vertex] == [1.0, 2.0, 3.0] - + assert calls == [ + ("draw", "first"), + ("clear", "first", ("first-handle",)), + ("draw", "second"), + ("clear", "second", ("second-handle",)), + ] -def test_scene_clear(): - scene = Scene() - sceneobj1 = scene.add(Box()) - sceneobj2 = scene.add(Box(), parent=sceneobj1) - sceneobj3 = scene.add(Box(), parent=sceneobj2) # noqa: F841 - - assert len(scene.objects) == 3 - - scene.clear(clear_context=False, clear_scene=True) - assert len(scene.objects) == 0 +def test_clear_without_a_context_or_resources_is_backend_neutral(): + scene = Scene(context=None, _detect_context=False) + scene.add(Box()) + scene.clear() -def test_scene_owns_tree_with_group_root(): - scene = Scene() - - assert not isinstance(scene, Tree) - assert isinstance(scene.tree, Tree) - assert isinstance(scene.root, Group) - assert scene.root.tree is scene.tree - assert all(not isinstance(node, SceneObject) for node in scene.tree.nodes) - - -def test_scene_stores_objects_and_items_by_guid(): - scene = Scene() - box = Box() - boxobject = scene.add(box) - - assert scene.object_store[boxobject.guid] is boxobject - assert scene.item_store[box.guid] is box - assert boxobject.item_guid == box.guid - assert boxobject.item is box - assert not hasattr(boxobject, "_detached_item") - assert not hasattr(boxobject, "_item") - assert scene.tree.root.children[0].object_guid == boxobject.guid + assert scene.objects == [] -def test_scene_reuses_shared_item_and_prunes_it_after_last_reference(): +def test_world_transformation_and_frame_setter_preserve_local_coordinates(): scene = Scene() - box = Box() - first = scene.add(box) - second = scene.add(box) - - assert first.item is second.item is box - assert list(scene.item_store) == [box.guid] + parent = scene.add(Box(), transformation=Translation.from_vector([1, 0, 0])) + child = scene.add(Box(), parent=parent, transformation=Translation.from_vector([0, 2, 0])) + assert child.frame.point == [1, 2, 0] - scene.remove(first) - assert scene.item_store[box.guid] is box - assert first.scene is None - assert first.item is box + child.frame = Frame([4, 5, 6], [1, 0, 0], [0, 1, 0]) - scene.remove(second) - assert box.guid not in scene.item_store + assert child.transformation.translation_vector == [3, 5, 6] + assert child.frame.point == [4, 5, 6] -def test_scene_replaces_an_objects_item_in_the_store(): +def test_graph_and_volmesh_coordinates_invalidate_on_ancestor_transform(): + graph = Graph() + graph.add_node(0, x=0, y=0, z=0) + volmesh = VolMesh.from_vertices_and_cells([[0, 0, 0]], []) scene = Scene() - original = Box(1.0) - replacement = Box(2.0) - sceneobject = scene.add(original) - - scene.replace_item(sceneobject, replacement) - - assert sceneobject.item is replacement - assert sceneobject.item_guid == replacement.guid - assert replacement.guid in scene.item_store - assert original.guid not in scene.item_store + parent = scene.add_group("Parent") + graphobject = scene.add(graph, parent=parent) + volmeshobject = scene.add(volmesh, parent=parent) + assert isinstance(graphobject, GraphObject) + assert isinstance(volmeshobject, VolMeshObject) + assert graphobject.node_xyz[0] == [0, 0, 0] + assert volmeshobject.vertex_xyz[0] == [0, 0, 0] + parent.transformation = Translation.from_vector([1, 2, 3]) -def test_group_creation(): - scene = Scene() - group = scene.add_group("TestGroup") - assert isinstance(group, Group) - assert group.name == "TestGroup" - assert group.parent == scene.root + assert graphobject.node_xyz[0] == [1, 2, 3] + assert volmeshobject.vertex_xyz[0] == [1, 2, 3] -def test_group_add_item(): - scene = Scene() - group = scene.add_group("TestGroup") - box = Box() - box_obj = group.add(box) - assert isinstance(box_obj, SceneObject) - assert box_obj.parent is group - assert box_obj.item is box +def test_scene_state_field_declarations_are_fully_validated(): + class InvalidDefault(SceneObject): + __scene_state_namespace__ = "test.invalid.default" + __scene_state_fields__ = (SceneStateField("value", int, default=1),) + class InvalidOmission(SceneObject): + __scene_state_namespace__ = "test.invalid.omission" + __scene_state_fields__ = (SceneStateField("value", int, required=False, omit_default=True),) -def test_group_add_from_list(): - scene = Scene() - group = scene.add_group("TestGroup") - boxes = [Box() for _ in range(3)] - box_objs = group.add_from_list(boxes) - assert len(box_objs) == 3 - for box_obj in box_objs: - assert isinstance(box_obj, SceneObject) - assert box_obj.parent is group + class DuplicateAttribute(SceneObject): + __scene_state_namespace__ = "test.duplicate.attribute" + __scene_state_fields__ = ( + SceneStateField("one", int, attribute_name="value"), + SceneStateField("two", int, attribute_name="value"), + ) + with pytest.raises(SceneStateError, match="default and be required"): + InvalidDefault.scene_state_fields() + with pytest.raises(SceneStateError, match="undeclared default"): + InvalidOmission.scene_state_fields() + with pytest.raises(SceneStateError, match="Duplicate scene-state adapter attribute"): + DuplicateAttribute.scene_state_fields() -def test_group_kwargs_inheritance(): - scene = Scene() - group = scene.add_group("TestGroup", show=False) - box = Box() - box_obj = group.add(box) - assert not box_obj.show # Should inherit show=False from group +def test_sceneobject_discovery_is_not_inferred_from_registry_emptiness(monkeypatch): + class Item(Data): + pass -def test_group_hierarchy(): - scene = Scene() - parent_group = scene.add_group("ParentGroup") - child_group = parent_group.add(Group("ChildGroup")) - box = Box() - box_obj = child_group.add(box) + class ItemObject(SceneObject): + pass - assert box_obj.parent is child_group - assert child_group.parent is parent_group - assert parent_group.parent is scene.root + original = dict(SCENEOBJECT_TYPES) + discovered = scene_context._SCENEOBJECTS_DISCOVERED + SCENEOBJECT_TYPES.clear() + SCENEOBJECT_TYPES[Box] = GeometryObject + scene_context._SCENEOBJECTS_DISCOVERED = False + + def discover(): + SCENEOBJECT_TYPES[Item] = ItemObject + + monkeypatch.setattr(scene_context, "register_scene_objects", discover) + try: + assert get_sceneobject_cls(Item()) is ItemObject + finally: + SCENEOBJECT_TYPES.clear() + SCENEOBJECT_TYPES.update(original) + scene_context._SCENEOBJECTS_DISCOVERED = discovered diff --git a/tests/compas/scene/test_scene_serialisation.py b/tests/compas/scene/test_scene_serialisation.py index 4df1d79581e..88006d139c3 100644 --- a/tests/compas/scene/test_scene_serialisation.py +++ b/tests/compas/scene/test_scene_serialisation.py @@ -1,523 +1,215 @@ -import compas from uuid import uuid4 -import pytest # noqa: F401 -from compas.data import Data -from compas.scene import Scene +import pytest + from compas.geometry import Box -from compas.geometry import Capsule -from compas.geometry import Circle -from compas.geometry import Cone -from compas.geometry import Cylinder -from compas.geometry import Ellipse -from compas.geometry import Frame -from compas.geometry import Line -from compas.geometry import Point -from compas.geometry import Polygon -from compas.geometry import Polyhedron -from compas.geometry import Polyline -from compas.geometry import Sphere -from compas.geometry import Torus from compas.geometry import Translation -from compas.geometry import Vector -from compas.geometry import Plane -from compas.datastructures import Mesh from compas.datastructures import Graph +from compas.datastructures import Mesh from compas.datastructures import VolMesh -from compas.scene import Group +from compas.scene import GraphObject +from compas.scene import GeometryObject +from compas.scene import MeshObject +from compas.scene import Scene from compas.scene import SceneItemNotBoundError from compas.scene import SceneObject from compas.scene import SceneStateField -from compas.scene import SceneStateError -from compas.scene import SceneNode - - -@pytest.fixture -def items(): - box = Box.from_width_height_depth(1, 1, 1) - capsule = Capsule(0.5, 1, Frame.worldXY()) - circle = Circle(1, Frame.worldXY()) - cone = Cone(1, 1, Frame.worldXY()) - cylinder = Cylinder(1, 1, Frame.worldXY()) - line = Line(Point(0, 0, 0), Point(1, 1, 1)) - point = Point(0, 0, 0) - polygon = Polygon.from_sides_and_radius_xy(5, 1) - polyhedron = Polyhedron.from_platonicsolid(4) - polyline = Polyline([[0, 0, 0], [1, 0, 0], [1, 0, 1]]) - sphere = Sphere(1) - torus = Torus(1, 0.3, Frame.worldXY()) - vector = Vector(0, 0, 1) - ellipse = Ellipse(1, 0.5, Frame.worldXY()) - frame = Frame.worldXY() - plane = Plane(Point(0, 0, 0), Vector(0, 0, 1)) - mesh = Mesh.from_polyhedron(8) - graph = Graph.from_nodes_and_edges([(0, 0, 0), (0, -1.5, 0), (-1, 1, 0), (1, 1, 0)], [(0, 1), (0, 2), (0, 3)]) - volmesh = VolMesh.from_meshgrid(1, 1, 1, 2, 2, 2) - group = Group(name="My Group") - - return [ - box, - capsule, - circle, - cone, - cylinder, - line, - point, - polygon, - polyhedron, - polyline, - sphere, - torus, - vector, - ellipse, - frame, - plane, - mesh, - graph, - volmesh, - group, - ] - - -def assert_is_data_equal(obj1, obj2, path=""): - if type(obj1) is not type(obj2): - print("Type mismatch: {} != {} for {}:{} and {}:{}".format(type(obj1), type(obj2), path, obj1, path, obj2)) - return False - - if isinstance(obj1, (list, tuple)): - if len(obj1) != len(obj2): - print("Length mismatch: {} != {} for {} and {}".format(len(obj1), len(obj2), path, path)) - return False - - for i, (item1, item2) in enumerate(zip(obj1, obj2)): - if not assert_is_data_equal(item1, item2, path="{}[{}]".format(path, i)): - return False - - return True - - elif isinstance(obj1, dict): - if set(obj1.keys()) != set(obj2.keys()): - print("Key mismatch: {} != {} for {} and {}".format(set(obj1.keys()), set(obj2.keys()), path, path)) - return False - - for key in obj1: - if not assert_is_data_equal(obj1[key], obj2[key], path='{}["{}"]'.format(path, key)): - return False - - return True - - elif isinstance(obj1, Data): - return assert_is_data_equal(obj1.__data__, obj2.__data__, path="{}.__data__".format(path)) - - else: - if obj1 != obj2: - print("Value mismatch: {} != {} for {}:{} and {}:{}".format(obj1, obj2, path, obj1, path, obj2)) - return False - else: - return True - - -def test_scene_serialisation(items): - scene1 = Scene() - for item in items: - scene1.add(item) - - scene2 = Scene.from_jsonstring(scene1.to_jsonstring()) - assert assert_is_data_equal(scene1, scene2) - - -def test_scene_serialisation_preserves_shared_items(): +from compas.scene import VolMeshObject + + +def test_scene_roundtrip_restores_stable_objects_and_hierarchy_without_items(): + scene = Scene("Reference only") + parent_item = Box(1) + child_item = Box(2) + parent = scene.add(parent_item, name="Parent", show=False) + scene.add(child_item, name="Child", parent=parent) + parent.transformation = Translation.from_vector([1, 0, 0]) + + restored = Scene.from_jsonstring(scene.to_jsonstring()) + + restored_parent = restored.find_by_name("Parent") + restored_child = restored.find_by_name("Child") + assert isinstance(restored_parent, GeometryObject) + assert restored_parent.guid == parent.guid + assert restored_parent.show is False + assert restored_parent.transformation == parent.transformation + assert restored_child.parent is restored_parent + assert restored.item_store == {} + with pytest.raises(SceneItemNotBoundError, match=str(parent_item.guid)): + _ = restored_parent.item + + +def test_independent_items_bind_with_shared_identity(): box = Box() scene = Scene() scene.add(box, name="First") scene.add(box, name="Second") + restored = Scene.from_jsonstring(scene.to_jsonstring()) - reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) - reconstructed.bind(box) + restored.bind(box) - assert reconstructed.objects[0].item is reconstructed.objects[1].item + assert restored.objects[0].item is restored.objects[1].item is box -def test_scene_serialisation_reconstructs_world_transform_from_local_transforms(): - scene = Scene() - parent_box = Box() - child_box = Box() - parent = scene.add(parent_box, name="Parent") - child = scene.add(child_box, name="Child", parent=parent) - parent.transformation = Translation.from_vector([10.0, 0.0, 0.0]) - child.transformation = Translation.from_vector([0.0, 5.0, 0.0]) - - reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) - reconstructed.bind_many([parent_box, child_box]) - reconstructed_parent = reconstructed.find_by_name("Parent") - reconstructed_child = reconstructed.find_by_name("Child") - - assert reconstructed_parent is not None - assert reconstructed_child is not None - assert reconstructed_parent.transformation == parent.transformation - assert reconstructed_child.transformation == child.transformation - assert reconstructed_child.worldtransformation == child.worldtransformation - - -def test_scene_serialisation_empty(): +def test_scene_serialization_separates_objects_from_hierarchy(): scene = Scene() - scene = compas.json_loads(compas.json_dumps(scene)) - - assert isinstance(scene, Scene) - - -def test_group_serialisation(): - scene1 = Scene() - group = scene1.add_group("TestGroup", show=False) - box = Box.from_width_height_depth(1, 1, 1) - group.add(box) - - scene2 = Scene.from_jsonstring(scene1.to_jsonstring()) - scene2.bind(box) - - # Check if group was serialized correctly - group2 = scene2.find_by_name("TestGroup") - assert isinstance(group2, Group) - assert group2.name == "TestGroup" - assert not group2.show - - # Check if box was serialized correctly - box2 = group2.children[0] - assert isinstance(box2.item, Box) - assert box2.parent is group2 - - -def test_nested_group_serialisation(): - scene1 = Scene() - parent_group = scene1.add_group("ParentGroup", show=False) - child_group = parent_group.add(Group("ChildGroup", show=True)) - box = Box.from_width_height_depth(1, 1, 1) - child_group.add(box) - - scene2 = Scene.from_jsonstring(scene1.to_jsonstring()) - scene2.bind(box) - - # Check parent group - parent2 = scene2.find_by_name("ParentGroup") - assert isinstance(parent2, Group) - assert not parent2.show - - # Check child group - child2 = parent2.children[0] - assert isinstance(child2, Group) - assert child2.name == "ChildGroup" - assert child2.show - assert child2.parent is parent2 - - # Check box - box2 = child2.children[0] - assert isinstance(box2.item, Box) - assert box2.parent is child2 - - -def test_group_kwargs_serialisation(): - scene1 = Scene() - group = scene1.add_group("TestGroup", show=False, opacity=0.5) - box = Box.from_width_height_depth(1, 1, 1) - group.add(box) - - scene2 = Scene.from_jsonstring(scene1.to_jsonstring()) - scene2.bind(box) - - # Check if group kwargs were serialized correctly - group2 = scene2.find_by_name("TestGroup") - assert not group2.show - assert group2.opacity == 0.5 - - # Check if box inherited kwargs correctly - box2 = group2.children[0] - assert not box2.show - assert box2.opacity == 0.5 - - -def test_scene_serialisation_is_reference_only_and_loads_unresolved(): - box = Box(2) - scene = Scene("Reference only") - sceneobject = scene.add(box, name="Box reference") - - reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) - node = reconstructed.find_node_by_name("Box reference") - - assert node is not None - assert node.guid == sceneobject.guid - assert node.item_guid == box.guid - assert not reconstructed.item_store - with pytest.raises(SceneItemNotBoundError, match=f"{node.guid}.*{box.guid}"): - reconstructed.object(node) - - reconstructed.bind(box) - assert reconstructed.object(node).item is box + box = scene.add(Box(), name="Box") + data = scene.__data__ + + assert data["version"] == 2 + assert [obj.guid for obj in data["objects"]] == [scene.root.guid, box.guid] + assert data["hierarchy"] == { + "object": str(scene.root.guid), + "children": [{"object": str(box.guid), "children": []}], + } -def test_scene_preserves_unknown_namespaced_extensions_without_an_adapter(): +def test_unknown_namespaced_sceneobject_state_roundtrips(): scene = Scene() - scene.add_reference( + obj = scene.add_reference( uuid4(), - name="External adapter", - extensions={"org.example.ViewerObject": {"overlay": True}}, + name="External", + extensions={"org.example": {"value": 7}}, ) - reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) - node = reconstructed.find_node_by_name("External adapter") + restored = Scene.from_jsonstring(scene.to_jsonstring()) + restored_obj = restored.object(obj.guid) - assert node is not None - assert node.extensions == {"org.example.ViewerObject": {"overlay": True}} + assert restored_obj.__data__["extensions"] == {"org.example": {"value": 7}} -def test_adapter_extension_fields_are_composed_through_the_mro(): - class BaseAdapter(SceneObject): - __scene_state_namespace__ = "org.example.BaseAdapter" - __scene_state_fields__ = (SceneStateField("base_value", int),) +def test_scene_rejects_duplicate_hierarchy_references(): + scene = Scene() + obj = scene.add(Box()) + data = scene.__data__ + data["hierarchy"]["children"].append({"object": str(obj.guid), "children": []}) - def __init__(self, *args, base_value=1, **kwargs): - super().__init__(*args, **kwargs) - self.base_value = base_value + with pytest.raises(ValueError, match="more than once"): + Scene.__from_data__(data) - class DerivedAdapter(BaseAdapter): - __scene_state_namespace__ = "org.example.DerivedAdapter" - __scene_state_fields__ = (SceneStateField("derived_value", str),) - def __init__(self, *args, derived_value="default", **kwargs): - super().__init__(*args, **kwargs) - self.derived_value = derived_value +def test_nested_groups_and_world_transform_roundtrip(): + scene = Scene("Nested") + first = scene.add_group("First", transformation=Translation.from_vector([1, 0, 0])) + second = scene.add_group("Second", parent=first, transformation=Translation.from_vector([0, 2, 0])) + child = scene.add(Box(), parent=second, transformation=Translation.from_vector([0, 0, 3])) - box = Box() - scene = Scene() - adapter = scene.add( - SceneObject( - box, - sceneobject_type=DerivedAdapter, - base_value=7, - derived_value="saved", - ) - ) - reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) - reconstructed.bind(box) - node = next(node for node in reconstructed.nodes if node.item_guid) - restored = reconstructed.object(node, object_type=DerivedAdapter) - - assert adapter.node is not None - assert adapter.node.extensions == { - "org.example.BaseAdapter": {"base_value": 7}, - "org.example.DerivedAdapter": {"derived_value": "saved"}, - } - assert isinstance(restored, DerivedAdapter) - assert restored.base_value == 7 - assert restored.derived_value == "saved" + restored = Scene.from_jsonstring(scene.to_jsonstring()) + restored_child = restored.object(child.guid) + assert restored_child.parent.name == "Second" + assert restored_child.parent.parent.name == "First" + assert restored_child.worldtransformation == child.worldtransformation -def test_bound_extension_state_uses_the_node_as_sole_authority(): - box = Box() + +def test_replace_item_and_shared_item_pruning(): + shared = Box(1) + replacement = Box(2) scene = Scene() - adapter = scene.add(box, pointsize=2.0) - assert adapter.node is not None + first = scene.add(shared) + second = scene.add(shared) - adapter.pointsize = 3.0 - assert adapter.node.extensions["compas.scene.GeometryObject"]["pointsize"] == 3.0 + scene.replace_item(first, replacement) + assert shared.guid in scene.item_store + scene.remove(second) - adapter.node.extensions["compas.scene.GeometryObject"]["pointsize"] = 4.0 - assert adapter.pointsize == 4.0 + assert shared.guid not in scene.item_store + assert first.item is replacement -def test_bound_extension_writes_preserve_property_setter_side_effects(): - class Adapter(SceneObject): - __scene_state_namespace__ = "org.example.SetterAdapter" - __scene_state_fields__ = (SceneStateField("resolution", int, attribute_name="resolution"),) +def test_bind_many_is_atomic(): + first = Box(1) + second = Box(2) + unknown = Box(3) + scene = Scene() + scene.add(first) + scene.add(second) + restored = Scene.from_jsonstring(scene.to_jsonstring()) - def __init__(self, *args, resolution=4, **kwargs): - super().__init__(*args, **kwargs) - self.setter_calls = 0 - self.resolution = resolution + with pytest.raises(KeyError): + restored.bind_many([first, unknown]) - @property - def resolution(self): - return self._resolution + assert restored.item_store == {} - @resolution.setter - def resolution(self, value): - self.setter_calls += 1 - self._resolution = value - adapter = Scene().add(SceneObject(Box(), sceneobject_type=Adapter)) - adapter.resolution = 8 +def test_bind_rejects_a_distinct_item_with_the_same_guid(): + item = Box(1) + duplicate = Box(2) + duplicate._guid = item.guid + scene = Scene() + scene.add(item) + restored = Scene.from_jsonstring(scene.to_jsonstring()) + restored.bind(item) - assert adapter.setter_calls == 2 - assert adapter.resolution == 8 - assert adapter.node is not None - assert adapter.node.extensions["org.example.SetterAdapter"]["resolution"] == 8 + with pytest.raises(ValueError, match="different data item"): + restored.bind(duplicate) -@pytest.mark.parametrize( - ("field", "value", "message"), - [ - ("name", 1, "name"), - ("transformation", "invalid", "transformation"), - ("color", "red", "color"), - ("opacity", True, "opacity"), - ("opacity", 2.0, "opacity"), - ("show", 1, "show"), - ("extensions", [], "extensions"), - ("extensions", {"valid": {1: "invalid"}}, "extension fields"), - ], -) -def test_scene_node_rejects_invalid_closed_schema_types(field, value, message): - data = SceneNode(kind="group", name="Root").__data__ - data[field] = value +def test_type_specific_sceneobject_state_roundtrips(): + mesh = Mesh.from_vertices_and_faces([[0, 0, 0], [1, 0, 0], [0, 1, 0]], [[0, 1, 2]]) + graph = Graph() + graph.add_node(0, x=0, y=0, z=0) + volmesh = VolMesh.from_vertices_and_cells([[0, 0, 0]], []) + scene = Scene() + meshobject = scene.add(mesh, show_vertices=True, vertexsize=3.0) + graphobject = scene.add(graph, show_nodes=False, edgewidth=2.0) + volmeshobject = scene.add(volmesh, show_cells=False, vertexsize=4.0) + + restored = Scene.from_jsonstring(scene.to_jsonstring()) + + restored_mesh = restored.object(meshobject.guid) + restored_graph = restored.object(graphobject.guid) + restored_volmesh = restored.object(volmeshobject.guid) + assert isinstance(restored_mesh, MeshObject) + assert restored_mesh.show_vertices is True + assert restored_mesh.vertexsize == 3.0 + assert isinstance(restored_graph, GraphObject) + assert restored_graph.show_nodes is False + assert restored_graph.edgewidth == 2.0 + assert isinstance(restored_volmesh, VolMeshObject) + assert restored_volmesh.show_cells is False + assert restored_volmesh.vertexsize == 4.0 + + +def test_scene_state_fields_compose_defaults_and_omitted_defaults(): + class BaseObject(SceneObject): + __scene_state_namespace__ = "test.base" + __scene_state_fields__ = (SceneStateField("size", int, default=1, required=False, omit_default=True),) + + def __init__(self, size=1, **kwargs): + super().__init__(**kwargs) + self.size = size - with pytest.raises((TypeError, ValueError), match=message): - SceneNode.__from_data__(data) + class DerivedObject(BaseObject): + __scene_state_namespace__ = "test.derived" + __scene_state_fields__ = (SceneStateField("label", str, default="default", required=False),) + def __init__(self, label="default", **kwargs): + super().__init__(**kwargs) + self.label = label -@pytest.mark.parametrize( - ("change", "message"), - [ - ({"unknown": True}, "Unknown scene-node fields"), - ({"name": None}, "Missing scene-node fields"), - ({"kind": "invalid"}, "kind"), - ({"kind": "item"}, "item GUID"), - ({"item": str(uuid4())}, "group scene node"), - ({"children": ()}, "children"), - ], -) -def test_scene_node_rejects_invalid_closed_schema_structure(change, message): - data = SceneNode(kind="group", name="Root").__data__ - if change == {"name": None}: - del data["name"] - else: - data.update(change) + original = DerivedObject(item_guid=uuid4(), label="kept") + data = original.__data__ + assert data["extensions"] == {"test.derived": {"label": "kept"}} - with pytest.raises((TypeError, ValueError), match=message): - SceneNode.__from_data__(data) + restored = DerivedObject.__from_data__(data) + + assert restored.size == 1 + assert restored.label == "kept" @pytest.mark.parametrize( ("change", "message"), [ ({"unknown": True}, "Unknown scene fields"), - ({"name": None}, "Missing scene fields"), - ({"version": 2}, "Unsupported scene schema version"), + ({"version": 999}, "Unsupported scene schema version"), + ({"objects": ()}, "objects should be a list"), + ({"name": None}, "name"), ], ) -def test_scene_rejects_invalid_closed_schema_structure(change, message): - data = Scene().__data__ - if change == {"name": None}: - del data["name"] - else: - data.update(change) - - with pytest.raises(ValueError, match=message): - Scene.__from_data__(data) - - -def test_scene_rejects_an_item_node_as_its_root(): +def test_scene_rejects_invalid_closed_schema(change, message): data = Scene().__data__ - data["root"] = SceneNode(kind="item", item_guid=uuid4(), name="Item").__data__ + data.update(change) - with pytest.raises(ValueError, match="root should be a group"): + with pytest.raises((TypeError, ValueError), match=message): Scene.__from_data__(data) - - -def test_scene_state_field_supports_attribute_names_defaults_and_omission(): - class Adapter(SceneObject): - __scene_state_namespace__ = "org.example.Adapter" - __scene_state_fields__ = ( - SceneStateField( - "saved-size", - int, - attribute_name="size", - default=5, - required=False, - omit_default=True, - ), - ) - - def __init__(self, *args, size=5, **kwargs): - super().__init__(*args, **kwargs) - self.size = size - - box = Box() - scene = Scene() - adapter = scene.add(SceneObject(box, sceneobject_type=Adapter)) - - assert adapter.node is not None - assert "org.example.Adapter" not in adapter.node.extensions - - reconstructed = Scene.from_jsonstring(scene.to_jsonstring()) - reconstructed.bind(box) - node = next(node for node in reconstructed.nodes if node.item_guid) - restored = reconstructed.object(node, object_type=Adapter) - assert restored.size == 5 - assert "org.example.Adapter" not in node.extensions - - -def test_scene_state_field_rejects_missing_required_state_and_attributes(): - class MissingAttributeAdapter(SceneObject): - __scene_state_namespace__ = "org.example.MissingAttribute" - __scene_state_fields__ = (SceneStateField("missing", int),) - - box = Box() - with pytest.raises(SceneStateError, match="Missing adapter attribute"): - Scene().add(SceneObject(box, sceneobject_type=MissingAttributeAdapter)) - - class RequiredAdapter(SceneObject): - __scene_state_namespace__ = "org.example.Required" - __scene_state_fields__ = (SceneStateField("required", int),) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.required = 1 - - scene = Scene() - node = scene.add_reference(box.guid, name="Required", extensions={}) - scene.bind(box) - with pytest.raises(SceneStateError, match="org.example.Required.required"): - scene.object(node, object_type=RequiredAdapter) - - -def test_scene_state_field_rejects_conflicting_declarations(): - class Base(SceneObject): - __scene_state_namespace__ = "org.example.Base" - __scene_state_fields__ = (SceneStateField("first", int, attribute_name="value"),) - - class Conflict(Base): - __scene_state_namespace__ = "org.example.Conflict" - __scene_state_fields__ = (SceneStateField("second", int, attribute_name="value"),) - - with pytest.raises(SceneStateError, match="Duplicate scene-state adapter attribute"): - Conflict.scene_state_fields() - - -def test_scene_objects_are_not_independently_serializable(): - adapter = Scene().add(Box()) - - with pytest.raises(NotImplementedError): - compas.json_dumps(adapter) - - -def test_bind_many_rejects_a_different_falsey_item_with_the_same_guid(): - class FalseyData(Data): - @property - def __data__(self): - return {} - - @classmethod - def __from_data__(cls, data): - return cls() - - def __bool__(self): - return False - - first = FalseyData() - second = FalseyData() - second._guid = first.guid - scene = Scene() - scene.add_reference(first.guid, name="Falsey") - scene.bind(first) - - with pytest.raises(ValueError, match="different data item"): - scene.bind_many([second]) From d43db206afec36450efbe76b53306ce3bd468076 Mon Sep 17 00:00:00 2001 From: tomvanmele Date: Sat, 7 Nov 2026 22:38:59 +0100 Subject: [PATCH 07/10] Add stable semantic scene objects --- CHANGELOG.md | 2 + docs/getting-started/basics.visualisation.md | 21 +++++++- src/compas/scene/__init__.py | 20 ++++++++ src/compas/scene/frameobject.py | 22 +++++++++ src/compas/scene/meshobject.py | 6 +++ src/compas/scene/planeobject.py | 30 ++++++++++++ src/compas/scene/shapeobject.py | 36 ++++++++++++++ src/compas/scene/surfaceobject.py | 36 ++++++++++++++ src/compas/scene/vectorobject.py | 31 ++++++++++++ tests/compas/scene/test_scene.py | 45 +++++++++++++++-- .../compas/scene/test_scene_serialisation.py | 49 ++++++++++++++++++- 11 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 src/compas/scene/frameobject.py create mode 100644 src/compas/scene/planeobject.py create mode 100644 src/compas/scene/shapeobject.py create mode 100644 src/compas/scene/surfaceobject.py create mode 100644 src/compas/scene/vectorobject.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f363917e5ca..f1b03625c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added durable, reference-only scene objects with declarative persistent-state fields, explicit data rebinding, inspectable unresolved references, and hierarchy-only scene nodes. +* Added stable semantic scene objects for shapes, surfaces, frames, planes, and + vectors, with backend-neutral discretization and display state. * Added a verified Linux Python 3.12 candidate wheel artifact for framework-wide pre-release integration. * Added the public `compas.typing` structural vocabulary with NumPy-aware `PointLike`, `VectorLike`, collection, and neutral `VerticesFaces` aliases. * Added the PEP 561 `py.typed` marker and installed-wheel static-consumer verification. diff --git a/docs/getting-started/basics.visualisation.md b/docs/getting-started/basics.visualisation.md index 9598ffeea69..3dcb4cd2cc6 100644 --- a/docs/getting-started/basics.visualisation.md +++ b/docs/getting-started/basics.visualisation.md @@ -22,12 +22,31 @@ When a COMPAS object is added to a scene, a scene object is created automaticall ```pycon >>> sceneobj = scene.add(box) >>> type(sceneobj).__name__ -'GeometryObject' +'ShapeObject' ``` The scene object holds a reference to the COMPAS data object, and stores its visualisation settings and transformation matrix. Multiple scene objects can be created for the same COMPAS data object, each with different visualisation settings and transformations. +Core provides stable semantic scene-object types for data whose visualisation +needs additional backend-neutral settings: + +- `ShapeObject` and `SurfaceObject` store the `u` and `v` discretization + counts used to display analytic shapes and parametric surfaces. +- `FrameObject` and `PlaneObject` store their display `size`. +- `VectorObject` stores the vector's display `anchor`. +- `MeshObject` stores `hide_coplanaredges` and `use_vertexcolors` in addition + to its vertex, edge, and face settings. + +These settings describe durable presentation intent. A visualisation backend +decides how to draw them; changing the backend does not change the scene-object +type or its stored settings. + +For a `FrameObject`, the inherited `geometry` property is the represented +[`Frame`][compas.geometry.Frame]. Its inherited `frame` property has the same +meaning as on every `SceneObject`: it is the world-placement frame derived from +the scene hierarchy. The two frames therefore serve different purposes. + ```pycon >>> sceneobj1 = scene.add(box) >>> sceneobj2 = scene.add(box) diff --git a/src/compas/scene/__init__.py b/src/compas/scene/__init__.py index 55f68141abb..49e38935535 100644 --- a/src/compas/scene/__init__.py +++ b/src/compas/scene/__init__.py @@ -15,6 +15,11 @@ from .meshobject import MeshObject from .graphobject import GraphObject from .geometryobject import GeometryObject +from .shapeobject import ShapeObject +from .surfaceobject import SurfaceObject +from .frameobject import FrameObject +from .planeobject import PlaneObject +from .vectorobject import VectorObject from .volmeshobject import VolMeshObject from .group import Group @@ -29,6 +34,11 @@ from compas.plugins import plugin from compas.geometry import Geometry +from compas.geometry import Shape +from compas.geometry import Surface +from compas.geometry import Frame +from compas.geometry import Plane +from compas.geometry import Vector from compas.datastructures import Mesh from compas.datastructures import Graph from compas.datastructures import VolMesh @@ -38,6 +48,11 @@ def register_scene_objects_base() -> None: """Register stable core scene-object types.""" register_sceneobject_type(Geometry, GeometryObject) + register_sceneobject_type(Shape, ShapeObject) + register_sceneobject_type(Surface, SurfaceObject) + register_sceneobject_type(Frame, FrameObject) + register_sceneobject_type(Plane, PlaneObject) + register_sceneobject_type(Vector, VectorObject) register_sceneobject_type(Mesh, MeshObject) register_sceneobject_type(Graph, GraphObject) register_sceneobject_type(VolMesh, VolMeshObject) @@ -54,6 +69,11 @@ def register_scene_objects_base() -> None: "MeshObject", "GraphObject", "GeometryObject", + "ShapeObject", + "SurfaceObject", + "FrameObject", + "PlaneObject", + "VectorObject", "VolMeshObject", "Group", "register_scene_objects", diff --git a/src/compas/scene/frameobject.py b/src/compas/scene/frameobject.py new file mode 100644 index 00000000000..eed38dd5983 --- /dev/null +++ b/src/compas/scene/frameobject.py @@ -0,0 +1,22 @@ +from typing import Any + +from .geometryobject import GeometryObject +from .sceneobject import SceneStateField + + +class FrameObject(GeometryObject): + """Scene object for a frame. + + Parameters + ---------- + size + Display size of the frame axes. + + """ + + __scene_state_namespace__ = "compas.scene.FrameObject" + __scene_state_fields__ = (SceneStateField("size", (int, float), default=1.0, required=False),) + + def __init__(self, size: float = 1.0, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.size = size diff --git a/src/compas/scene/meshobject.py b/src/compas/scene/meshobject.py index 91ae74bd63e..c7cce748230 100644 --- a/src/compas/scene/meshobject.py +++ b/src/compas/scene/meshobject.py @@ -34,6 +34,8 @@ class MeshObject(SceneObject): SceneStateField("facecolor", required=False), SceneStateField("vertexsize", (int, float), default=1.0, required=False), SceneStateField("edgewidth", (int, float), default=1.0, required=False), + SceneStateField("hide_coplanaredges", bool, default=False, required=False), + SceneStateField("use_vertexcolors", bool, default=False, required=False), ) # ========================================================================== @@ -50,6 +52,8 @@ def __init__( facecolor: Mapping[Any, ColorInput] | Color | None = None, vertexsize: float = 1.0, edgewidth: float = 1.0, + hide_coplanaredges: bool = False, + use_vertexcolors: bool = False, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -61,6 +65,8 @@ def __init__( self.facecolor = facecolor or self.color self.vertexsize = vertexsize self.edgewidth = edgewidth + self.hide_coplanaredges = hide_coplanaredges + self.use_vertexcolors = use_vertexcolors # ========================================================================== # Properties diff --git a/src/compas/scene/planeobject.py b/src/compas/scene/planeobject.py new file mode 100644 index 00000000000..7088ec22f14 --- /dev/null +++ b/src/compas/scene/planeobject.py @@ -0,0 +1,30 @@ +from typing import Any +from typing import cast + +from compas.geometry import Plane + +from .geometryobject import GeometryObject +from .sceneobject import SceneStateField + + +class PlaneObject(GeometryObject): + """Scene object for a plane. + + Parameters + ---------- + size + Display size of the plane. + + """ + + __scene_state_namespace__ = "compas.scene.PlaneObject" + __scene_state_fields__ = (SceneStateField("size", (int, float), default=1.0, required=False),) + + def __init__(self, size: float = 1.0, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.size = size + + @property + def plane(self) -> Plane: + """The plane represented by this scene object.""" + return cast(Plane, self.item) diff --git a/src/compas/scene/shapeobject.py b/src/compas/scene/shapeobject.py new file mode 100644 index 00000000000..ecfd05faaac --- /dev/null +++ b/src/compas/scene/shapeobject.py @@ -0,0 +1,36 @@ +from typing import Any +from typing import cast + +from compas.geometry import Shape + +from .geometryobject import GeometryObject +from .sceneobject import SceneStateField + + +class ShapeObject(GeometryObject): + """Scene object for an analytic shape. + + Parameters + ---------- + u + Number of discretization intervals in the first parameter direction. + v + Number of discretization intervals in the second parameter direction. + + """ + + __scene_state_namespace__ = "compas.scene.ShapeObject" + __scene_state_fields__ = ( + SceneStateField("u", int, default=16, required=False), + SceneStateField("v", int, default=16, required=False), + ) + + def __init__(self, u: int = 16, v: int = 16, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.u = u + self.v = v + + @property + def shape(self) -> Shape: + """The analytic shape represented by this scene object.""" + return cast(Shape, self.item) diff --git a/src/compas/scene/surfaceobject.py b/src/compas/scene/surfaceobject.py new file mode 100644 index 00000000000..5c4de633168 --- /dev/null +++ b/src/compas/scene/surfaceobject.py @@ -0,0 +1,36 @@ +from typing import Any +from typing import cast + +from compas.geometry import Surface + +from .geometryobject import GeometryObject +from .sceneobject import SceneStateField + + +class SurfaceObject(GeometryObject): + """Scene object for a parametric surface. + + Parameters + ---------- + u + Number of discretization intervals in the first parameter direction. + v + Number of discretization intervals in the second parameter direction. + + """ + + __scene_state_namespace__ = "compas.scene.SurfaceObject" + __scene_state_fields__ = ( + SceneStateField("u", int, default=16, required=False), + SceneStateField("v", int, default=16, required=False), + ) + + def __init__(self, u: int = 16, v: int = 16, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.u = u + self.v = v + + @property + def surface(self) -> Surface: + """The parametric surface represented by this scene object.""" + return cast(Surface, self.item) diff --git a/src/compas/scene/vectorobject.py b/src/compas/scene/vectorobject.py new file mode 100644 index 00000000000..f61f92f0257 --- /dev/null +++ b/src/compas/scene/vectorobject.py @@ -0,0 +1,31 @@ +from typing import Any +from typing import cast + +from compas.geometry import Point +from compas.geometry import Vector + +from .geometryobject import GeometryObject +from .sceneobject import SceneStateField + + +class VectorObject(GeometryObject): + """Scene object for a vector. + + Parameters + ---------- + anchor + Point at which the vector is displayed. + + """ + + __scene_state_namespace__ = "compas.scene.VectorObject" + __scene_state_fields__ = (SceneStateField("anchor", Point),) + + def __init__(self, anchor: Point | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.anchor = anchor if anchor is not None else Point(0.0, 0.0, 0.0) + + @property + def vector(self) -> Vector: + """The vector represented by this scene object.""" + return cast(Vector, self.item) diff --git a/tests/compas/scene/test_scene.py b/tests/compas/scene/test_scene.py index 46756289ff0..f2b29b01708 100644 --- a/tests/compas/scene/test_scene.py +++ b/tests/compas/scene/test_scene.py @@ -7,15 +7,24 @@ from compas.datastructures import VolMesh from compas.geometry import Box from compas.geometry import Frame +from compas.geometry import Plane +from compas.geometry import PlanarSurface +from compas.geometry import Point from compas.geometry import Translation +from compas.geometry import Vector +from compas.scene import FrameObject from compas.scene import GraphObject from compas.scene import GeometryObject +from compas.scene import PlaneObject from compas.scene import Scene from compas.scene import SceneNode from compas.scene import SceneObject from compas.scene import SceneOperationNotRegisteredError from compas.scene import SceneStateError from compas.scene import SceneStateField +from compas.scene import ShapeObject +from compas.scene import SurfaceObject +from compas.scene import VectorObject from compas.scene import VolMeshObject from compas.scene import get_sceneobject_cls from compas.scene import register_scene_operation @@ -42,11 +51,41 @@ def test_scene_add_returns_a_stable_semantic_type(): sceneobject = scene.add(box) - assert type(sceneobject) is GeometryObject - assert get_sceneobject_cls(box) is GeometryObject + assert type(sceneobject) is ShapeObject + assert get_sceneobject_cls(box) is ShapeObject assert sceneobject.item is box +@pytest.mark.parametrize( + ("item", "sceneobject_type"), + [ + (Point(1, 2, 3), GeometryObject), + (Box(), ShapeObject), + (PlanarSurface(), SurfaceObject), + (Frame.worldXY(), FrameObject), + (Plane.worldXY(), PlaneObject), + (Vector(1, 2, 3), VectorObject), + ], +) +def test_sceneobject_type_dispatch_is_exact_and_context_independent(item, sceneobject_type): + assert get_sceneobject_cls(item) is sceneobject_type + assert type(Scene(context="Viewer").add(item)) is sceneobject_type + + +def test_semantic_fields_are_not_added_to_generic_geometry_objects(): + sceneobject = Scene().add(Point(1, 2, 3)) + + assert not hasattr(sceneobject, "u") + assert not hasattr(sceneobject, "v") + + +def test_plane_object_has_one_public_size_field(): + sceneobject = Scene().add(Plane.worldXY(), size=2.0) + + assert sceneobject.size == 2.0 + assert not hasattr(sceneobject, "planesize") + + def test_scene_node_contains_hierarchy_only(): scene = Scene() sceneobject = scene.add(Box(), name="Box", show=False) @@ -156,7 +195,7 @@ def test_missing_scene_operation_names_context_type_and_operation(operation_regi scene = Scene(context="Missing") sceneobject = scene.add(Box()) - with pytest.raises(SceneOperationNotRegisteredError, match="draw.*GeometryObject.*Missing"): + with pytest.raises(SceneOperationNotRegisteredError, match="draw.*ShapeObject.*Missing"): sceneobject.draw() diff --git a/tests/compas/scene/test_scene_serialisation.py b/tests/compas/scene/test_scene_serialisation.py index 88006d139c3..3517a6d786f 100644 --- a/tests/compas/scene/test_scene_serialisation.py +++ b/tests/compas/scene/test_scene_serialisation.py @@ -10,6 +10,11 @@ from compas.scene import GraphObject from compas.scene import GeometryObject from compas.scene import MeshObject +from compas.scene import FrameObject +from compas.scene import PlaneObject +from compas.scene import ShapeObject +from compas.scene import SurfaceObject +from compas.scene import VectorObject from compas.scene import Scene from compas.scene import SceneItemNotBoundError from compas.scene import SceneObject @@ -151,7 +156,13 @@ def test_type_specific_sceneobject_state_roundtrips(): graph.add_node(0, x=0, y=0, z=0) volmesh = VolMesh.from_vertices_and_cells([[0, 0, 0]], []) scene = Scene() - meshobject = scene.add(mesh, show_vertices=True, vertexsize=3.0) + meshobject = scene.add( + mesh, + show_vertices=True, + vertexsize=3.0, + hide_coplanaredges=True, + use_vertexcolors=True, + ) graphobject = scene.add(graph, show_nodes=False, edgewidth=2.0) volmeshobject = scene.add(volmesh, show_cells=False, vertexsize=4.0) @@ -163,6 +174,8 @@ def test_type_specific_sceneobject_state_roundtrips(): assert isinstance(restored_mesh, MeshObject) assert restored_mesh.show_vertices is True assert restored_mesh.vertexsize == 3.0 + assert restored_mesh.hide_coplanaredges is True + assert restored_mesh.use_vertexcolors is True assert isinstance(restored_graph, GraphObject) assert restored_graph.show_nodes is False assert restored_graph.edgewidth == 2.0 @@ -171,6 +184,40 @@ def test_type_specific_sceneobject_state_roundtrips(): assert restored_volmesh.vertexsize == 4.0 +def test_geometry_specific_sceneobject_state_roundtrips(): + from compas.geometry import Box + from compas.geometry import Frame + from compas.geometry import Plane + from compas.geometry import PlanarSurface + from compas.geometry import Point + from compas.geometry import Vector + + scene = Scene() + shapeobject = scene.add(Box(), u=8, v=12) + surfaceobject = scene.add(PlanarSurface(), u=5, v=7) + frameobject = scene.add(Frame.worldXY(), size=2.0) + planeobject = scene.add(Plane.worldXY(), size=3.0) + vectorobject = scene.add(Vector(1, 2, 3), anchor=Point(4, 5, 6)) + + restored = Scene.from_jsonstring(scene.to_jsonstring()) + + restored_shape = restored.object(shapeobject.guid) + restored_surface = restored.object(surfaceobject.guid) + restored_frame = restored.object(frameobject.guid) + restored_plane = restored.object(planeobject.guid) + restored_vector = restored.object(vectorobject.guid) + assert isinstance(restored_shape, ShapeObject) + assert (restored_shape.u, restored_shape.v) == (8, 12) + assert isinstance(restored_surface, SurfaceObject) + assert (restored_surface.u, restored_surface.v) == (5, 7) + assert isinstance(restored_frame, FrameObject) + assert restored_frame.size == 2.0 + assert isinstance(restored_plane, PlaneObject) + assert restored_plane.size == 3.0 + assert isinstance(restored_vector, VectorObject) + assert restored_vector.anchor == Point(4, 5, 6) + + def test_scene_state_fields_compose_defaults_and_omitted_defaults(): class BaseObject(SceneObject): __scene_state_namespace__ = "test.base" From 41e517133523c2d352529c15540d8011d75e5acd Mon Sep 17 00:00:00 2001 From: tomvanmele Date: Sat, 7 Nov 2026 23:25:02 +0100 Subject: [PATCH 08/10] Cover closed scene node validation --- tests/compas/scene/test_scene.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/compas/scene/test_scene.py b/tests/compas/scene/test_scene.py index f2b29b01708..156a3e0c5a2 100644 --- a/tests/compas/scene/test_scene.py +++ b/tests/compas/scene/test_scene.py @@ -1,3 +1,4 @@ +from typing import Any from uuid import uuid4 import pytest @@ -200,10 +201,17 @@ def test_missing_scene_operation_names_context_type_and_operation(operation_regi def test_scene_node_rejects_invalid_data(): + with pytest.raises(ValueError, match="Unknown scene-node fields: extra"): + SceneNode.__from_data__({"object": str(uuid4()), "children": [], "extra": True}) + with pytest.raises(ValueError, match="Missing scene-node fields: children"): + SceneNode.__from_data__({"object": str(uuid4())}) with pytest.raises(ValueError, match="UUID string"): SceneNode.__from_data__({"object": "invalid", "children": []}) with pytest.raises(TypeError, match="children"): SceneNode.__from_data__({"object": str(uuid4()), "children": ()}) + with pytest.raises(TypeError, match="object_guid should be a UUID"): + invalid_guid: Any = "invalid" + SceneNode(invalid_guid) def test_remove_clears_resources_for_every_context(operation_registry): From 314443976a056174ac755e4393059cb37795dda1 Mon Sep 17 00:00:00 2001 From: tomvanmele Date: Sun, 8 Nov 2026 10:27:33 +0100 Subject: [PATCH 09/10] Declare editable scene state --- CHANGELOG.md | 2 + docs/getting-started/basics.visualisation.md | 46 +++++ src/compas/scene/__init__.py | 2 + src/compas/scene/geometryobject.py | 16 +- src/compas/scene/graphobject.py | 12 +- src/compas/scene/meshobject.py | 16 +- src/compas/scene/sceneobject.py | 124 ++++++++++++- tests/compas/scene/test_scene.py | 180 +++++++++++++++++++ tests/typing/accepted.py | 28 +++ tests/typing/installed_consumer.py | 20 +++ 10 files changed, 420 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b03625c5b..e61100f9b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 hierarchy-only scene nodes. * Added stable semantic scene objects for shapes, surfaces, frames, planes, and vectors, with backend-neutral discretization and display state. +* Added MRO-composed editable scene-state fields, narrowly scoped non-state + declarations, and data-only mutation callbacks for contextual object editors. * Added a verified Linux Python 3.12 candidate wheel artifact for framework-wide pre-release integration. * Added the public `compas.typing` structural vocabulary with NumPy-aware `PointLike`, `VectorLike`, collection, and neutral `VerticesFaces` aliases. * Added the PEP 561 `py.typed` marker and installed-wheel static-consumer verification. diff --git a/docs/getting-started/basics.visualisation.md b/docs/getting-started/basics.visualisation.md index 3dcb4cd2cc6..5eb1ddb0c13 100644 --- a/docs/getting-started/basics.visualisation.md +++ b/docs/getting-started/basics.visualisation.md @@ -92,6 +92,52 @@ For example, meshes can have different colors for the vertices, the edges, and t And the colors of vertices, edges, and faces can be specified individually, per element. See the section about mesh visualisation for more information. +### Editable Attributes + +Stable scene-object classes declare which of their attributes may be exposed by +an object editor. The resolved mapping contains only attribute names and +optional data-side update callbacks. Visualisation packages decide how to +construct controls and refresh their views. + +```pycon +>>> attributes = type(sceneobj).editable_attributes() +>>> "show_surfaces" in attributes +True +>>> sceneobj.update_editable_attribute("opacity", 0.5) +>>> sceneobj.opacity +0.5 +``` + +The declarations compose through the scene-object class hierarchy. A `None` +callback uses ordinary attribute assignment. A declaration may instead provide +an atomic, synchronous data-update callback when assignment alone is not +sufficient. Update callbacks never draw, rebuild render data, or refresh a user +interface. + +Durable editable attributes are declared once as scene state: + +```python +from compas.scene import SceneObject +from compas.scene import SceneStateField + + +class CustomObject(SceneObject): + __scene_state_namespace__ = "example.CustomObject" + __scene_state_fields__ = (SceneStateField("quality", int, required=False, editable=True),) +``` + +The separate `__editable_attributes__` mapping is reserved for editable +attributes that are not durable scene state. Derived callback overrides retain +the inherited attribute position, while new attributes append in declaration +order. Use +[`SceneObject.update_editable_attribute`][compas.scene.SceneObject.update_editable_attribute] +to apply the default assignment or the declared data-update callback. + +Editability does not add presentation metadata to +[`SceneStateField`][compas.scene.SceneStateField] and is not serialized. A +durable scene-state field is not editable unless it explicitly sets +`editable=True`. + ## Scene Hierarchy Scene objects are organised in a hierarchy under an ordinary root group. A diff --git a/src/compas/scene/__init__.py b/src/compas/scene/__init__.py index 49e38935535..c93441de902 100644 --- a/src/compas/scene/__init__.py +++ b/src/compas/scene/__init__.py @@ -11,6 +11,7 @@ from .exceptions import SceneStateError from .exceptions import SceneOperationNotRegisteredError from .sceneobject import SceneObject +from .sceneobject import SceneAttributeUpdate from .sceneobject import SceneStateField from .meshobject import MeshObject from .graphobject import GraphObject @@ -65,6 +66,7 @@ def register_scene_objects_base() -> None: "SceneOperationNotRegisteredError", "SceneNode", "SceneObject", + "SceneAttributeUpdate", "SceneStateField", "MeshObject", "GraphObject", diff --git a/src/compas/scene/geometryobject.py b/src/compas/scene/geometryobject.py index ec80ab32a45..1ddb8d9cdc3 100644 --- a/src/compas/scene/geometryobject.py +++ b/src/compas/scene/geometryobject.py @@ -17,14 +17,14 @@ class GeometryObject(SceneObject): surfacecolor = ColorAttribute() __scene_state_namespace__ = "compas.scene.GeometryObject" __scene_state_fields__ = ( - SceneStateField("pointcolor", (Color, type(None)), default=None, required=False), - SceneStateField("linecolor", (Color, type(None)), default=None, required=False), - SceneStateField("surfacecolor", (Color, type(None)), default=None, required=False), - SceneStateField("pointsize", (int, float), default=1.0, required=False), - SceneStateField("linewidth", (int, float), default=1.0, required=False), - SceneStateField("show_points", bool, default=False, required=False), - SceneStateField("show_lines", bool, default=True, required=False), - SceneStateField("show_surfaces", bool, default=True, required=False), + SceneStateField("show_points", bool, default=False, required=False, editable=True), + SceneStateField("show_lines", bool, default=True, required=False, editable=True), + SceneStateField("show_surfaces", bool, default=True, required=False, editable=True), + SceneStateField("pointcolor", (Color, type(None)), default=None, required=False, editable=True), + SceneStateField("linecolor", (Color, type(None)), default=None, required=False, editable=True), + SceneStateField("surfacecolor", (Color, type(None)), default=None, required=False, editable=True), + SceneStateField("linewidth", (int, float), default=1.0, required=False, editable=True), + SceneStateField("pointsize", (int, float), default=1.0, required=False, editable=True), ) # ========================================================================== diff --git a/src/compas/scene/graphobject.py b/src/compas/scene/graphobject.py index 86f52d0792c..84b1bc39153 100644 --- a/src/compas/scene/graphobject.py +++ b/src/compas/scene/graphobject.py @@ -28,12 +28,12 @@ class GraphObject(SceneObject): edgecolor = ColorDictAttribute() __scene_state_namespace__ = "compas.scene.GraphObject" __scene_state_fields__ = ( - SceneStateField("show_nodes", (bool, list), default=True, required=False), - SceneStateField("show_edges", (bool, list), default=True, required=False), - SceneStateField("nodecolor", required=False), - SceneStateField("edgecolor", required=False), - SceneStateField("nodesize", (int, float), default=1.0, required=False), - SceneStateField("edgewidth", (int, float), default=1.0, required=False), + SceneStateField("show_nodes", (bool, list), default=True, required=False, editable=True), + SceneStateField("show_edges", (bool, list), default=True, required=False, editable=True), + SceneStateField("nodecolor", required=False, editable=True), + SceneStateField("edgecolor", required=False, editable=True), + SceneStateField("edgewidth", (int, float), default=1.0, required=False, editable=True), + SceneStateField("nodesize", (int, float), default=1.0, required=False, editable=True), ) # ========================================================================== diff --git a/src/compas/scene/meshobject.py b/src/compas/scene/meshobject.py index c7cce748230..b18c514a19b 100644 --- a/src/compas/scene/meshobject.py +++ b/src/compas/scene/meshobject.py @@ -26,14 +26,14 @@ class MeshObject(SceneObject): facecolor = ColorDictAttribute() __scene_state_namespace__ = "compas.scene.MeshObject" __scene_state_fields__ = ( - SceneStateField("show_vertices", (bool, list), default=False, required=False), - SceneStateField("show_edges", (bool, list), default=False, required=False), - SceneStateField("show_faces", (bool, list), default=True, required=False), - SceneStateField("vertexcolor", required=False), - SceneStateField("edgecolor", required=False), - SceneStateField("facecolor", required=False), - SceneStateField("vertexsize", (int, float), default=1.0, required=False), - SceneStateField("edgewidth", (int, float), default=1.0, required=False), + SceneStateField("show_vertices", (bool, list), default=False, required=False, editable=True), + SceneStateField("show_edges", (bool, list), default=False, required=False, editable=True), + SceneStateField("show_faces", (bool, list), default=True, required=False, editable=True), + SceneStateField("vertexcolor", required=False, editable=True), + SceneStateField("edgecolor", required=False, editable=True), + SceneStateField("facecolor", required=False, editable=True), + SceneStateField("edgewidth", (int, float), default=1.0, required=False, editable=True), + SceneStateField("vertexsize", (int, float), default=1.0, required=False, editable=True), SceneStateField("hide_coplanaredges", bool, default=False, required=False), SceneStateField("use_vertexcolors", bool, default=False, required=False), ) diff --git a/src/compas/scene/sceneobject.py b/src/compas/scene/sceneobject.py index 794e1ca23eb..97997d50ce0 100644 --- a/src/compas/scene/sceneobject.py +++ b/src/compas/scene/sceneobject.py @@ -1,11 +1,15 @@ +from collections.abc import Callable from collections.abc import Iterator +from collections.abc import Mapping from dataclasses import dataclass from functools import reduce from operator import mul +from types import MappingProxyType from typing import Any from typing import ClassVar from typing import Literal from typing import Self +from typing import cast from uuid import UUID from compas.colors import Color @@ -21,8 +25,12 @@ _MISSING = object() +type SceneAttributeUpdate[OwnerT] = Callable[[OwnerT, object], None] +type _SceneAttributeUpdate = Callable[[Any, object], None] + + @dataclass(frozen=True, slots=True) -class SceneStateField: +class SceneStateField[OwnerT]: """Declaration of one durable semantic scene-object field. Parameters @@ -39,6 +47,12 @@ class SceneStateField: Whether omission without a default is an error. omit_default Whether a value equal to `default` is omitted. + editable + Whether the ordinary object attribute may be edited by a consumer. + update + Optional synchronous data-only update callback. The callback must + leave the owner unchanged if it raises. Rendering and user-interface + refresh remain the responsibility of the visualization context. """ @@ -48,6 +62,8 @@ class SceneStateField: default: object = _MISSING required: bool = True omit_default: bool = False + editable: bool = False + update: SceneAttributeUpdate[OwnerT] | None = None @property def attribute(self) -> str: @@ -85,8 +101,12 @@ class SceneObject(Data): """ color = ColorAttribute() + __editable_attributes__: ClassVar[Mapping[str, _SceneAttributeUpdate | None]] = { + "name": None, + "opacity": None, + } __scene_state_namespace__: ClassVar[str | None] = None - __scene_state_fields__: ClassVar[tuple[SceneStateField, ...]] = () + __scene_state_fields__: ClassVar[tuple[SceneStateField[Any], ...]] = () @property def __data__(self) -> dict[str, Any]: @@ -327,9 +347,99 @@ def contrastcolor(self, color: Color) -> None: self._contrastcolor = Color.coerce(color) @classmethod - def scene_state_fields(cls) -> dict[str, tuple[SceneStateField, ...]]: + def editable_attributes(cls) -> Mapping[str, SceneAttributeUpdate[Self] | None]: + """Return editable attributes composed across the MRO. + + The result combines non-state declarations with editable durable + scene-state fields. A derived non-state declaration replaces an + inherited callback without changing attribute order. New attributes + append in declaration order. + + Returns + ------- + Mapping[str, SceneAttributeUpdate[Self] | None] + Read-only ordered mapping from attribute names to optional + data-side update callbacks. A value of `None` selects ordinary + `setattr` mutation. + + Raises + ------ + TypeError + If a class declaration is not a mapping or a callback is not + callable. + SceneStateError + If a non-state declaration duplicates an editable scene-state + field. + ValueError + If an attribute name is empty. + + """ + declarations: dict[str, SceneAttributeUpdate[Self] | None] = {} + sources: dict[str, Literal["non-state", "state"]] = {} + state_fields = cls.scene_state_fields() + for owner in reversed(cls.mro()): + local = owner.__dict__.get("__editable_attributes__", {}) + if not isinstance(local, Mapping): + raise TypeError(f"Editable attributes on {owner.__name__} should be a mapping.") + for name, update in local.items(): + if not isinstance(name, str): + raise TypeError(f"Editable attribute names on {owner.__name__} should be strings.") + if not name: + raise ValueError(f"Editable attribute names on {owner.__name__} cannot be empty.") + if update is not None and not callable(update): + raise TypeError(f"Editable attribute update for {owner.__name__}.{name} should be callable or None.") + if sources.get(name) == "state": + raise SceneStateError(f"Editable attribute {owner.__name__}.{name} duplicates a scene-state field.") + declarations[name] = cast(SceneAttributeUpdate[Self] | None, update) + sources[name] = "non-state" + + namespace = owner.__dict__.get("__scene_state_namespace__") + if namespace not in state_fields: + continue + for field in state_fields[namespace]: + if not field.editable: + continue + name = field.attribute + if name in declarations: + raise SceneStateError(f"Editable scene-state field {namespace}.{field.name} duplicates {name}.") + declarations[name] = cast(SceneAttributeUpdate[Self] | None, field.update) + sources[name] = "state" + return MappingProxyType(declarations) + + def update_editable_attribute(self, name: str, value: object) -> None: + """Update one declared editable attribute. + + Ordinary declarations use `setattr`. A declaration-specific update + callback replaces that assignment and must leave this object unchanged + if it raises. This method performs no rendering or user-interface + refresh. + + Parameters + ---------- + name + Name of a declared editable attribute. + value + New attribute value. + + Raises + ------ + KeyError + If `name` is not declared editable for this scene-object type. + + """ + try: + update = self.editable_attributes()[name] + except KeyError: + raise KeyError(f"Scene object attribute is not editable: {name}.") from None + if update is None: + setattr(self, name, value) + else: + update(self, value) + + @classmethod + def scene_state_fields(cls) -> dict[str, tuple[SceneStateField[Any], ...]]: """Return durable field declarations composed across the MRO.""" - declarations: dict[str, tuple[SceneStateField, ...]] = {} + declarations: dict[str, tuple[SceneStateField[Any], ...]] = {} attributes: set[str] = set() for owner in reversed(cls.mro()): fields = owner.__dict__.get("__scene_state_fields__", ()) @@ -356,6 +466,12 @@ def scene_state_fields(cls) -> dict[str, tuple[SceneStateField, ...]]: raise SceneStateError(f"Invalid default for scene-state field {namespace}.{field.name}.") if field.omit_default and field.default is _MISSING: raise SceneStateError(f"Scene-state field {namespace}.{field.name} cannot omit an undeclared default.") + if not isinstance(field.editable, bool): + raise SceneStateError(f"Invalid editable flag for scene-state field {namespace}.{field.name}.") + if field.update is not None and not callable(field.update): + raise SceneStateError(f"Invalid update callback for scene-state field {namespace}.{field.name}.") + if field.update is not None and not field.editable: + raise SceneStateError(f"Scene-state field {namespace}.{field.name} cannot define update unless editable.") if field.attribute in attributes: raise SceneStateError(f"Duplicate scene-state adapter attribute: {field.attribute}.") attributes.add(field.attribute) diff --git a/tests/compas/scene/test_scene.py b/tests/compas/scene/test_scene.py index 156a3e0c5a2..79e5d85318e 100644 --- a/tests/compas/scene/test_scene.py +++ b/tests/compas/scene/test_scene.py @@ -1,3 +1,5 @@ +import subprocess +import sys from typing import Any from uuid import uuid4 @@ -16,6 +18,7 @@ from compas.scene import FrameObject from compas.scene import GraphObject from compas.scene import GeometryObject +from compas.scene import MeshObject from compas.scene import PlaneObject from compas.scene import Scene from compas.scene import SceneNode @@ -307,6 +310,9 @@ def test_graph_and_volmesh_coordinates_invalidate_on_ancestor_transform(): def test_scene_state_field_declarations_are_fully_validated(): + invalid_editable: Any = "yes" + invalid_update: Any = "update" + class InvalidDefault(SceneObject): __scene_state_namespace__ = "test.invalid.default" __scene_state_fields__ = (SceneStateField("value", int, default=1),) @@ -322,12 +328,186 @@ class DuplicateAttribute(SceneObject): SceneStateField("two", int, attribute_name="value"), ) + class UpdateWithoutEditable(SceneObject): + __scene_state_namespace__ = "test.update.without.editable" + __scene_state_fields__ = (SceneStateField("value", int, update=lambda owner, value: None),) + + class InvalidEditable(SceneObject): + __scene_state_namespace__ = "test.invalid.editable" + __scene_state_fields__ = (SceneStateField("value", int, editable=invalid_editable),) + + class InvalidUpdate(SceneObject): + __scene_state_namespace__ = "test.invalid.update" + __scene_state_fields__ = (SceneStateField("value", int, editable=True, update=invalid_update),) + with pytest.raises(SceneStateError, match="default and be required"): InvalidDefault.scene_state_fields() with pytest.raises(SceneStateError, match="undeclared default"): InvalidOmission.scene_state_fields() with pytest.raises(SceneStateError, match="Duplicate scene-state adapter attribute"): DuplicateAttribute.scene_state_fields() + with pytest.raises(SceneStateError, match="cannot define update unless editable"): + UpdateWithoutEditable.scene_state_fields() + with pytest.raises(SceneStateError, match="Invalid editable flag"): + InvalidEditable.scene_state_fields() + with pytest.raises(SceneStateError, match="Invalid update callback"): + InvalidUpdate.scene_state_fields() + + +def test_editable_attribute_declarations_compose_state_and_non_state_fields(): + def update_replaced(owner, value): + owner.replaced = value + + class BaseObject(SceneObject): + __editable_attributes__ = { + "first": None, + "replaced": None, + } + + __scene_state_namespace__ = "test.editable.base" + __scene_state_fields__ = (SceneStateField("state", int, required=False, editable=True),) + + class DerivedObject(BaseObject): + __editable_attributes__ = { + "replaced": update_replaced, + "last": None, + } + + __scene_state_namespace__ = "test.editable.derived" + __scene_state_fields__ = (SceneStateField("derived_state", int, required=False, editable=True),) + + attributes = DerivedObject.editable_attributes() + + assert list(attributes) == ["name", "opacity", "first", "replaced", "state", "last", "derived_state"] + assert attributes["replaced"] is update_replaced + assert attributes["state"] is None + with pytest.raises(TypeError): + mutable_attributes: Any = attributes + mutable_attributes["extra"] = None + + +def test_editable_attribute_declarations_are_fully_validated(): + class InvalidContainer(SceneObject): + __editable_attributes__ = () + + class InvalidName(SceneObject): + __editable_attributes__ = {1: None} + + class EmptyName(SceneObject): + __editable_attributes__ = {"": None} + + class InvalidUpdate(SceneObject): + __editable_attributes__ = {"value": "invalid"} + + class DuplicateStateField(SceneObject): + __editable_attributes__ = {"value": None} + __scene_state_namespace__ = "test.editable.duplicate" + __scene_state_fields__ = (SceneStateField("value", int, required=False, editable=True),) + + with pytest.raises(TypeError, match="InvalidContainer.*mapping"): + InvalidContainer.editable_attributes() + with pytest.raises(TypeError, match="names.*strings"): + InvalidName.editable_attributes() + with pytest.raises(ValueError, match="cannot be empty"): + EmptyName.editable_attributes() + with pytest.raises(TypeError, match="update.*callable or None"): + InvalidUpdate.editable_attributes() + with pytest.raises(SceneStateError, match="duplicates"): + DuplicateStateField.editable_attributes() + + +def test_update_editable_attribute_uses_default_and_declared_data_mutation(): + calls = [] + + def update_value(owner, value): + calls.append((owner, value)) + owner.value = value * 2 + + def reject_value(owner, value): + raise ValueError("rejected") + + class EditableObject(SceneObject): + __editable_attributes__ = { + "name": None, + } + __scene_state_namespace__ = "test.editable.update" + __scene_state_fields__ = ( + SceneStateField("value", (int, float), required=False, editable=True, update=update_value), + SceneStateField("rejected", (int, float), required=False, editable=True, update=reject_value), + ) + + def __init__(self): + super().__init__() + self.value = 1 + self.rejected = 2 + + sceneobject = EditableObject() + sceneobject.update_editable_attribute("name", "Updated") + sceneobject.update_editable_attribute("value", 3) + + assert sceneobject.name == "Updated" + assert sceneobject.value == 6 + assert calls == [(sceneobject, 3)] + with pytest.raises(ValueError, match="rejected"): + sceneobject.update_editable_attribute("rejected", 4) + assert sceneobject.rejected == 2 + with pytest.raises(KeyError, match="not editable: show"): + sceneobject.update_editable_attribute("show", False) + + +def test_builtin_editable_attributes_match_stable_object_state(): + assert list(SceneObject.editable_attributes()) == ["name", "opacity"] + assert list(GeometryObject.editable_attributes()) == [ + "name", + "opacity", + "show_points", + "show_lines", + "show_surfaces", + "pointcolor", + "linecolor", + "surfacecolor", + "linewidth", + "pointsize", + ] + assert list(MeshObject.editable_attributes()) == [ + "name", + "opacity", + "show_vertices", + "show_edges", + "show_faces", + "vertexcolor", + "edgecolor", + "facecolor", + "edgewidth", + "vertexsize", + ] + assert list(GraphObject.editable_attributes()) == [ + "name", + "opacity", + "show_nodes", + "show_edges", + "nodecolor", + "edgecolor", + "edgewidth", + "nodesize", + ] + assert "u" not in ShapeObject.editable_attributes() + assert "size" not in FrameObject.editable_attributes() + assert "hide_coplanaredges" not in MeshObject.editable_attributes() + assert "show_vertices" not in VolMeshObject.editable_attributes() + assert "__editable_attributes__" not in GeometryObject.__dict__ + assert "__editable_attributes__" not in MeshObject.__dict__ + assert "__editable_attributes__" not in GraphObject.__dict__ + + +def test_editable_attribute_declarations_are_runtime_only_and_graphics_free(): + sceneobject = GeometryObject() + + assert "editable" not in sceneobject.__data__ + assert "update" not in sceneobject.__data__ + + code = "import sys; import compas.scene; assert 'PySide6' not in sys.modules; assert 'OpenGL' not in sys.modules" + subprocess.run([sys.executable, "-c", code], check=True) def test_sceneobject_discovery_is_not_inferred_from_registry_emptiness(monkeypatch): diff --git a/tests/typing/accepted.py b/tests/typing/accepted.py index bfedd8ae6fa..068db480cd9 100644 --- a/tests/typing/accepted.py +++ b/tests/typing/accepted.py @@ -1,4 +1,5 @@ from collections.abc import Iterator +from collections.abc import Mapping from decimal import Decimal from typing import SupportsFloat from typing import assert_type @@ -57,6 +58,9 @@ from compas.geometry.triangulation import triangulate_polygon from compas.datastructures import Graph from compas.datastructures import Mesh +from compas.scene import SceneAttributeUpdate +from compas.scene import SceneObject +from compas.scene import SceneStateField class CustomCoordinate: @@ -298,3 +302,27 @@ def numpy_triangulator(vertices: PointsLike) -> np.ndarray[tuple[int, int], np.d constraints=np.asarray([vertices32[:2]], dtype=np.float64), points=np.asarray([[0.25, 0.25, 0]], dtype=np.int64), ) + + +def update_sceneobject(owner: "CustomSceneObject", value: object) -> None: + if not isinstance(value, (int, float)): + raise TypeError + owner.value = float(value) + + +class CustomSceneObject(SceneObject): + __scene_state_namespace__ = "test.typing.CustomSceneObject" + __scene_state_fields__ = (SceneStateField("value", (int, float), required=False, editable=True, update=update_sceneobject),) + + def __init__(self) -> None: + super().__init__() + self.value = 1.0 + + +custom_sceneobject = CustomSceneObject() +custom_update: SceneAttributeUpdate[CustomSceneObject] = update_sceneobject +assert_type( + custom_sceneobject.editable_attributes(), + Mapping[str, SceneAttributeUpdate[CustomSceneObject] | None], +) +custom_sceneobject.update_editable_attribute("value", 2.0) diff --git a/tests/typing/installed_consumer.py b/tests/typing/installed_consumer.py index 48ac1443f15..31a32a2407a 100644 --- a/tests/typing/installed_consumer.py +++ b/tests/typing/installed_consumer.py @@ -26,6 +26,9 @@ from compas.typing import PointLike from compas.typing import RealInput from compas.typing import VerticesFaces +from compas.scene import SceneAttributeUpdate +from compas.scene import SceneObject +from compas.scene import SceneStateField vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] @@ -53,6 +56,19 @@ class CustomMesh(Mesh): custom_mesh = CustomMesh.from_vertices_and_faces(vertices, triangles) + +def update_sceneobject(owner: "CustomSceneObject", value: object) -> None: + owner.name = str(value) + + +class CustomSceneObject(SceneObject): + __scene_state_namespace__ = "test.typing.CustomSceneObject" + __scene_state_fields__ = (SceneStateField("value", object, required=False, editable=True, update=update_sceneobject),) + + +custom_sceneobject = CustomSceneObject() +custom_update: SceneAttributeUpdate[CustomSceneObject] = update_sceneobject + assert_type(Point(0.0, 0.0, 0.0).x, float) assert_type(tessellation.number_of_triangles, int) assert_type(tessellation.to_vertices_and_triangles(), tuple[list[list[float]], list[list[int]]]) @@ -65,6 +81,10 @@ class CustomMesh(Mesh): assert_type(planar_region.triangulated(), Tessellation) assert_type(mesh.triangulated(), Mesh) assert_type(custom_mesh.triangulated(), CustomMesh) +assert_type( + custom_sceneobject.editable_attributes()["value"], + SceneAttributeUpdate[CustomSceneObject] | None, +) assert_type(triangulate_polygon(vertices), list[list[int]]) assert_type(triangulate_polygon_earclip(vertices), list[list[int]]) assert_type(triangulate_polygon_minimum_area(vertices), list[list[int]]) From 0b4555c23d570589bf9eab412cfa5a71312e1aa2 Mon Sep 17 00:00:00 2001 From: tomvanmele Date: Sun, 8 Nov 2026 12:07:49 +0100 Subject: [PATCH 10/10] Document scene state serialization --- docs/getting-started/basics.visualisation.md | 47 ++++++++++++++++++++ src/compas/scene/sceneobject.py | 25 +++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/docs/getting-started/basics.visualisation.md b/docs/getting-started/basics.visualisation.md index 5eb1ddb0c13..e366cc416df 100644 --- a/docs/getting-started/basics.visualisation.md +++ b/docs/getting-started/basics.visualisation.md @@ -138,6 +138,53 @@ Editability does not add presentation metadata to durable scene-state field is not editable unless it explicitly sets `editable=True`. +### Data, Scene State, and Extensions + +Every scene object is a [`Data`][compas.data.Data] object. COMPAS serialization +wraps it in an envelope containing its data type, GUID, optional name, and the +payload returned by `SceneObject.__data__`. The represented geometry or +datastructure is another independent `Data` object; the scene object stores +only its GUID in the `item` field. + +The scene-object payload has two state layers: + +- Common state such as `transformation`, `color`, `opacity`, and `show` is + stored directly in the payload. +- Type-specific state declared by `SceneStateField` is stored under the + declaring class's namespace in the `extensions` mapping. + +For example, a shape scene object may serialize geometry display settings and +shape resolution separately: + +```python +from compas.colors import Color + + +payload = { + "item": "", + "transformation": None, + "color": Color.white(), + "opacity": 1.0, + "show": True, + "extensions": { + "compas.scene.GeometryObject": { + "show_points": False, + "show_lines": True, + }, + "compas.scene.ShapeObject": { + "u": 16, + "v": 16, + }, + }, +} +``` + +Namespaces let subclasses and external packages add durable state without +widening the common scene-object payload. Unknown namespaced values are +preserved during a round trip for forward compatibility. Runtime item +bindings, editable-attribute callbacks, Viewer controls, selection, and render +resources are not stored in `extensions` or elsewhere in the serialized scene. + ## Scene Hierarchy Scene objects are organised in a hierarchy under an ordinary root group. A diff --git a/src/compas/scene/sceneobject.py b/src/compas/scene/sceneobject.py index 97997d50ce0..a6e9321ee13 100644 --- a/src/compas/scene/sceneobject.py +++ b/src/compas/scene/sceneobject.py @@ -51,8 +51,8 @@ class SceneStateField[OwnerT]: Whether the ordinary object attribute may be edited by a consumer. update Optional synchronous data-only update callback. The callback must - leave the owner unchanged if it raises. Rendering and user-interface - refresh remain the responsibility of the visualization context. + leave the owner unchanged if it raises. The caller owns any rendering + or user-interface effects after a successful update. """ @@ -438,7 +438,26 @@ def update_editable_attribute(self, name: str, value: object) -> None: @classmethod def scene_state_fields(cls) -> dict[str, tuple[SceneStateField[Any], ...]]: - """Return durable field declarations composed across the MRO.""" + """Return durable type-specific fields composed across the MRO. + + The result groups fields by their declaring class's scene-state + namespace. During serialization, field values are written to the + matching namespace in `SceneObject.__data__["extensions"]`. Common + scene state such as `color`, `opacity`, and `show` remains in the base + scene-object payload instead. + + Returns + ------- + dict[str, tuple[SceneStateField, ...]] + Field declarations keyed by stable extension namespace. + + Raises + ------ + SceneStateError + If namespaces, field declarations, defaults, or editability + metadata are inconsistent. + + """ declarations: dict[str, tuple[SceneStateField[Any], ...]] = {} attributes: set[str] = set() for owner in reversed(cls.mro()):