Skip to content

Commit c192535

Browse files
gonzalocasasclaude
andcommitted
Integrate compas.scene.Scene via PlotterScene
Follow the compas_viewer ViewerScene pattern: Plotter now owns a PlotterScene(compas.scene.Scene) bound to the "Plotter" context, instead of managing its own object list. This gives a hierarchical scene tree (parent/child with composed world transformations), serialization, and API symmetry with the Rhino/Blender/Viewer backends. - add/find/zoom_extents/redraw/sceneobjects delegate to the scene tree. - PlotterScene.add injects the owning plotter and draws the object; remove clears its matplotlib artists. - SceneObject.draw() now returns the created artists (SceneObject contract), so Scene.draw() works; drawing is still managed by the Plotter (like the viewer). - Guarded registration hook for future 3D shape / Brep scene objects. - Docs: tutorial "Scenes and hierarchy" section; 4 new tests (11 total). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 74e0971 commit c192535

18 files changed

Lines changed: 228 additions & 31 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,6 @@ ENV/
5050
# COMPAS / temp
5151
temp/
5252
*.gif
53+
54+
# uv
55+
uv.lock

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
`Ellipse` and `Frame`.
1818
* Scene objects for the `Mesh` and `Graph` data structures, with vertex/edge/face
1919
and node/edge labels.
20+
* `PlotterScene`, a `compas.scene.Scene` subclass bound to the `"Plotter"`
21+
context. Every `Plotter` now owns one, giving a hierarchical scene tree
22+
(parent/child transforms), serialization, and API symmetry with the Rhino,
23+
Blender and Viewer backends.
24+
* Guarded registration hook for future 3D shape / `Brep` / surface scene objects.
2025

2126
### Changed
2227

docs/tutorial.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,36 @@ plotter.zoom_extents()
105105
plotter.show()
106106
```
107107

108+
## Scenes and hierarchy
109+
110+
Every `Plotter` owns a [`PlotterScene`][compas_plotters.scene.PlotterScene], a
111+
subclass of [`compas.scene.Scene`](https://compas.dev/compas/latest/) bound to
112+
the `"Plotter"` context. `Plotter.add` delegates to it, so you get the full
113+
COMPAS scene tree: parent/child relationships, composed world transformations,
114+
and serialization — the same model used by the Rhino, Blender and Viewer
115+
backends.
116+
117+
```python
118+
from compas.geometry import Point, Translation
119+
from compas_plotters import Plotter
120+
121+
plotter = Plotter()
122+
123+
parent = plotter.add(Point(0, 0, 0))
124+
child = plotter.add(Point(1, 0, 0), parent=parent)
125+
126+
# Transforming the parent moves the child with it.
127+
parent.transformation = Translation.from_vector([5, 0, 0])
128+
assert child.worldtransformation[0, 3] == 5.0
129+
130+
plotter.scene # -> the underlying PlotterScene
131+
plotter.sceneobjects # -> the plotter scene objects in the tree
132+
```
133+
134+
Drawing is managed by the `Plotter` (objects are drawn when added, and
135+
`Plotter.redraw` refreshes them), so you normally do not call `Scene.draw`
136+
yourself.
137+
108138
## Saving
109139

110140
```python

src/compas_plotters/plotter.py

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@
99
import matplotlib.pyplot as plt
1010
from compas.geometry import allclose
1111

12+
from compas_plotters.scene.plotterobject import PlotterSceneObject
13+
from compas_plotters.scene.plotterscene import PlotterScene
14+
1215
if TYPE_CHECKING:
1316
from compas.data import Data
1417
from matplotlib.axes import Axes
1518
from matplotlib.figure import Figure
1619

17-
from compas_plotters.scene.plotterobject import PlotterSceneObject
18-
1920
Viewbox = tuple[tuple[float, float], tuple[float, float]]
2021

2122

@@ -70,7 +71,7 @@ def __init__(
7071
self._show_axes = show_axes
7172
self._viewbox: Viewbox | None = None
7273
self._axes: Axes | None = None
73-
self._sceneobjects: list[PlotterSceneObject] = []
74+
self.scene = PlotterScene(plotter=self)
7475
self.viewbox = view
7576
self.figsize = figsize
7677
self.dpi = dpi
@@ -125,7 +126,12 @@ def figure(self) -> Figure:
125126
@property
126127
def sceneobjects(self) -> list[PlotterSceneObject]:
127128
"""The scene objects currently included in the plot."""
128-
return self._sceneobjects
129+
return [obj for obj in self.scene.objects if isinstance(obj, PlotterSceneObject)]
130+
131+
@property
132+
def is_live(self) -> bool:
133+
"""Whether the matplotlib figure has been created yet."""
134+
return self._axes is not None
129135

130136
@property
131137
def title(self) -> str:
@@ -163,7 +169,7 @@ def zoom_extents(self, padding: float | None = None) -> None:
163169
fig_aspect = width / height
164170

165171
data: list[list[float]] = []
166-
for obj in self._sceneobjects:
172+
for obj in self.sceneobjects:
167173
data += obj.viewdata()
168174
if not data:
169175
return
@@ -226,17 +232,7 @@ def add(self, item: Data, **kwargs) -> PlotterSceneObject:
226232
-------
227233
The scene object created for the item.
228234
"""
229-
# Imported lazily to avoid a circular import at module load time.
230-
from compas.scene import SceneObject
231-
232-
if self.zstack == "natural":
233-
zorder = 1000 + len(self._sceneobjects) * 100
234-
kwargs.setdefault("zorder", zorder)
235-
236-
sceneobject: PlotterSceneObject = SceneObject(item=item, context="Plotter", plotter=self, **kwargs) # type: ignore[assignment]
237-
sceneobject.draw()
238-
self._sceneobjects.append(sceneobject)
239-
return sceneobject
235+
return self.scene.add(item, **kwargs) # type: ignore[return-value]
240236

241237
def add_from_list(self, items: Iterable[Data], **kwargs) -> list[PlotterSceneObject]:
242238
"""Add multiple COMPAS objects, all with the same options.
@@ -266,7 +262,7 @@ def find(self, item: Data) -> PlotterSceneObject | None:
266262
-------
267263
The matching scene object, or None if the item is not in the plot.
268264
"""
269-
for obj in self._sceneobjects:
265+
for obj in self.sceneobjects:
270266
if item is obj.item:
271267
return obj
272268
return None
@@ -302,7 +298,7 @@ def redraw(self, pause: float | None = None) -> None:
302298
pause
303299
If provided, pause for this many seconds after redrawing.
304300
"""
305-
for obj in self._sceneobjects:
301+
for obj in self.sceneobjects:
306302
obj.redraw()
307303
self.figure.canvas.draw()
308304
self.figure.canvas.flush_events()

src/compas_plotters/scene/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from .lineobject import LineObject
2929
from .meshobject import MeshObject
3030
from .plotterobject import PlotterSceneObject
31+
from .plotterscene import PlotterScene
3132
from .pointobject import PointObject
3233
from .polygonobject import PolygonObject
3334
from .polylineobject import PolylineObject
@@ -48,13 +49,34 @@ def register_scene_objects():
4849
register(Mesh, MeshObject, context="Plotter")
4950
register(Graph, GraphObject, context="Plotter")
5051

52+
# Roadmap: 3D shapes, Breps and surfaces are drawn via XY projection once
53+
# their scene objects are implemented. Registration is guarded so the package
54+
# keeps working until then, and lights up automatically when the objects (and
55+
# any optional dependencies, e.g. compas_occ for Breps) become available.
56+
try:
57+
from compas.geometry import Box
58+
from compas.geometry import Capsule
59+
from compas.geometry import Cone
60+
from compas.geometry import Cylinder
61+
from compas.geometry import Polyhedron
62+
from compas.geometry import Sphere
63+
from compas.geometry import Torus
64+
65+
from .shapeobject import ShapeObject
66+
67+
for shape_cls in (Box, Sphere, Cylinder, Cone, Capsule, Torus, Polyhedron):
68+
register(shape_cls, ShapeObject, context="Plotter")
69+
except ImportError:
70+
pass
71+
5172

5273
# Eager registration so that the Plotter works in editable / non-installed setups
5374
# (where the entry-point-based plugin discovery does not run).
5475
register_scene_objects()
5576

5677

5778
__all__ = [
79+
"PlotterScene",
5880
"PlotterSceneObject",
5981
"PointObject",
6082
"VectorObject",

src/compas_plotters/scene/circleobject.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def viewdata(self) -> list[list[float]]:
6060
r = self.circle.radius
6161
return [[cx - r, cy], [cx + r, cy], [cx, cy - r], [cx, cy + r]]
6262

63-
def draw(self) -> None:
63+
def draw(self) -> list:
6464
circle = CirclePatch(
6565
self.circle.center[:2],
6666
radius=self.circle.radius,
@@ -73,3 +73,4 @@ def draw(self) -> None:
7373
zorder=self.zorder,
7474
)
7575
self._mpl_objects = [self.axes.add_artist(circle)]
76+
return self._mpl_objects

src/compas_plotters/scene/ellipseobject.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def viewdata(self) -> list[list[float]]:
6161
b = self.ellipse.minor
6262
return [[cx - a, cy], [cx + a, cy], [cx, cy - b], [cx, cy + b]]
6363

64-
def draw(self) -> None:
64+
def draw(self) -> list:
6565
ellipse = EllipsePatch(
6666
self.ellipse.center[:2],
6767
width=2 * self.ellipse.major,
@@ -75,3 +75,4 @@ def draw(self) -> None:
7575
zorder=self.zorder,
7676
)
7777
self._mpl_objects = [self.axes.add_artist(ellipse)]
78+
return self._mpl_objects

src/compas_plotters/scene/frameobject.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,12 @@ def _arrow(self, end, color) -> FancyArrowPatch:
5858
mutation_scale=100,
5959
)
6060

61-
def draw(self) -> None:
61+
def draw(self) -> list:
6262
origin = self.frame.point
6363
ex = origin + self.frame.xaxis.scaled(self.size)
6464
ey = origin + self.frame.yaxis.scaled(self.size)
6565
self._mpl_objects = [
6666
self.axes.add_patch(self._arrow(ex, self.xcolor)),
6767
self.axes.add_patch(self._arrow(ey, self.ycolor)),
6868
]
69+
return self._mpl_objects

src/compas_plotters/scene/graphobject.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,14 @@ def _node_radius(self) -> float:
6868
def viewdata(self) -> list[list[float]]:
6969
return [xyz[:2] for xyz in self.node_xyz.values()]
7070

71-
def draw(self) -> None:
71+
def draw(self) -> list:
7272
node_xyz = self.node_xyz
7373
if self.show_edges:
7474
self._draw_edges(node_xyz)
7575
if self.show_nodes:
7676
self._draw_nodes(node_xyz)
7777
self._draw_labels(node_xyz)
78+
return self._mpl_objects
7879

7980
def _draw_nodes(self, node_xyz: dict) -> None:
8081
radius = self._node_radius()

src/compas_plotters/scene/lineobject.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,16 @@ def clip(self) -> list | None:
7171
box = [[xmin, ymin], [xmax, ymin], [xmax, ymax], [xmin, ymax]]
7272
return intersection_line_box_xy(self.line, box)
7373

74-
def draw(self) -> None:
74+
def draw(self) -> list:
7575
color = to_rgb(self.color)
7676
if self.draw_as_segment:
7777
x0, y0 = self.line.start[:2]
7878
x1, y1 = self.line.end[:2]
7979
else:
8080
points = self.clip()
8181
if not points:
82-
return
82+
self._mpl_objects = []
83+
return self._mpl_objects
8384
(x0, y0), (x1, y1) = points[0][:2], points[1][:2]
8485

8586
line2d = Line2D(
@@ -96,3 +97,4 @@ def draw(self) -> None:
9697
self.plotter.add(self.line.start, edgecolor=color),
9798
self.plotter.add(self.line.end, edgecolor=color),
9899
]
100+
return self._mpl_objects

0 commit comments

Comments
 (0)