Skip to content

Commit 26376a1

Browse files
gonzalocasasclaude
andcommitted
Add 3D shape scene objects (XY projection)
Draw COMPAS solids as their projection onto the XY plane: - ShapeObject: shared object for Sphere, Cylinder, Cone, Capsule, Torus and Polyhedron, tessellating via Shape.to_vertices_and_faces (u/v resolution) and drawing each face as a semi-transparent polygon with a solid outline. - Box keeps its dedicated BoxObject (exact six-face projection). Registers the shapes under the "Plotter" context, extends the tutorial, and adds tests covering Box and all shape types (13 tests total). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fbcfbfb commit 26376a1

5 files changed

Lines changed: 178 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
context. Every `Plotter` now owns one, giving a hierarchical scene tree
2222
(parent/child transforms), serialization, and API symmetry with the Rhino,
2323
Blender and Viewer backends.
24-
* Guarded registration hook for future 3D shape / `Brep` / surface scene objects.
24+
* Scene object for `Box`, drawn as the exact XY projection of its six faces.
25+
* Shared `ShapeObject` drawing `Sphere`, `Cylinder`, `Cone`, `Capsule`, `Torus`
26+
and `Polyhedron` as tessellated XY projections, with `u`/`v` resolution control.
2527

2628
### Changed
2729

docs/tutorial.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,29 @@ in points, so they do not grow or shrink when you zoom.
6262

6363
3D shapes are drawn as their projection onto the XY plane. A `Box`, for example,
6464
is projected face by face, so it reads as a solid wireframe regardless of how its
65-
frame is oriented.
65+
frame is oriented. The same works for `Sphere`, `Cylinder`, `Cone`, `Capsule`,
66+
`Torus` and `Polyhedron`: they are tessellated into a mesh and that mesh is
67+
projected. Curved shapes take `u`/`v` keyword arguments to control the
68+
tessellation resolution.
69+
70+
```python
71+
from compas.geometry import Sphere, Cylinder, Cone, Torus, Polyhedron
72+
from compas.geometry import Translation
73+
from compas_plotters import Plotter
74+
75+
plotter = Plotter(figsize=(10, 3))
76+
77+
plotter.add(Sphere(1.0))
78+
plotter.add(Cylinder(0.6, 2.0).transformed(Translation.from_vector([3, 0, 0])))
79+
plotter.add(Cone(0.8, 2.0).transformed(Translation.from_vector([6, 0, 0])), u=12)
80+
plotter.add(Torus(1.0, 0.3).transformed(Translation.from_vector([9, 0, 0])))
81+
plotter.add(Polyhedron.from_platonicsolid(12).transformed(Translation.from_vector([12, 0, 0])))
82+
83+
plotter.zoom_extents()
84+
plotter.show()
85+
```
86+
87+
The `Box` below is projected the same way.
6688

6789
```python
6890
from math import radians
@@ -116,8 +138,9 @@ def spin(frame):
116138
plotter.show()
117139
```
118140

119-
Each frame applies a small rotation about the Z axis; the six faces are
120-
re-projected onto the XY plane, so the wireframe appears to turn in 3D.
141+
Each frame applies a small rotation; the six faces are re-projected onto the XY
142+
plane, so the wireframe appears to turn in 3D. Any of the shapes above can be
143+
animated the same way.
121144

122145
## Meshes
123146

src/compas_plotters/scene/__init__.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,19 @@
1111
from compas.datastructures import Graph
1212
from compas.datastructures import Mesh
1313
from compas.geometry import Box
14+
from compas.geometry import Capsule
1415
from compas.geometry import Circle
16+
from compas.geometry import Cone
17+
from compas.geometry import Cylinder
1518
from compas.geometry import Ellipse
1619
from compas.geometry import Frame
1720
from compas.geometry import Line
1821
from compas.geometry import Point
1922
from compas.geometry import Polygon
23+
from compas.geometry import Polyhedron
2024
from compas.geometry import Polyline
25+
from compas.geometry import Sphere
26+
from compas.geometry import Torus
2127
from compas.geometry import Vector
2228
from compas.plugins import plugin
2329
from compas.scene import register
@@ -34,6 +40,7 @@
3440
from .pointobject import PointObject
3541
from .polygonobject import PolygonObject
3642
from .polylineobject import PolylineObject
43+
from .shapeobject import ShapeObject
3744
from .vectorobject import VectorObject
3845

3946

@@ -52,24 +59,13 @@ def register_scene_objects():
5259
register(Mesh, MeshObject, context="Plotter")
5360
register(Graph, GraphObject, context="Plotter")
5461

55-
# Roadmap: 3D shapes, Breps and surfaces are drawn via XY projection once
56-
# their scene objects are implemented. Registration is guarded so the package
57-
# keeps working until then, and lights up automatically when the objects (and
58-
# any optional dependencies, e.g. compas_occ for Breps) become available.
59-
try:
60-
from compas.geometry import Capsule
61-
from compas.geometry import Cone
62-
from compas.geometry import Cylinder
63-
from compas.geometry import Polyhedron
64-
from compas.geometry import Sphere
65-
from compas.geometry import Torus
62+
# 3D shapes are drawn as their projection onto the XY plane, tessellated by
63+
# a shared ShapeObject. Box keeps its own object (exact 6-face projection).
64+
for shape_cls in (Sphere, Cylinder, Cone, Capsule, Torus, Polyhedron):
65+
register(shape_cls, ShapeObject, context="Plotter")
6666

67-
from .shapeobject import ShapeObject
68-
69-
for shape_cls in (Sphere, Cylinder, Cone, Capsule, Torus, Polyhedron):
70-
register(shape_cls, ShapeObject, context="Plotter")
71-
except ImportError:
72-
pass
67+
# Roadmap: Breps and surfaces are drawn via XY projection once their scene
68+
# objects are implemented (Breps additionally need e.g. compas_occ).
7369

7470

7571
# Eager registration so that the Plotter works in editable / non-installed setups
@@ -89,6 +85,7 @@ def register_scene_objects():
8985
"EllipseObject",
9086
"FrameObject",
9187
"BoxObject",
88+
"ShapeObject",
9289
"MeshObject",
9390
"GraphObject",
9491
]
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
from typing import Sequence
4+
5+
from compas.colors import Color
6+
from compas.geometry import Shape
7+
from compas.scene import GeometryObject
8+
from matplotlib.patches import Polygon as PolygonPatch
9+
10+
from .plotterobject import PlotterSceneObject
11+
from .plotterobject import to_rgb
12+
13+
14+
class ShapeObject(PlotterSceneObject, GeometryObject):
15+
"""Plotter scene object for :class:`compas.geometry.Shape`.
16+
17+
Handles all COMPAS solids (Sphere, Cylinder, Cone, Capsule, Torus,
18+
Polyhedron, ...). The shape is tessellated with
19+
:meth:`compas.geometry.Shape.to_vertices_and_faces` and drawn as the
20+
projection of that mesh onto the XY plane: each face becomes a
21+
semi-transparent polygon with a solid outline, so the shape reads as a solid
22+
wireframe regardless of its orientation.
23+
24+
Parameters
25+
----------
26+
u
27+
Number of faces around curved shapes (longitude). Ignored by shapes
28+
without a resolution, such as :class:`compas.geometry.Polyhedron`.
29+
v
30+
Number of faces along curved shapes (latitude), where applicable.
31+
linewidth
32+
Width of the edges.
33+
linestyle
34+
Matplotlib line style (``"solid"``, ``"dotted"``, ``"dashed"``, ``"dashdot"``).
35+
facecolor
36+
Color of the faces.
37+
edgecolor
38+
Color of the edges.
39+
fill
40+
If True, fill the faces.
41+
alpha
42+
Transparency of the faces, between 0 and 1.
43+
"""
44+
45+
def __init__(
46+
self,
47+
u: int = 16,
48+
v: int = 16,
49+
linewidth: float = 0.5,
50+
linestyle: str = "solid",
51+
facecolor: Color | Sequence[float] = (0.9, 0.9, 1.0),
52+
edgecolor: Color | Sequence[float] = (0.0, 0.0, 0.0),
53+
fill: bool = True,
54+
alpha: float = 0.2,
55+
**kwargs,
56+
) -> None:
57+
super().__init__(zorder=1000, **kwargs)
58+
self.u = u
59+
self.v = v
60+
self.linewidth = linewidth
61+
self.linestyle = linestyle
62+
self.facecolor = facecolor
63+
self.edgecolor = edgecolor
64+
self.fill = fill
65+
self.alpha = alpha
66+
67+
@property
68+
def shape(self) -> Shape:
69+
return self.geometry # type: ignore[return-value]
70+
71+
def _vertices_and_faces(self) -> tuple[list, list]:
72+
try:
73+
return self.shape.to_vertices_and_faces(u=self.u, v=self.v)
74+
except TypeError:
75+
# Shapes without a resolution (e.g. Polyhedron) take no u/v.
76+
return self.shape.to_vertices_and_faces()
77+
78+
def viewdata(self) -> list[list[float]]:
79+
vertices, _ = self._vertices_and_faces()
80+
return [list(vertex[:2]) for vertex in vertices]
81+
82+
def draw(self) -> list:
83+
vertices, faces = self._vertices_and_faces()
84+
# Bake the transparency into the face color only, so the edges stay solid
85+
# even where projected faces overlap.
86+
facecolor = (*to_rgb(self.facecolor), self.alpha) if self.fill else "none"
87+
self._mpl_objects = []
88+
for face in faces:
89+
polygon = PolygonPatch(
90+
[list(vertices[index][:2]) for index in face],
91+
linewidth=self.linewidth,
92+
linestyle=self.linestyle,
93+
facecolor=facecolor,
94+
edgecolor=to_rgb(self.edgecolor),
95+
fill=self.fill,
96+
zorder=self.zorder,
97+
)
98+
self._mpl_objects.append(self.axes.add_patch(polygon))
99+
return self._mpl_objects

tests/test_plotter.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,24 @@
44

55
from compas.datastructures import Graph
66
from compas.datastructures import Mesh
7+
from compas.geometry import Box
8+
from compas.geometry import Capsule
79
from compas.geometry import Circle
10+
from compas.geometry import Cone
11+
from compas.geometry import Cylinder
812
from compas.geometry import Ellipse
913
from compas.geometry import Frame
1014
from compas.geometry import Line
1115
from compas.geometry import Point
1216
from compas.geometry import Polygon
17+
from compas.geometry import Polyhedron
1318
from compas.geometry import Polyline
19+
from compas.geometry import Sphere
20+
from compas.geometry import Torus
1421
from compas.geometry import Vector
1522

1623
from compas_plotters import Plotter
24+
from compas_plotters.scene import BoxObject
1725
from compas_plotters.scene import CircleObject
1826
from compas_plotters.scene import EllipseObject
1927
from compas_plotters.scene import FrameObject
@@ -23,6 +31,7 @@
2331
from compas_plotters.scene import PointObject
2432
from compas_plotters.scene import PolygonObject
2533
from compas_plotters.scene import PolylineObject
34+
from compas_plotters.scene import ShapeObject
2635
from compas_plotters.scene import VectorObject
2736

2837

@@ -50,6 +59,33 @@ def test_add_all_geometry_types():
5059
assert len(plotter.sceneobjects) >= len(pairs)
5160

5261

62+
def test_add_box_returns_boxobject():
63+
plotter = Plotter()
64+
obj = plotter.add(Box(1, 2, 3))
65+
assert isinstance(obj, BoxObject)
66+
# one patch per face
67+
assert len(obj._mpl_objects) == 6
68+
assert len(obj.viewdata()) == 8
69+
70+
71+
def test_add_all_shape_types():
72+
plotter = Plotter()
73+
shapes = [
74+
Sphere(1.0),
75+
Cylinder(0.5, 2.0),
76+
Cone(0.5, 2.0),
77+
Capsule(0.3, 1.5),
78+
Torus(1.0, 0.3),
79+
Polyhedron.from_platonicsolid(12),
80+
]
81+
for shape in shapes:
82+
obj = plotter.add(shape)
83+
assert isinstance(obj, ShapeObject)
84+
# every tessellated face is drawn
85+
_, faces = obj._vertices_and_faces()
86+
assert len(obj._mpl_objects) == len(faces) > 0
87+
88+
5389
def test_add_mesh_and_graph():
5490
plotter = Plotter()
5591
mesh = Mesh.from_vertices_and_faces(

0 commit comments

Comments
 (0)