Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/scenex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
from .model._nodes.volume import Volume
from .model._transform import Transform
from .model._view import Letterbox, ResizePolicy, View
from .util import native, run, set_cursor, show
from .utils._app import native, run, set_cursor, show
Comment thread
gselzer marked this conversation as resolved.

__all__ = [
"Camera",
Expand Down
2 changes: 1 addition & 1 deletion src/scenex/model/_nodes/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,6 @@ def iter_parents(self) -> Iterator[Node]:

def tree_repr(self) -> str:
"""Return an ASCII/Unicode tree representation of self and its descendants."""
from scenex.util import tree_repr
from scenex.utils._tree import tree_repr

return tree_repr(self, node_repr=object.__repr__)
4 changes: 3 additions & 1 deletion src/scenex/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Utilities for working with scenex structures."""

__all__ = []
from ._app import native, run, set_cursor, show

__all__ = ["native", "run", "set_cursor", "show"]
141 changes: 5 additions & 136 deletions src/scenex/util.py → src/scenex/utils/_app.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
"""Utility functions for displaying and debugging scenex visualizations.

This module provides helper functions for common visualization tasks including
displaying models, formatting scene graph trees, and utility functions used
internally by scenex.
"""Utility functions pertaining to the underlying application.

The `show()` function is the primary entry point for creating visualizations,
handling the details of canvas creation, backend selection, and camera fitting
Expand All @@ -12,98 +8,20 @@
from __future__ import annotations

import logging
from collections.abc import Callable, Iterable
from contextlib import suppress
from typing import TYPE_CHECKING, Any, Protocol, cast
from typing import TYPE_CHECKING, Any, cast

from scenex import model
from scenex.app import app
from scenex.utils import projections

if TYPE_CHECKING:
from typing import TypeAlias
from . import projections

if TYPE_CHECKING:
from scenex.adaptors._base import CanvasAdaptor
from scenex.app._auto import CursorType

Tree: TypeAlias = str | dict[str, list["Tree"]]

class SupportsChildren(Protocol):
"""Protocol for node-like objects that have children."""

@property
def children(self) -> Iterable[SupportsChildren]:
"""Return the children of the node."""
...


__all__ = ["native", "show", "tree_dict", "tree_repr"]

logger = logging.getLogger("scenex")


def tree_repr(
node: SupportsChildren,
*,
node_repr: Callable[[Any], str] = object.__repr__,
_prefix: str = "",
_is_last: bool = True,
) -> str:
"""
Return an ASCII/Unicode tree representation of `node` and its descendants.

This assumes that `node` is a tree-like object with a `children` attribute that is
either a property or a callable that returns an iterable of child nodes.

Parameters
----------
node : SupportsChildren
Any object that has a `children` attribute or method that returns an iterable
of child nodes.
node_repr : Callable[[Any], str], optional
Function to convert the node to a string. Defaults to `object.__repr__` (which
avoids complex repr functions on objects, but use `repr` if you want to see
the full representation).
_prefix : str, optional
Prefix to use for each line of the tree. Defaults to an empty string.
_is_last : bool, optional
Whether this node is the last child of its parent. Defaults to `True`.
This is used to determine the branch character to use in the tree
representation.
"""
if _prefix:
branch = "└── " if _is_last else "├── "
else:
branch = ""

lines: list[str] = [f"{_prefix}{branch}{node_repr(node)}"]
if children := list(_get_children(node)):
prefix_child = _prefix + (" " if _is_last else "│ ")
for idx, child in enumerate(children):
lines.append(
tree_repr(
child,
node_repr=node_repr,
_prefix=prefix_child,
_is_last=idx == len(children) - 1,
)
)
return "\n".join(lines)


def _ensure_iterable(obj: object) -> Iterable[Any]:
"""Ensure the object is iterable."""
if isinstance(obj, Iterable):
return obj
if callable(obj):
with suppress(TypeError):
return _ensure_iterable(obj())
raise TypeError(
f"Expected an iterable or callable that returns an iterable, "
f"got {type(obj).__name__}"
)


def show(
obj: model.Node | model.View | model.Canvas, *, backend: str | None = None
) -> model.Canvas:
Expand Down Expand Up @@ -159,7 +77,7 @@ def show(
- Call `run()` after `show()` to enter the event loop in desktop applications
- In Jupyter notebooks, visualizations appear automatically without `run()`
"""
from .adaptors import get_adaptor_registry
from scenex.adaptors import get_adaptor_registry

view = None
if isinstance(obj, model.Canvas):
Expand All @@ -183,10 +101,6 @@ def show(
app().create_app()
for view in canvas.views:
projections.zoom_to_fit(view, zoom_factor=0.9, letterbox=True)

# logger.debug("SHOW MODEL %s", tree_repr(view.scene))
# native_scene = view.scene._get_native()
# logger.debug("SHOW NATIVE %s", tree_repr(native_scene))
return canvas


Expand Down Expand Up @@ -258,51 +172,6 @@ def run() -> None:
app().run()


def _cls_name_with_id(obj: Any) -> str:
return f"{obj.__class__.__name__}:{id(obj)}"


def tree_dict(
node: SupportsChildren,
*,
obj_name: Callable[[Any], str] = _cls_name_with_id,
) -> Tree:
"""Build an intermediate representation of the tree rooted at `node`.

Leaves are represented as strings, and non-leaf nodes are represented as
dictionaries with the node name as the key and a list of child nodes as the value.
This is useful for debugging and visualization purposes.

Parameters
----------
node : SupportsChildren
The root node of the tree to be represented.
obj_name : Callable[[Any], str], optional
A function to convert the node to a string. Defaults to a lambda function that
returns the class name and ID

Returns
-------
str | dict[str, list[dict | str]]
A string, if the node is a leaf, or a dictionary representing the tree,
if the node has children, like `{"node_name": ["child1", "child2", ...]}`.
"""
node_name = obj_name(node)
if not (children := _get_children(node)):
return node_name

result: list[dict | str] = []
for child in children:
result.append(tree_dict(child, obj_name=obj_name))
return {obj_name(node): result}


def _get_children(obj: Any) -> Iterable[Any]:
if (children := getattr(obj, "children", None)) is None:
return ()
return _ensure_iterable(children)


def set_cursor(canvas: model.Canvas, cursor: CursorType) -> None:
"""Set the cursor for the given canvas.

Expand Down
126 changes: 126 additions & 0 deletions src/scenex/utils/_tree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Utility functions for displaying and debugging scenex scenegraphs."""

from __future__ import annotations

from collections.abc import Callable, Iterable
from contextlib import suppress
from typing import TYPE_CHECKING, Any, Protocol

if TYPE_CHECKING:
from typing import TypeAlias

Tree: TypeAlias = str | dict[str, list["Tree"]]

class SupportsChildren(Protocol):
"""Protocol for node-like objects that have children."""

@property
def children(self) -> Iterable[SupportsChildren]:
"""Return the children of the node."""
...


def _cls_name_with_id(obj: Any) -> str:
return f"{obj.__class__.__name__}:{id(obj)}"


def tree_repr(
node: SupportsChildren,
*,
node_repr: Callable[[Any], str] = object.__repr__,
_prefix: str = "",
_is_last: bool = True,
) -> str:
"""
Return an ASCII/Unicode tree representation of `node` and its descendants.

This assumes that `node` is a tree-like object with a `children` attribute that is
either a property or a callable that returns an iterable of child nodes.

Parameters
----------
node : SupportsChildren
Any object that has a `children` attribute or method that returns an iterable
of child nodes.
node_repr : Callable[[Any], str], optional
Function to convert the node to a string. Defaults to `object.__repr__` (which
avoids complex repr functions on objects, but use `repr` if you want to see
the full representation).
_prefix : str, optional
Prefix to use for each line of the tree. Defaults to an empty string.
_is_last : bool, optional
Whether this node is the last child of its parent. Defaults to `True`.
This is used to determine the branch character to use in the tree
representation.
"""
if _prefix:
branch = "└── " if _is_last else "├── "
else:
branch = ""

lines: list[str] = [f"{_prefix}{branch}{node_repr(node)}"]
if children := list(_get_children(node)):
prefix_child = _prefix + (" " if _is_last else "│ ")
for idx, child in enumerate(children):
lines.append(
tree_repr(
child,
node_repr=node_repr,
_prefix=prefix_child,
_is_last=idx == len(children) - 1,
)
)
return "\n".join(lines)


def tree_dict(
node: SupportsChildren,
*,
obj_name: Callable[[Any], str] = _cls_name_with_id,
) -> Tree:
"""Build an intermediate representation of the tree rooted at `node`.

Leaves are represented as strings, and non-leaf nodes are represented as
dictionaries with the node name as the key and a list of child nodes as the value.
This is useful for debugging and visualization purposes.

Parameters
----------
node : SupportsChildren
The root node of the tree to be represented.
obj_name : Callable[[Any], str], optional
A function to convert the node to a string. Defaults to a lambda function that
returns the class name and ID

Returns
-------
str | dict[str, list[dict | str]]
A string, if the node is a leaf, or a dictionary representing the tree,
if the node has children, like `{"node_name": ["child1", "child2", ...]}`.
"""
node_name = obj_name(node)
if not (children := _get_children(node)):
return node_name

result: list[dict | str] = []
for child in children:
result.append(tree_dict(child, obj_name=obj_name))
return {obj_name(node): result}


def _get_children(obj: Any) -> Iterable[Any]:
if (children := getattr(obj, "children", None)) is None:
return ()
return _ensure_iterable(children)


def _ensure_iterable(obj: object) -> Iterable[Any]:
if isinstance(obj, Iterable):
return obj
if callable(obj):
with suppress(TypeError):
return _ensure_iterable(obj())
raise TypeError(
f"Expected an iterable or callable that returns an iterable, "
f"got {type(obj).__name__}"
)
7 changes: 4 additions & 3 deletions tests/test_basic_scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import scenex as snx
from scenex.adaptors._auto import determine_backend
from scenex.utils._tree import tree_dict, tree_repr

if TYPE_CHECKING:
from scenex.adaptors._pygfx._scene import Scene as PygfxScene
Expand Down Expand Up @@ -70,7 +71,7 @@ def test_basic_view(basic_view: snx.View) -> None:
assert isinstance(repr(basic_view), str)
assert isinstance(basic_view.model_dump(), dict)
assert isinstance(basic_view.model_dump_json(), str)
assert snx.util.tree_repr(basic_view.scene, node_repr=_obj_name) == EXPECT_REPR
assert tree_repr(basic_view.scene, node_repr=_obj_name) == EXPECT_REPR
ary = basic_view.render()
assert isinstance(ary, np.ndarray)

Expand All @@ -88,9 +89,9 @@ def test_view_tree_matches_native(basic_view: snx.View) -> None:
structure of the tree generated by the native backend."""
basic_view._get_adaptors(create=True)

model_tree = snx.util.tree_dict(basic_view.scene, obj_name=_obj_name)
model_tree = tree_dict(basic_view.scene, obj_name=_obj_name)
native_scene = _native_scene(basic_view.scene)
view_tree = snx.util.tree_dict(native_scene, obj_name=_obj_name)
view_tree = tree_dict(native_scene, obj_name=_obj_name)
assert isinstance(view_tree, dict)
assert model_tree == _filter_backend_nodes(view_tree)

Expand Down
Loading