Skip to content

Scene Data Store #1461

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
Draft

Scene Data Store #1461

wants to merge 20 commits into from

Conversation

Licini
Copy link
Contributor

@Licini Licini commented May 14, 2025

Hey guys a rather large PR, there are several components to this, let me explain:

1. Scene data store

Previously we inherent Scene from Tree and SceneObject from TreeNode. It was nice but the serialization was not very straightforward, because we don't want to serialize Data item directly under SceneObject since they might be reused. So we had to customize the serialization and deserialization to only store guid of item under SceneObject, then group all the distinct items as a list under scene.

This PR basically makes the idea more explicit by reflecting this mechanism directly as the attributes of Scene. Under Scene, we have 3 main aspects: datastore, objectstore and tree. The datastore is a bucket of unique Data items, the objectstore is a bucket of SceneObjects, both keyed by their guids. And tree stores hierarchy of the scene objects, but nothing more.

For SceneObject, instead maintaining a live link to Data item, we only actually store guid of that item and use the datastore of the scene to retrieve it (with minimal overhead).

The benefit is a much more transparent serialization and deserialization process: now both Scene and SceneObject can be json dumped/loaded without any customization using our default encoder/decoder. The structure of two classes directly reflect the serialized data with almost no change.

This also makes it easier for further extension of Scene for example if you want to use a datastore that contains very large geometry but with lazy loading, you can pass in a custom datastore yourself when creating the scene, using dict-like object which can be queried with a guid.

For example a minimal serialized scene can look like this now:

{
    "data": {
        "attributes": {},
        "datastore": {
            "07b131a8-6edc-407a-8944-174a274fec5b": {
                "data": {
                    # ...
                },
                "dtype": "compas.geometry/Box",
                "guid": "07b131a8-6edc-407a-8944-174a274fec5b"
            }
        },
        "name": "Scene",
        "objectstore": {
            "22fd4e9c-66fe-481a-9392-c07c7fea2522": {
                "data": {
                    "item": "07b131a8-6edc-407a-8944-174a274fec5b",
                    # ...
                },
                "dtype": "compas.scene/GeometryObject",
                "guid": "22fd4e9c-66fe-481a-9392-c07c7fea2522",
                "name": "box1"
            },
            "bea576ac-641e-400b-9083-0ed018003af0": {
                "data": {
                    "item": "07b131a8-6edc-407a-8944-174a274fec5b",
                    # ...
                },
                "dtype": "compas.scene/GeometryObject",
                "guid": "bea576ac-641e-400b-9083-0ed018003af0",
                "name": "box2"
            }
        },
        "tree": {
            "data": {
                "attributes": {},
                "root": {
                    "children": [
                        {
                            "children": [
                                {
                                    "name": "bea576ac-641e-400b-9083-0ed018003af0"
                                }
                            ],
                            "name": "22fd4e9c-66fe-481a-9392-c07c7fea2522"
                        }
                    ],
                    "name": "Scene"
                }
            },
            "dtype": "compas.datastructures/Tree",
            "guid": "ef7305fd-cabc-45c2-a66e-e3cdc367ec25",
            "inheritance": []
        }
    },
    "dtype": "compas.scene/Scene",
    "guid": "ac6b3bc4-bda2-4c5c-9d0e-8496d3590cb5",
    "inheritance": []
}

2. SceneObjectFactory

We have been using __new__ for automatically choosing appropriate SceneObject class. It has been working well most of time. But it becomes annoying if one wants to initialize a custom SceneObject class like Group, where I just want a straightforward initiation without triggering auto class detection. @tomvanmele has been advocating for more explicit Factory method instead __new__, to make the constructor behavior more predictable and more type-hint friendly. Which I quite agree.

This does not change the external APIs, users still use Scene.add the same way, which internally calls the factory method now instead of the class constructor.

3. Others

I added more tests on Scene to make sure it behaves correctly. In summary, although there are a big internal re-organization in Scene and SceneObject, the user-facing APIs aren't changed, there also shouldn't be any changes needed for all the extended SceneObjects. I will drop some in-line comments below

@Licini Licini changed the title Scene Scene Data Store May 14, 2025
Copy link

codecov bot commented May 15, 2025

Codecov Report

Attention: Patch coverage is 90.55118% with 12 lines in your changes missing coverage. Please review.

Project coverage is 61.93%. Comparing base (c1ac434) to head (01cf1af).
Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
src/compas/scene/sceneobject.py 83.01% 9 Missing ⚠️
src/compas/scene/scene.py 96.00% 2 Missing ⚠️
src/compas/datastructures/tree/tree.py 88.88% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1461      +/-   ##
==========================================
- Coverage   61.94%   61.93%   -0.01%     
==========================================
  Files         208      208              
  Lines       22365    22401      +36     
==========================================
+ Hits        13853    13875      +22     
- Misses       8512     8526      +14     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Licini Licini marked this pull request as ready for review May 16, 2025 14:53
Copy link
Contributor Author

@Licini Licini May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidating _get_sceneobject_cls and get_sceneobject_cls into one function. Also cleaned up the kwargs. I removed sceneobject_type parameter because now you can call constructor directly with __new__ removed.

Comment on lines -30 to -42

def __new__(cls, *args, **kwargs):
# overwriting __new__ to revert to the default behavior of normal object, So an instance can be created directly without providing a registered item.
return object.__new__(cls)

@property
def __data__(self):
# type: () -> dict
data = {
"settings": self.settings,
"children": [child.__data__ for child in self.children],
}
return data
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These customizations are no longer needed anymore, thanks to the more straight forward serialization/deserialization

Comment on lines 44 to -71
@property
def __data__(self):
# type: () -> dict
items = {str(object.item.guid): object.item for object in self.objects if object.item is not None}
return {
"name": self.name,
"root": self.root.__data__, # type: ignore
"items": list(items.values()),
"attributes": self.attributes,
"datastore": self.datastore,
"objectstore": self.objectstore,
"tree": self.tree,
}

@classmethod
def __from_data__(cls, data):
# type: (dict) -> Scene
scene = cls(data["name"])
items = {str(item.guid): item for item in data["items"]}

def add(node, parent, items):
for child_node in node.get("children", []):
settings = child_node["settings"]
if "item" in child_node:
guid = child_node["item"]
sceneobject = parent.add(items[guid], **settings)
else:
sceneobject = parent.add(Group(**settings))
add(child_node, sceneobject, items)

add(data["root"], scene, items)

return scene
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We directly serialize datastore, objectstore and tree here. And during deserialization we can read them as they are without using a custom __from__data__ method.

# type: (str, str | None) -> None
super(Scene, self).__init__(name=name)
super(Scene, self).add(TreeNode(name="ROOT"))
def __init__(self, context=None, datastore=None, objectstore=None, tree=None, **kwargs):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users can passing their own customs stores and tree, if they know what they are doing.

Comment on lines 186 to +188
def item(self):
# type: () -> compas.data.Data
return self._item
return self.scene.datastore[self._item]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

item now becomes a getter that retrieve the live object from datastore, what we actually store as _item is the guid

Comment on lines +195 to +218
@property
def is_root(self):
# type: () -> bool
return self.node.is_root

@property
def is_leaf(self):
# type: () -> bool
return self.node.is_leaf

@property
def is_branch(self):
# type: () -> bool
return self.node.is_branch

@property
def parentnode(self):
# type: () -> compas.datastructures.Node | None
return self.node.parent

@property
def childnodes(self):
# type: () -> list[compas.datastructures.Node]
return self.node.children
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"shortcut" properties that allows us to still work with SceneObject like a TreeNode

@tomvanmele
Copy link
Member

@Licini shall we try to finish this?

Comment on lines 6 to +15

from compas.scene import SceneObject
from compas.scene import sceneobject_factory


class CompasToRhinoGeometry(component):
def RunScript(self, cg):
if not cg:
return None

return SceneObject(item=cg).draw()
return sceneobject_factory(item=cg).draw()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gonzalocasas @chenkasirer Please note here, with the factory pattern, you need call factory function in GH components instead of constructor

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change that affects a lot of external code. It needs a major release (compas 3!). Also, I don't like the method name sceneobject_factory, it feels old, could we add the factory method as a class/static method of SceneObject, like SceneObject.create()? the word factory on its own sounds too much java-like to my taste :P

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change that affects a lot of external code. It needs a major release (compas 3!). Also, I don't like the method name sceneobject_factory, it feels old, could we add the factory method as a class/static method of SceneObject, like SceneObject.create()? the word factory on its own sounds too much java-like to my taste :P

Ok 🤣 I had impression that these are the only places SceneOjbect constructor are called. But sure SceneObject.create() is a good idea to me too. Regarding the full PR, I have been having more discussions @tomvanmele on potential issues and corresponding reworks for data store, so I will close this one for now and re-submit another one that only concerns the serialization part. We can deal with __new__ in the future in compas 3

@Licini
Copy link
Contributor Author

Licini commented May 26, 2025

@Licini shall we try to finish this?

Done with the requested changes

@Licini Licini marked this pull request as draft May 30, 2025 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants