diff --git a/.gitignore b/.gitignore
index 696ca26..7eefaa9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -159,6 +159,7 @@ devel_isolated/
# Ignore generated docs
*.dox
*.wikidoc
+docs/source/generated/
# eclipse stuff
.project
diff --git a/docs/source/_static/css/custom.css b/docs/source/_static/css/custom.css
index 8e4e7a7..9c2d836 100644
--- a/docs/source/_static/css/custom.css
+++ b/docs/source/_static/css/custom.css
@@ -13,3 +13,24 @@
overflow: visible !important;
}
}
+
+
+/* Copyright (left) + GitHub link (right) on one row, wrapping gracefully
+ on narrow viewports instead of overflowing. A slightly smaller font
+ than body text keeps both on one line at normal widths. */
+footer div[role="contentinfo"] {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: baseline;
+ font-size: 0.8em;
+}
+
+footer div[role="contentinfo"] p {
+ margin-bottom: 0;
+}
+
+.footer-github-link {
+ white-space: nowrap;
+}
+
diff --git a/docs/source/_templates/autosummary/class.rst b/docs/source/_templates/autosummary/class.rst
new file mode 100644
index 0000000..f09f72d
--- /dev/null
+++ b/docs/source/_templates/autosummary/class.rst
@@ -0,0 +1,8 @@
+{{ fullname | escape | underline}}
+
+.. currentmodule:: {{ module }}
+
+.. autoclass:: {{ objname }}
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/_templates/footer.html b/docs/source/_templates/footer.html
new file mode 100644
index 0000000..52fcbc7
--- /dev/null
+++ b/docs/source/_templates/footer.html
@@ -0,0 +1,5 @@
+{% extends "!footer.html" %}
+{% block contentinfo %}
+ {{ super() }}
+
+{% endblock %}
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 9d54089..43ca4a5 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -4,48 +4,174 @@ API Reference
.. currentmodule:: spatialgeometry
+Class summary
+=============
+
+Spatial geometry classes for 3D shapes and scene graph management.
+
.. autosummary::
+ :toctree: generated
+
+ SceneNode
+ SceneGroup
+ Shape
+ CollisionShape
+ CollisionShapeGroup
+
+The class hierarchy for all Spatial Geometry classes is shown below:
+
+.. inheritance-diagram::
+ spatialgeometry.Shape
+ spatialgeometry.Axes
+ spatialgeometry.Arrow
+ spatialgeometry.Path
+ spatialgeometry.CollisionShape
+ spatialgeometry.CollisionShapeGroup
+ spatialgeometry.Mesh
+ spatialgeometry.Cylinder
+ spatialgeometry.Cuboid
+ spatialgeometry.Sphere
+ spatialgeometry.Ellipsoid
+ spatialgeometry.Box
+ spatialgeometry.SceneNode
+ spatialgeometry.SceneGroup
+ :parts: 1
+ :top-classes: spatialgeometry.SceneNode, collections.UserList
+
+
+
+Collision shapes
+================
+
+These are the basic 3D geometric shapes that can be rendered into a scene, and can also
+be used for collision detection.
+
+.. autosummary::
+
+ Cuboid
+ Sphere
+ Ellipsoid
+ Cylinder
+ Mesh
+ Box
+
+These shapes all inherit from:
+
+* the :class:`CollisionShape` base class which means they can be used for collision detection, and
+* the :class:`SceneNode` base class which means they can be nodes in a scene graph to allow visualization and
+ animation of complex scenes.
+
+Collision shapes also support the collision operator ``&`` which returns True if the two shapes are colliding, and False otherwise. For example:
+
+.. runblock:: pycon
+
+ from spatialgeometry import Cuboid, Sphere
+ from spatialmath import SE3
+
+ c = Cuboid(scale=[1, 2, 3])
+ s1 = Sphere(1, pose=SE3(4, 0, 0))
+ s2 = Sphere(1, pose=SE3(0, 0, 0))
+
+ c & s1
+ c & s2
+
+
+.. autoclass:: Cuboid
+ :members:
+ :undoc-members:
+ :show-inheritance:
+ :inherited-members:
+ :exclude-members: collided
+
+.. autoclass:: Sphere
+ :members:
+ :undoc-members:
+ :show-inheritance:
+ :inherited-members:
+ :exclude-members: collided
+
+.. autoclass:: Cylinder
+ :members:
+ :undoc-members:
+ :show-inheritance:
+ :inherited-members:
+ :exclude-members: collided
+
+.. autoclass:: Ellipsoid
+ :members:
+ :undoc-members:
+ :show-inheritance:
+ :inherited-members:
+ :exclude-members: collided
+
+.. autoclass:: Box
+ :members:
+ :undoc-members:
+ :show-inheritance:
+ :inherited-members:
+ :exclude-members: collided
+
+.. autoclass:: Mesh
+ :members:
+ :undoc-members:
+ :show-inheritance:
+ :inherited-members:
+ :exclude-members: collided
- Shape
- Axes
- Arrow
- CollisionShape
- Mesh
- Cylinder
- Cuboid
- Sphere
- Box
- SceneNode
- SceneGroup
Shapes
======
-.. automodule:: spatialgeometry.geom.Shape
+These are the basic 3D geometric shapes that can be rendered into a scene, but they cannot
+be used for collision detection.
+
+.. autosummary::
+
+ Axes
+ Arrow
+ Path
+
+They all inherit directly from the :class:`Shape` base class.
+
+.. autoclass:: Axes
:members:
:undoc-members:
:show-inheritance:
+ :inherited-members:
+.. autoclass:: Arrow
+ :members:
+ :undoc-members:
+ :show-inheritance:
+ :inherited-members:
-Collision shapes
-=================
-
-.. automodule:: spatialgeometry.geom.CollisionShape
+.. autoclass:: Path
:members:
:undoc-members:
:show-inheritance:
+ :inherited-members:
+
+
+Scene Graphs
+============
+.. autoclass:: SceneNode
+ :members:
+ :undoc-members:
+ :show-inheritance:
+ :inherited-members:
-Scene graph
-===========
-.. automodule:: spatialgeometry.geom.SceneNode
+.. autoclass:: SceneGroup
:members:
:undoc-members:
:show-inheritance:
+ :inherited-members:
-.. automodule:: spatialgeometry.geom.SceneGroup
+.. autoclass:: CollisionShapeGroup
:members:
:undoc-members:
:show-inheritance:
+ :inherited-members:
+ :exclude-members: collided
\ No newline at end of file
diff --git a/docs/source/conf.py b/docs/source/conf.py
index a165c76..277df53 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -20,7 +20,7 @@
# -- Project information -----------------------------------------------------
project = 'Spatial Geometry'
-copyright = '2020, Jesse Haviland and Peter Corke'
+copyright = '2020-present, Jesse Haviland and Peter Corke'
author = 'Jesse Haviland and Peter Corke'
# Parse version number out of pyproject.toml
@@ -62,13 +62,98 @@
mermaid_height = "auto"
autosummary_generate = True
-autodoc_member_order = 'bysource'
+
+# Merge each class's own docstring with its (MRO-resolved) __init__
+# docstring on autoclass:: pages -- most shape __init__s have no docstring
+# of their own and inherit Shape.__init__'s pose/color/stype/base docs,
+# which otherwise never surface (Sphinx's default 'class' setting shows
+# only the class docstring, never __init__'s).
+autoclass_content = 'both'
+
+# Alphabetical (Sphinx's own default) rather than 'bysource' -- this is a
+# reference page meant for looking up a member you already know the name
+# of, not a narrative to read top-to-bottom in definition order.
+autodoc_member_order = 'alphabetical'
+
+# Sphinx's own 'alphabetical' sort is plain case-sensitive string
+# comparison, so e.g. "T" (a property) sorts before "attach" rather than
+# alongside the rest of the a's. No config option controls this.
+#
+# FRAGILE: the older, semi-public sphinx.ext.autodoc.Documenter.sort_members
+# method still exists but is dead code for this build -- as of Sphinx
+# 9.1.0 the real sort lives in a private, version-specific internal
+# (sphinx.ext.autodoc._dynamic._member_finder._sort_members, called as a
+# plain same-module function, not a method). Found by grepping the
+# installed package for '.sort(' after patching the documented method had
+# no effect. If a Sphinx upgrade moves this again, this patch silently
+# stops taking effect (falls back to Sphinx's own case-sensitive order)
+# rather than erroring -- if member order looks wrong again after
+# upgrading Sphinx, this is the first place to check.
+import sphinx.ext.autodoc._dynamic._member_finder as _member_finder
+
+_orig_sort_members = _member_finder._sort_members
+
+
+def _sort_members_case_insensitive(documenters, order, **kwargs):
+ if order == 'alphabetical':
+ documenters.sort(key=lambda entry: entry[0].full_name.lower())
+ return documenters
+ return _orig_sort_members(documenters, order, **kwargs)
+
+
+_member_finder._sort_members = _sort_members_case_insensitive
+
+# Show "Shape" rather than "spatialgeometry.geom.Shape.Shape" in class
+# headers, signatures and cross-references.
+add_module_names = False
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
exclude_patterns = ['test_*']
+
+# Every autoclass:: directive in api.rst uses :inherited-members:, so a
+# subclass page (e.g. Cuboid) lists CollisionShape's and Shape's members
+# indistinguishably from its own -- :show-inheritance: only adds a single
+# "Bases: X" line at the top of the page, it doesn't label individual
+# members. This hook appends an "Inherited from" note to each member's
+# docstring when it isn't actually defined on the class whose page it's
+# being rendered on.
+#
+# Relies on __qualname__ being set at the point of original definition and
+# never rewritten by inheritance (true for plain methods and for a
+# property's fget/fset individually) -- NOT reliable for a property that
+# overrides only its setter while reusing the base class's getter (e.g.
+# Mesh.color): fget.__qualname__ still points at the base class, so a
+# genuinely-overridden setter goes unlabelled. No case like that needs the
+# label anyway (the point is finding where unfamiliar members come from,
+# not ones a class visibly redefines), so this is left unhandled.
+def _label_inherited_members(app, what, name, obj, options, lines):
+ if what not in ("method", "attribute", "property"):
+ return
+
+ parts = name.rsplit(".", 2)
+ if len(parts) != 3:
+ return
+ _, cls_name, _ = parts
+
+ target = obj.fget if isinstance(obj, property) else obj
+ qualname = getattr(target, "__qualname__", None)
+ if not qualname or "." not in qualname:
+ return
+
+ defining_cls_name = qualname.rsplit(".", 1)[0]
+ if defining_cls_name == cls_name or "." in defining_cls_name:
+ return
+
+ lines.append("")
+ lines.append(f"*Inherited from* :class:`~spatialgeometry.{defining_cls_name}`.")
+
+
+def setup(app):
+ app.connect("autodoc-process-docstring", _label_inherited_members)
+
# options for spinx_pyrunblock, used for inline examples
# Python session setup, turn off color printing for SE3, set NumPy precision
autorun_languages = {}
diff --git a/docs/source/fileformats.rst b/docs/source/fileformats.rst
new file mode 100644
index 0000000..e1b5c57
--- /dev/null
+++ b/docs/source/fileformats.rst
@@ -0,0 +1,249 @@
+*****************
+Mesh File Formats
+*****************
+
+A :class:`~spatialgeometry.Mesh` shape's ``filename`` is read by two
+independent consumers, and each only understands a limited set of all possible mesh file
+formats:
+
+* **The browser**, via `Swift `_. Swift is
+ built on `three.js `__, and picks a loader based on
+ the file's extension to display the mesh.
+* **trimesh**, used two separate ways:
+
+ - Feeding `Coal `_ for distance/collision
+ queries -- lazily, the first time a collision-enabled shape (``collision=True``)
+ is actually used in a ``closest_point()`` or ``iscollided()`` call. Coal itself
+ doesn't read files at all, it just consumes the vertices and triangles that
+ trimesh hands it.
+ - Computing a mesh's local bounding box (``corners()``/``bounds()``/``extents()``)
+ directly from trimesh's own ``mesh.bounds`` -- independent of ``collision``,
+ and never cached, so each call reloads the file.
+
+If you want a mesh to both **display in Swift** and **participate in
+collision checking**, the file needs to be loadable by both. If you only
+need one or the other, the constraint relaxes: use anything three.js
+supports for display-only meshes (``collision=False``), or anything
+trimesh supports for collision-only meshes never rendered on screen.
+
+`Paul Bourke's Data Formats page `_ is a great
+reference for the history and capabilities of many of the formats discussed here.
+
+
+The two sets, and their intersection
+=====================================
+
+Swift's loaders cover ``.dae``, ``.stl``, ``.obj`` (+ matching ``.mtl``),
+``.gltf``/``.glb``, ``.ply``, ``.wrl`` (VRML), and ``.pcd`` (point clouds).
+
+`trimesh `__ (pulled in by SpatialGeometry's ``collision`` extra) covers ``.dae``, ``.stl``,
+``.obj``, ``.gltf``/``.glb``, ``.ply``, ``.off``, ``.xyz``, ``.zae``, and a
+few CAD-interchange formats (``.3mf``, ``.step``/``.stp``) if the relevant
+optional dependencies are present.
+
+trimesh itself has further `install extras `__
+(``pip install trimesh[easy]``, ``trimesh[recommend]``, ...) that unlock those
+"if the relevant optional dependencies are present" formats -- but installing
+them doesn't widen the overlap below, because none of the formats they add are
+formats Swift's three.js loaders understand in the first place:
+
+* ``trimesh[easy]`` adds ``lxml`` (needed to actually parse ``.3mf`` and
+ ``.xaml``) and ``pycollada`` (needed to parse ``.dae``). SpatialGeometry's own
+ ``collision`` extra already lists ``pycollada`` directly, so COLLADA works
+ here without reaching for trimesh's ``easy`` extra.
+* ``trimesh[recommend]`` adds ``cascadio`` (needed to parse ``.step``/``.stp``).
+* ``trimesh[deprecated]`` adds ``openctm`` (needed to parse ``.ctm``, a
+ legacy format trimesh itself is phasing out).
+
+In short: trimesh's extras are only worth installing here if you need
+collision geometry from a ``.3mf``/``.step``/``.stp``/``.xaml``/``.ctm`` file
+and don't care about displaying it in Swift (which can't load any of those
+anyway) -- for the display-and-collision workflow this page is about, they're
+not needed.
+
+The overlap -- the formats that work for both display **and**
+collision -- is:
+
+* **STL** (``.stl``)
+* **OBJ** (``.obj``)
+* **PLY** (``.ply``)
+* **COLLADA** (``.dae``)
+* **glTF / GLB** (``.gltf`` / ``.glb``)
+
+These five are what the rest of this page focuses on. If you're not sure
+where to start, jump straight to the :ref:`summary table `.
+
+
+The five formats
+=================
+
+STL
+---
+
+STL ("stereolithography") is the oldest format here by some margin --
+3D Systems introduced it in 1987 for early 3D-printing hardware, and it has
+barely changed since. It stores nothing but a flat, unindexed list of
+triangles (each with its own three vertices and a normal) -- no materials,
+no hierarchy, no scene structure. That simplicity is exactly why it's still
+everywhere: it's the default mesh export from essentially every CAD package
+(SolidWorks, Fusion 360, OnShape, ...), which makes it the most common
+format you'll encounter for robot link and end-effector geometry exported
+from CAD, and it shows up constantly as the ``visual``/``collision`` mesh
+reference in URDF files. It comes in a compact binary form and a verbose
+ASCII form. Color has no place in the original spec; some tools (`the
+"Magics" convention
+`_)
+squeeze an RGB triplet into unused bytes of binary STL, which both trimesh
+and Swift's loader recognize, but it's a convention, not a standard, and
+plenty of files omit it. STL isn't going away -- it's too deeply embedded
+in CAD/3D-printing tooling -- but it isn't gaining new capabilities either.
+
+OBJ
+---
+
+OBJ dates to the late 1980s/early 1990s, from Wavefront Technologies'
+Advanced Visualizer -- one of the earliest widely-shared 3D interchange
+formats, and still one of the simplest to read or write by hand. Geometry
+lives in a plain-text ``.obj`` file; materials (including texture image
+references) live in a companion ``.mtl`` file that the ``.obj`` points to,
+so an OBJ mesh is really a small bundle of files, not one. A common,
+widely-supported (though never formally standardized) extension appends an
+RGB triplet directly to each vertex line, and both trimesh and Swift's
+``OBJLoader`` understand it. OBJ's ubiquity means it remains a safe,
+boring, universally-supported choice for a single static textured mesh --
+neither growing nor shrinking, it's the dependable middle ground.
+
+PLY
+---
+
+PLY (the "Stanford Polygon" format) was created in 1994 at Stanford for 3D
+scanning research -- it's the format behind the famous Stanford Bunny
+dataset. Unlike STL and OBJ, per-vertex and per-face color is a first-class
+part of the spec, not a bolt-on convention, which made PLY the natural
+choice for scanned or photogrammetry data where color comes from the scan
+itself rather than a painted material. It supports both compact binary and
+readable ASCII encodings. Texture-mapping support was added later and is
+less universally implemented than vertex color. PLY remains the format of
+choice whenever per-vertex color matters more than materials/textures --
+point clouds, scan output, mesh-processing research -- and continues to see
+steady use in that niche.
+
+COLLADA (.dae)
+--------------
+
+COLLADA ("COLLAborative Design Activity") is an XML-based scene-interchange
+format, originally developed by Sony for game production in 2004 and later
+adopted by the Khronos Group. It's considerably richer than STL/OBJ/PLY --
+full scene graphs, named materials with texture references, skinning and
+animation -- which also makes it more verbose and slower to parse. It's the
+other format (alongside STL) you'll most often meet in robotics: many ROS
+robot description packages ship their visual meshes as ``.dae`` precisely
+because it carries per-link color/material information that STL can't.
+COLLADA's influence peaked in the mid-2000s to mid-2010s (SketchUp, early
+Blender pipelines); Khronos itself now positions `glTF `_
+as COLLADA's successor for new work, and COLLADA is best understood today as
+a well-supported legacy format rather than one to reach for by choice.
+
+External textures
+^^^^^^^^^^^^^^^^^^
+
+A ``.dae`` file is plain XML text -- it doesn't embed image data inline. Its
+```` block just points at texture files by relative path
+(e.g. ``textures/diffuse.jpg``), so a textured COLLADA model is really a
+small *bundle*: the ``.dae`` plus one or more JPEG/PNG files sitting
+alongside it, the same idea as OBJ's separate ``.mtl`` (see below) except the
+material *definitions* live inline in the XML and only the *images* are
+external.
+
+Treat the ``.dae`` and its texture files as one unit -- never move or rename
+one without the other. The relative paths are resolved against wherever the
+``.dae`` itself was loaded from. For collision, trimesh's COLLADA loader
+defaults to ``ignore_broken=True``, so a missing or unreachable texture
+image doesn't stop it extracting the geometry Coal needs -- textures are
+irrelevant to collision anyway.
+
+Other formats have a version of this same "bundle" problem: OBJ's geometry
+(``.obj``) and materials (``.mtl``, itself referencing texture images) are
+always two separate files, never optional. glTF has it too, but only in the
+plain-text ``.gltf`` form, which can reference a separate ``.bin`` geometry
+buffer and separate image files -- ``.glb`` packs geometry, buffers, and
+images into one file, sidestepping the issue entirely, and is generally
+preferable for that reason when you control the export. STL and PLY are
+normally single, self-contained files with no companion assets, so this
+doesn't apply to them.
+
+How a renderer handles a missing companion file (texture, ``.mtl``, ``.bin``)
+is a renderer-specific concern, not something SpatialGeometry itself is
+involved in -- see `Swift's documentation `_
+for that.
+
+glTF / GLB
+----------
+
+glTF ("GL Transmission Format") is Khronos's 2017 answer to "what should
+COLLADA have been" -- often described as the JPEG of 3D. It was designed
+from the outset for fast runtime loading: its layout maps almost directly
+onto GPU vertex/index buffers, so there's very little parsing work between
+"bytes on disk" and "triangles on screen". It supports PBR materials,
+textures, per-vertex color, skinning, and animation. It comes in two forms:
+plain-text ``.gltf`` (JSON, typically referencing separate ``.bin`` geometry
+and image files -- multiple files again, like OBJ) and single-file binary
+``.glb``, which packs geometry, materials, and textures into one file. glTF
+is the actively-growing format of this group -- it's three.js's own
+preferred format, Blender's default export target, and the format most new
+web/AR/VR/game tooling is designed around. If you're generating meshes
+fresh rather than reusing existing CAD/URDF assets, glTF/GLB is generally
+the best default.
+
+
+.. _fileformats-table:
+
+Summary table
+==============
+
+.. list-table::
+ :header-rows: 1
+ :widths: 18 14 16 14 14 14
+
+ * - Format
+ - Vertex color
+ - Materials / face color
+ - Texture (UV)
+ - Single file
+ - Binary form
+ * - `STL `__
+ - ~ (non-standard)
+ - ✗
+ - ✗
+ - ✓
+ - ✓ (or ASCII)
+ * - `OBJ `__
+ - ~ (non-standard)
+ - ✓ (via ``.mtl``)
+ - ✓ (via ``.mtl``)
+ - ✗ (+ ``.mtl`` + images)
+ - ✗ (text only)
+ * - `PLY `__
+ - ✓
+ - ✓
+ - ~ (common, not core)
+ - ✓ (usually)
+ - ✓ (or ASCII)
+ * - `COLLADA `__
+ - ✓
+ - ✓
+ - ✓ (external images)
+ - ✗ (+ image files)
+ - ✗ (XML text)
+ * - `glTF/GLB `__
+ - ✓
+ - ✓ (PBR)
+ - ✓
+ - ✓ (``.glb``) / ✗ (``.gltf``)
+ - ✓ (``.glb``)
+
+**Rule of thumb:** reusing a CAD/URDF export -- use whatever it already is
+(almost always STL or COLLADA, both fully supported). Generating a mesh
+fresh, or need color/texture with a single self-contained file -- use GLB.
+Working with scanned/point-cloud data where per-vertex color matters most --
+use PLY.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index ad3df02..df72267 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -5,4 +5,5 @@ Spatial Geometry
:maxdepth: 2
intro
+ fileformats
api
diff --git a/docs/source/intro.rst b/docs/source/intro.rst
index b36ecd5..84c10ee 100644
--- a/docs/source/intro.rst
+++ b/docs/source/intro.rst
@@ -2,12 +2,10 @@
Introduction
************
-.. TODO: expand this page with more detail.
-
-Spatial Geometry provides simple 3D shape primitives -- cuboids, cylinders,
-spheres, and triangle meshes -- for representing robot links, obstacles, and
+Spatial Geometry provides simple 3D shape primitives --- cuboids, cylinders,
+spheres, ellipsoids, triangular meshes, axes and paths --- for representing robot links, obstacles, and
other geometry in a scene. Every shape carries a pose (position and
-orientation) and an optional colour, and can be tested for distance and
+orientation) and optional rendering properties such as color and opacity. A shape can be tested for distance and
collision against any other shape using `Coal
`_.
@@ -24,7 +22,7 @@ Installation
pip install spatialgeometry
-Distance and collision checking need the ``collision`` extra (`Coal
+Distance and collision checking requires the ``collision`` extra (`Coal
`_ and `trimesh `_)::
pip install spatialgeometry[collision]
@@ -48,9 +46,9 @@ Create a cuboid (rectangular prism) shape:
.. runblock:: pycon
- >>> import spatialgeometry as gm
+ >>> from spatialgeometry import Cuboid
>>> from spatialmath import SE3
- >>> cube = gm.Cuboid([1, 2, 3], color="blue")
+ >>> cube = Cuboid([1, 2, 3], color="blue")
>>> cube
In this case the cuboid is colored blue, and is 1 unit wide in the x-direction, 2 units deep in the
@@ -58,34 +56,78 @@ y-direction, and 3 units tall in the z-direction, and is centered at the origin.
default pose is the identity transform, which places the shape at the origin with no
rotation.
-Spatial Geometry includes a number of primitive shapes such as cuboids, cylinders, spheres, as well
-as triangle meshes. The following example creates a cuboid, a sphere, and a robot gripper from a mesh:
+Spatial Geometry includes a number of primitive shapes such as cuboids, cylinders, spheres, ellipsoids, triangular meshes, axes and paths.
+The following example creates a cuboid, a sphere, and a robot gripper from a mesh:
.. runblock:: pycon
- >>> import spatialgeometry as gm
+ >>> from spatialgeometry import Cuboid, Sphere, Mesh
>>> from spatialmath import SE3
- >>> cube = gm.Cuboid([1, 2, 3], color="blue", pose=SE3(0, 0, 0))
- >>> sphere = gm.Sphere(0.5, pose=SE3(2, 0, 0.3), color="red")
- >>> gripper = gm.Mesh("../figs/panda_hand.dae", pose=SE3.Rx(90, unit="deg")*SE3.Tx(0.5))
+ >>> cube = Cuboid([1, 2, 3], color="blue", pose=SE3(0, 0, 0))
+ >>> sphere = Sphere(0.5, pose=SE3(2, 0, 0.3), color="red")
+ >>> gripper = Mesh("figs/panda_hand.dae", pose=SE3.Rx(90, unit="deg")*SE3.Tx(0.5))
>>> cube
>>> sphere
>>> gripper
-Spatial Geometry uses the `trimesh `__ library to load triangle
-meshes from a number of file formats, including glTF/GLB, PLY, STL,
-OBJ, and Collada (``.dae``).
+Spatial Geometry uses the `trimesh `__ library to load triangular
+meshes from a number of file formats --- see :doc:`fileformats` for the full list, and
+which of them also work for display in `Swift `_.
+
+We can determine the axis-aligned bounding boxes of any shape, for example:
+
+.. runblock:: pycon
+ :exclude: 1-5
+
+ >>> from spatialgeometry import Cuboid, Sphere, Mesh
+ >>> from spatialmath import SE3
+ >>> cube = Cuboid([1, 2, 3], color="blue", pose=SE3(0, 0, 0))
+ >>> sphere = Sphere(0.5, pose=SE3(2, 0, 0.3), color="red")
+ >>> gripper = Mesh("figs/panda_hand.dae", pose=SE3.Rx(90, unit="deg")*SE3.Tx(0.5))
+ >>> cube.extents() # dimensions of the bounding box
+ >>> cube.bounds() # min and max coordinates of the bounding box
+ >>> sphere.extents() # dimensions of the bounding box
+ >>> sphere.bounds() # min and max coordinates of the bounding box
+ >>> gripper.extents() # dimensions of the bounding box
+ >>> gripper.bounds() # min and max coordinates of the bounding box
+ >>> gripper.corners() # coordinates of the 8 corners of the bounding box
+
+The edges of the box are aligned with the x-, y- and z-axes of the world frame.
+The ``extents`` method returns the dimensions of the bounding box, which clearly reflects the constructed dimensions of the cuboid and sphere.
+The ``bounds`` method returns the minimum and maximum coordinates of the bounding box in the local frame.
+The bounds show that the cuboid and sphere are centred about the origin -- this is true for all SpatialGeometry shape primitives
+but not necessarily true for meshes.
+For all methods, the rows correspond to the x-, y-, and z-axes.
+This bounding box is computed in the object's local frame, before any transformation (``pose`` parameter at construction, or the ``T`` attribute set).
+
+To determine the axis-aligned bounding box in the world frame we pass the ``world=True`` argument to the methods:
+
+.. runblock:: pycon
+ :exclude: 1-2
+
+ >>> from spatialgeometry import Cuboid
+ >>> from spatialmath import SE3
+ >>> cube = Cuboid([1, 2, 3], color="blue", pose=SE3(10, 11, 12)*SE3.RPY(10, 20, 30, unit="deg"))
+ >>> cube.extents(world=True) # dimensions of the bounding box in the world frame
+ >>> cube.bounds(world=True) # min and max coordinates of the bounding box in the world frame
+ >>> cube.corners(world=True) # coordinates of the 8 corners of the bounding box in the world frame
+
+
+We clearly see that the bounding box in the world frame is larger than the bounding box in the
+local frame, because the shape has been rotated in the world frame, and the corners reflect that the
+shape has been translated in the world frame.
+
We can measure the distance between any two shapes, and check whether they collide:
.. runblock:: pycon
:exclude: 1-5
- >>> import spatialgeometry as gm
+ >>> from spatialgeometry import Cuboid, Sphere, Mesh
>>> from spatialmath import SE3
- >>> cube = gm.Cuboid([1, 2, 3], color="blue", pose=SE3(0, 0, 0))
- >>> sphere = gm.Sphere(0.5, pose=SE3(2, 0, 0), color="red")
- >>> gripper = gm.Mesh("figs/panda_hand.dae", pose=SE3.Rx(90, unit="deg"))
+ >>> cube = Cuboid([1, 2, 3], color="blue", pose=SE3(0, 0, 0))
+ >>> sphere = Sphere(0.5, pose=SE3(2, 0, 0), color="red")
+ >>> gripper = Mesh("figs/panda_hand.dae", pose=SE3.Rx(90, unit="deg"))
>>> d, p1, p2 = cube.closest_point(sphere, inf_dist=10)
>>> d
>>> p1 # point on cube
@@ -101,156 +143,166 @@ We can measure the distance between any two shapes, and check whether they colli
between them. If the shapes collide, ``d`` is zero and ``p1`` and ``p2`` are the same
point.
-A shape's pose and geometry can be serialised to a plain dict, used by
-Swift to describe a scene to the browser:
-.. runblock:: pycon
-
- >>> import spatialgeometry as gm
- >>> cube = gm.Cuboid([1, 2, 3],color="blue", pose=SE3.Rx(90, unit="deg"))
- >>> cube.to_dict()
-This shows the full list of properties that describe the shape, including its type, size, pose (as a translation
-vector and unit quaternion), opacity, and color.
-
-Visualizing shapes
-==================
-
-Spatial Geometry itself has no renderer -- it describes geometry but
-doesn't draw it. To actually see a shape, we use the companion package `Swift
-`_.
+A shape can be completely described by a dict:
-Displaying shapes
------------------
-
-Swift opens a browser tab and renders whatever is added to the scene:
-
-.. code-block:: python
-
- # pip install swift-sim
- import spatialgeometry as gm
- from spatialmath import SE3
- from swift import Swift
+.. runblock:: pycon
- env = Swift()
- env.launch(realtime=True)
+ >>> from spatialgeometry import Cuboid
+ >>> cube = Cuboid([1, 2, 3],color="blue", pose=SE3.Rx(90, unit="deg"))
+ >>> cube.to_dict()
- cube = gm.Cuboid([1, 2, 3], pose=SE3(0, 0, 0.5), color="blue")
- sphere = gm.Sphere(0.3, pose=SE3(2, 0, 0.3), color="red")
- gripper = gm.Mesh("../figs/panda_hand.dae", pose=SE3.Rx(90, unit="deg")*SE3.Tx(0.3))
- env.add(cube)
- env.add(sphere)
- env.add(gripper)
+which includes the full list of properties that describe the shape, including its type, size, pose (as a translation
+vector and unit quaternion), opacity, and color. This is used by
+Swift to describe objects to the JavaScript code running in the browser.
-Swift's ``env.add()`` accepts a bare ``Shape`` directly -- internally it
-just calls the shape's ``to_dict()`` (shown above) and sends it over
-a websocket to the browser which runs Swift's JavaScript code to render the scene.
+Shape properties
+================
-The scene is navigated with the mouse, using three.js's standard
-`OrbitControls `__:
+The properties of an object are represented by read/write properties.
+We can change a shape's geometric properties, such as size or pose, as well as
+visual properties, such as color and opacity, by setting the appropriate property. For example:
-.. list-table:: Mouse controls
- :header-rows: 1
- :widths: 30 70
+.. runblock:: pycon
- * - Control
- - Action
- * - Left button, drag
- - Rotate (orbit) the camera around the orbit target
- * - Right button, drag
- - Pan the camera and orbit target together
- * - Scroll wheel
- - Zoom in/out (dolly the camera towards/away from the orbit target)
+ >>> from spatialgeometry import Cuboid
+ >>> from spatialmath import SE3
+ >>> cube = Cuboid([1, 2, 3], color="blue")
+ >>> cube
+ >>> cube.color = "red"
+ >>> cube.T = SE3(1, 2, 3)*SE3.Rx(90, unit="deg")
+ >>> cube.scale = [2, 4, 6]
+ >>> cube.opacity = 0.5
+ >>> cube
+ >>> print(cube)
-The camera always looks at a fixed point in space called the *orbit target*
--- dragging with the left button rotates the camera around this point rather
-than around the scene's origin. Swift sets the orbit target just above the
-ground plane, at ``(0, 0, 0.2)``, so that rotating the view keeps your
-shapes centred rather than swinging around the ground plane at ``z=0``.
-Panning (right button or Ctrl/Cmd/Shift+left button) moves the orbit target itself, so subsequent
-rotations pivot around wherever you've panned to.
-Notes:
+Scene graphs
+============
-* The shapes have a finite z-displacement to lift them above the ground plane at
- z=0. The parts of objects below the ground plane are not visible from above the ground
- plane (default camera position) but if you rotate the scene using the mouse you can
- look beneath the ground and see the hidden part of the object.
+Spatial Geometry supports scene graphs --- shapes can be placed in the scene
+relative to other shapes. If the *parent* shape's pose is changed, the pose of all the *child* shapes is updated accordingly.
+This allows for hierarchical modeling of complex scenes.
- * While we can use a wide variety of mesh formats for Spatial Geometry, Swift only
- supports a subset of them: Collada (``.dae``) and STL (``.stl``). Collada (``.dae``)
- supports color and texture, which STL (``.stl``) does not. See Swift's README for
- more details.
+Consider a household scene. We model a room that contains a table on which are placed several plates, and each plate has an item of food.
+We can express this as
+a directed acyclic graph (DAG) --- *scene graph* --- where each node represents an object in the scene:
+.. mermaid::
-See ``examples/displaying_shapes.py`` for a complete example.
+ graph LR
+ Room[Room] --> Table[Table]
+ Room --> Chair1[Chair1]
+ Room --> Chair2[Chair2]
+ Table --> Plate1((Plate1))
+ Plate1 --> Beef[[Beef]]
+ Table --> Plate2((Plate2))
+ Plate2 --> Chicken[[Chicken]]
-Animating shapes
-----------------
+Each edge, an arrow from parent to child, represents a relative pose --- the pose of the
+child relative to the parent. The pose of the table is specified relative to the room,
+the pose of the plates is specified relative to the table, and so on. We say that the
+table is a child of the room, and the room is the parent of the table. If we move the
+table, the plates and food move with it. If we move a plate, the food on it moves with
+it. The scene graph allows us to model the relationships between objects in a scene in a concise way.
-To animate a shape, we simply change its pose and call ``env.step()`` to update the
-scene. The following example animates a sphere moving back and forth along the x-axis:
-.. code-block:: python
+.. runblock:: pycon
- # pip install swift-sim
- import spatialgeometry as gm
+ from spatialgeometry import Cuboid, Cylinder, Sphere
from spatialmath import SE3
- from swift import Swift
- env = Swift()
- env.launch(realtime=True)
+ room = Cuboid([5, 5, 3], color="gray", pose=SE3(0, 0, 1.5))
+ table = Cuboid([2, 1, 1], pose=SE3(1, 0, 0.5)) # 2x1m table that is 1m tall
+ plate1 = Cylinder(0.2, 0.04, color="white", pose=SE3(0.5, 0, 0.02))
+ plate2 = Cylinder(0.2, 0.04, color="white", pose=SE3(-0.5, 0, 0.02))
+ beef = Sphere(0.05, color="saddlebrown", pose=SE3(0.05, 0, 0.05))
+ chicken = Sphere(0.05, color="peru", pose=SE3(0, 0.04, 0.05))
+ table.scene_parent = room
+ plate1.scene_parent = table
+ plate2.scene_parent = table
+ beef.scene_parent = plate1
+ chicken.scene_parent = plate2
- sphere = gm.Sphere(0.3, pose=SE3(0, 0, 0.3), color="red")
+ print(plate1.tree())
+ print(chicken._wT) # world pose of chicken
- env.add(sphere)
+The ``tree()`` method prints the scene graph in a human-readable form, showing the parent-child relationships between the shapes.
- for i in range(500):
- x = math.sin(i/20) * 0.5
- sphere.T = SE3.Trans(x, 0, 0.3)
- env.step(0.05) # wait 0.05 seconds before next step
+.. note::
+ The parent relationships can also be set at construction time by passing the ``scene_parent`` argument to the constructor of a shape.
+ This does require that the parent shape is constructed first, so that it can be passed to the child shape's constructor.
-See ``examples/animating_shapes.py`` for a complete example.
-
-Scene graphs
-============
-Spatial Geometry supports the concept of a scene graph, where shapes can be placed
-relative to other shapes. Specifically, the child shape's pose is relative to the parent
-shape's pose. This allows for hierarchical modeling of complex objects.
+The initial world pose of the chicken is printed, and we see that it is the same as its
+local pose --- the scene graph has not yet been updated to reflect the relative
+poses of its parents.
+The ``_wT`` property is a read-only property that returns the world pose of the shape as a 4x4 Numpy array.
-Consider a household scene. We can model a table and specify its pose with respect to
-the room. On the table we have several plates, placed relative to the table, and we have items
-of food on each of the plates, placed relative to the plate. We can express this as
-a directed acyclic graph (DAG), where each node represents an object in the scene.
-In this context we call the graph a *scene graph*.
-
-.. mermaid::
-
- graph LR
- Room[Room] --> Table[Table]
- Room --> Chair1[Chair1]
- Room --> Chair2[Chair2]
- Table --> Plate1((Plate1))
- Plate1 --> Beef[[Beef]]
- Table --> Plate2((Plate2))
- Plate2 --> Chicken[[Chicken]]
+We can update the scene graph by calling the ``update()`` method on the root node of the
+scene graph (the room in this case):
-Each edge, an arrow from parent to child, represents a relative pose -- the pose of the
-child relative to the parent. The pose of the table is specified relative to
-the room, the pose of the plates is specified relative to the table, and so on.
-We say that the table is a child of the room, and the room is the parent of the table.
+.. runblock:: pycon
+ :exclude: 1-14
-The relative poses do not have to be constant -- they can be animated over time. For
+ from spatialgeometry import Cuboid, Cylinder, Sphere
+ from spatialmath import SE3
+ room = Cuboid([5, 5, 3], color="gray", pose=SE3(0, 0, 1.5))
+ table = Cuboid([2, 1, 1], pose=SE3(1, 0, 0.5)) # 2x1m table that is 1m tall
+ plate1 = Cylinder(0.2, 0.04, color="white", pose=SE3(0.5, 0, 0.02))
+ plate2 = Cylinder(0.2, 0.04, color="white", pose=SE3(-0.5, 0, 0.02))
+ beef = Sphere(0.05, color="saddlebrown", pose=SE3(0.05, 0, 0.05))
+ chicken = Sphere(0.05, color="peru", pose=SE3(0, 0.04, 0.05))
+ table.scene_parent = room
+ plate1.scene_parent = table
+ plate2.scene_parent = table
+ beef.scene_parent = plate1
+ chicken.scene_parent = plate2
+ print(chicken._wT)
+ room.update() # update the scene graph starting from the root node
+ print(chicken._wT)
+
+The beauty of scene graphs is that they allow us to model complex scenes with many
+objects and relationships in a concise way. The relative poses of the child shapes are
+specified relative to their parent shapes, and the world poses of all shapes can be
+computed by traversing the scene graph from the root node down to the leaves. The
+relative poses do not have to be constant --- they can be animated over time. For
example, we can animate the table moving around the room, and the plates and food will
move with it. We can also change the parent-child relationships over time, for example
if we pick up a plate and move it to a different table, or if we pick up a piece of food
and move it to a different plate. The scene graph is a powerful way to model complex
scenes with many objects and relationships.
-We can also model an articulated robot arm in this way. Each link has a joint controlled
+Let's demonstrate this by moving the table:
+
+.. runblock:: pycon
+ :exclude: 1-14
+
+ from spatialgeometry import Cuboid, Cylinder, Sphere
+ from spatialmath import SE3
+ room = Cuboid([5, 5, 3], color="gray", pose=SE3(0, 0, 1.5))
+ table = Cuboid([2, 1, 1], pose=SE3(1, 0, 0.5)) # 2x1m table that is 1m tall
+ plate1 = Cylinder(0.2, 0.04, color="white", pose=SE3(0.5, 0, 0.02))
+ plate2 = Cylinder(0.2, 0.04, color="white", pose=SE3(-0.5, 0, 0.02))
+ beef = Sphere(0.05, color="saddlebrown", pose=SE3(0.05, 0, 0.05))
+ chicken = Sphere(0.05, color="peru", pose=SE3(0, 0.04, 0.05))
+ table.scene_parent = room
+ plate1.scene_parent = table
+ plate2.scene_parent = table
+ beef.scene_parent = plate1
+ chicken.scene_parent = plate2
+ print(chicken._wT)
+ table.T = table.T * SE3(0.2, 0.3, 0) # move the table
+ room.update() # update the scene graph starting from the root node
+ print(chicken._wT)
+
+We can see that the world pose of the chicken has changed, reflecting the movement of the table.
+See ``examples/room.py`` for a similar example working in Swift.
+
+We can use this same approach to model an articulated robot arm. Each link has a joint controlled
pose relative to its parent link and we have created a scene graph link from the robot's
gripper to a piece of food that it is holding:
@@ -267,35 +319,80 @@ In code the parent-child relationships are expressed by setting the ``scene_pare
property of a shape to another shape. Each ``Shape`` subclass can be a scene node in the
graph.
-The following simple example creates a cube and attaches two spheres to it, at
-specified offsets, and moves the cube within the scene:
+
+See the :doc:`api` page for the full class reference.
+
+Scene groups
+------------
+
+All shapes, collision and non-collision shapes, inherit from :class:`Shape` which
+inherits from the :class:`SceneNode` base class. The :class:`SceneGroup` class is a
+special type of :class:`SceneNode` which inherits
+list-like properties from ``UserList``. It can be used to group several shapes together --- they
+move as a single object when the :class:`SceneGroup` is moved, and they have a common parent. Since
+a :class:`SceneGroup` inherits from :class:`SceneNode` it can have child nodes.
.. runblock:: pycon
- import spatialgeometry as gm
+ from spatialgeometry import Cuboid, Sphere, SceneGroup
from spatialmath import SE3
- # Every Shape *is* a SceneNode
- cube = gm.Cuboid([1, 1, 1], color="blue", pose=SE3(0, 0, 0))
- sphere1 = gm.Sphere(0.5, pose=SE3(1, 0, 0), color="red")
- sphere2 = gm.Sphere(0.5, pose=SE3(0, 1, 0), color="green")
- # attach sphere1 and sphere2 to the cube, the relative offsets are given by their respective poses
- sphere1.scene_parent = cube
- sphere2.scene_parent = cube
- print(sphere1._wT[:3, 3])
- print(sphere2._wT[:3, 3])
- # Move the parent. This updates cube's own world pose but does NOT cascade to its children
- cube.T = SE3(5, 0, 0)
- # Tell the scene graph that something has changed and that the world poses of all children need to be updated.
- cube.update()
- # Now the children have been updated to reflect the new world pose of their parent
- print(sphere1._wT[:3, 3])
- print(sphere2._wT[:3, 3])
-
-The ``update()`` method is invoked automatically by Swift's ``env.step()`` method
-to handle changes in object's pose. Because we are not using Swift in this example we need to call this manually.
-This code structure can be scaled up indefintely to create complex scenes with many objects and relationships.
-See ``examples/scene_graph.py`` for a similar example working in Swift.
-
+ cube = Cuboid([1, 1, 1], color="blue", pose=SE3(0, 0, 0))
+ sphere1 = Sphere(0.5, pose=SE3(1, 0, 0), color="red")
+ sphere2 = Sphere(0.5, pose=SE3(0, 1, 0), color="green")
+ sphere3 = Sphere(0.5, pose=SE3(0, 0, 1), color="blue")
+ sphere4 = Sphere(0.5, pose=SE3(1, 1, 0), color="white")
+ group = SceneGroup([sphere1, sphere2])
+ group.scene_parent = cube
+ sphere3.scene_parent = group
+ sphere4.scene_parent = cube
+ group
+ print(cube.tree())
+
+Note that ``sphere3`` was never passed to ``group.append()`` -- it was attached by
+setting its ``scene_parent`` directly to ``group`` -- yet it still shows up as a
+member of ``group``. This isn't a special case: ``append()`` *is* just
+``item.scene_parent = self``, so the two are the same operation under different
+names. List membership and scene-graph parentage of a :class:`SceneGroup` are the
+same relationship, not two things kept in sync with each other.
+
+A related concept is the :class:`CollisionShapeGroup` which groups together
+:class:`CollisionShape` instances. In robotics, the *collision shape* of a robot link is
+typically a collection of simple 3D primitives (cuboids, spheres, cylinders) that are
+used for fast and efficient collision detection. The more detailed and accurate
+triangular meshes are only used for visualization. A collision check that involves one
+or two collision shape groups will check for collisions between all the shapes in each
+group.
-See the :doc:`api` page for the full class reference.
+Visualizing shapes
+==================
+
+Spatial Geometry itself has no renderer --- it describes geometry but
+doesn't draw it. To actually see a shape, we use the companion package `Swift
+`_. Here's a simple example that creates a cuboid, a sphere, and a robot gripper from a mesh, and displays them:
+
+
+.. code-block:: python
+
+ # pip install swift-sim
+ from spatialgeometry import Cuboid, Sphere, Mesh
+ from spatialmath import SE3
+ from swift import Swift
+
+ env = Swift()
+ env.launch(realtime=True)
+
+ cube = Cuboid([1, 2, 3], pose=SE3(0, 0, 0.5), color="blue")
+ sphere = Sphere(0.3, pose=SE3(2, 0, 0.3), color="red")
+ gripper = Mesh("../figs/panda_hand.dae", pose=SE3.Rx(90, unit="deg")*SE3.Tx(0.3))
+
+ env.add(cube)
+ env.add(sphere)
+ env.add(gripper)
+
+Swift opens a new browser tab and renders whatever is *added* to the scene. If the
+pose of a shape is changed the ``env.step()`` will update the appearance of the scene in the browser.
+
+
+More information about Swift and its capabilities for animation can be found in the
+`Swift documentation `_.
diff --git a/examples/animating_shapes.py b/examples/animating_shapes.py
deleted file mode 100644
index 00a18cf..0000000
--- a/examples/animating_shapes.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# pip install swift-sim
-import spatialgeometry as gm
-from spatialmath import SE3
-from swift import Swift
-import math
-
-env = Swift()
-env.launch(realtime=True)
-
-sphere = gm.Sphere(0.3, pose=SE3(0, 0, 0.3), color="red")
-
-env.add(sphere)
-
-for i in range(500):
- x = math.sin(i/20) * 0.5
- sphere.T = SE3.Trans(x, 0, 0.3)
- env.step(0.05) # wait 0.05 seconds before next step
\ No newline at end of file
diff --git a/examples/cube.py b/examples/cube.py
new file mode 100644
index 0000000..fd8a5bb
--- /dev/null
+++ b/examples/cube.py
@@ -0,0 +1,21 @@
+from spatialgeometry import Cuboid
+from spatialmath import SE3
+import numpy as np
+np.set_printoptions(precision=3, suppress=True, linewidth=80)
+
+cube = Cuboid([1, 2, 3], color="blue")
+print(cube)
+print("pose of cube:", SE3(cube.T).strline())
+print("\nIn local frame:")
+print(f"dimensions of bounding box:\n{cube.extents()}")
+print(f"bounds of bounding box:\n{cube.bounds()}")
+print(f"corners of bounding box:\n{cube.corners()}")
+print()
+cube.T = SE3(10, 11, 12)*SE3.RPY(10, 20, 30, unit="deg")
+print("pose of cube:", SE3(cube.T).strline())
+
+print("\nIn world frame:")
+print(f"dimensions of bounding box:\n{cube.extents(world=True)}")
+print(f"bounds of bounding box:\n{cube.bounds(world=True)}")
+print(f"corners of bounding box:\n{cube.corners(world=True)}")
+
diff --git a/examples/displaying_shapes.py b/examples/displaying_shapes.py
deleted file mode 100644
index 0376960..0000000
--- a/examples/displaying_shapes.py
+++ /dev/null
@@ -1,15 +0,0 @@
-# pip install swift-sim
-import spatialgeometry as gm
-from spatialmath import SE3
-from swift import Swift
-
-env = Swift()
-env.launch(realtime=True)
-
-cube = gm.Cuboid([1, 2, 3], pose=SE3(0, 0, 0.5), color="blue")
-sphere = gm.Sphere(0.3, pose=SE3(2, 0, 0.3), color="red")
-gripper = gm.Mesh("../docs/figs/panda_hand.dae", pose=SE3.Rx(90, unit="deg"))
-
-env.add(cube)
-env.add(sphere)
-env.add(gripper)
diff --git a/examples/mesh.py b/examples/mesh.py
new file mode 100644
index 0000000..29fa4c2
--- /dev/null
+++ b/examples/mesh.py
@@ -0,0 +1,10 @@
+from spatialgeometry import Cuboid, Mesh
+from spatialmath import SE3
+import numpy as np
+np.set_printoptions(precision=3, suppress=True, linewidth=80)
+
+gripper = Mesh("../docs/figs/panda_hand.dae", pose=SE3.Rx(90, unit="deg")*SE3.Tx(0.5))
+
+print(f"dimensions of bounding box:\n{gripper.extents()}")
+print(f"bounds of bounding box:\n{gripper.bounds()}")
+print(f"corners of bounding box:\n{gripper.corners()}")
diff --git a/examples/room.py b/examples/room.py
new file mode 100644
index 0000000..8f1a4a0
--- /dev/null
+++ b/examples/room.py
@@ -0,0 +1,34 @@
+from spatialgeometry import Cuboid, Cylinder, Sphere
+from spatialmath import SE3
+
+# define the room, table, plates and food items
+room = Cuboid([5, 5, 3], color="gray", pose=SE3(0, 0, 1.5))
+table = Cuboid([2, 1, 1], pose=SE3(1, 0, 0.5)) # 2x1m table that is 1m tall
+plate1 = Cylinder(0.2, 0.04, color="white", pose=SE3(0.5, 0, 0.02))
+plate2 = Cylinder(0.2, 0.04, color="white", pose=SE3(-0.5, 0, 0.02))
+beef = Sphere(0.05, color="saddlebrown", pose=SE3(0.05, 0, 0.05))
+chicken = Sphere(0.05, color="peru", pose=SE3(0, 0.04, 0.05))
+
+# set up the scene graph by setting the scene_parent of each shape
+table.scene_parent = room
+plate1.scene_parent = table
+plate2.scene_parent = table
+beef.scene_parent = plate1
+chicken.scene_parent = plate2
+
+print("Scene graph:")
+print(plate1.tree())
+
+print("\nPose of chicken (initial):", SE3(chicken._wT).strline())
+room.update()
+print("Pose of chicken (updated):", SE3(chicken._wT).strline())
+
+table.T = table.T * SE3(0.2, 0.3, 0) # move the table
+room.update() # update the scene graph starting from the root node
+print("Pose of chicken (moved table):", SE3(chicken._wT).strline())
+
+plate2.T = plate2.T * SE3.Rz(45, "deg") # rotate the plate
+room.update() # update the scene graph starting from the root node
+print("Pose of chicken (rotated plate):", SE3(chicken._wT).strline())
+
+
diff --git a/examples/scene_graph.py b/examples/scene_graph.py
deleted file mode 100644
index e5bafe9..0000000
--- a/examples/scene_graph.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import spatialgeometry as gm
-from spatialmath import SE3
-from swift import Swift
-import math
-
-env = Swift()
-env.launch(realtime=True)
-
-# Every Shape *is* a SceneNode (Shape inherits from SceneNode) -- there's no
-# separate wrapper node to create, just parent one shape to another directly.
-cube = gm.Cuboid([1, 1, 1], color="blue", pose=SE3(0, 0, 0.5))
-sphere1 = gm.Sphere(0.5, pose=SE3(1, 0, 0.3), color="red")
-sphere2 = gm.Sphere(0.5, pose=SE3(0, 1, 0.3), color="green")
-
-# sphere1.T and sphere2.T are now interpreted relative to cube, not the world.
-sphere1.scene_parent = cube
-sphere2.scene_parent = cube
-
-env.add(cube)
-env.add(sphere1)
-env.add(sphere2)
-
-for i in range(200):
- # The cube rotates about the z-axis and spirals outward
- cube.T = SE3.Rz(i/10) * SE3((i/50), 0, 0)
-
- env.step()
\ No newline at end of file
diff --git a/src/spatialgeometry/geom/CollisionShape.py b/src/spatialgeometry/geom/CollisionShape.py
index 22a3885..6f6954d 100644
--- a/src/spatialgeometry/geom/CollisionShape.py
+++ b/src/spatialgeometry/geom/CollisionShape.py
@@ -42,6 +42,13 @@ def _require_coal() -> None:
class CollisionShape(Shape):
+ """
+ Base class for a :class:`Shape` that also has associated
+ collision geometry, so instances can be used for collision
+ checking (via `coal `_) as
+ well as being rendered in the scene.
+ """
+
def __init__(self, collision: bool = True, **kwargs) -> None:
self.co = None # coal.CollisionObject, created on first use
self._cinit = False
@@ -139,6 +146,10 @@ class CollisionShapeGroup(CollisionShape, UserList):
:class:`CollisionShapeGroup`) objects that itself behaves like a single
collision-checkable shape.
+ :param initlist: Initial elements to populate the group with.
+ :param collision: Whether this group participates in collision
+ checking, defaults to True.
+
Unlike :class:`~spatialgeometry.SceneGroup`, which admits any
:class:`SceneNode`, a :class:`CollisionShapeGroup` only accepts
:class:`CollisionShape` or :class:`CollisionShapeGroup` instances --
@@ -354,21 +365,27 @@ def iscollided(self, shape: CollisionShape) -> bool:
class Mesh(CollisionShape):
"""
- A mesh object described by an STL, OBJ, or DAE file.
+ A triangular mesh object.
:param filename: Absolute path to the mesh file.
:param scale: Scale factor(s) along XYZ axes (default [1, 1, 1]). A
single number applies the same scale to all three axes.
- :param color: Flat colour override, applied to every face/vertex. If
- not given, the renderer uses whatever per-vertex/per-face colours
- are baked into the mesh file itself, when present.
:param y_up: Set True if the mesh file was authored with +Y as the
"up" axis -- a common convention in general 3D/graphics tooling --
- rather than this ecosystem's +Z-up convention. See the
- ``_Y_UP_TO_Z_UP`` comment in this module for the full story; the
- short version is that Swift applies the matching correction on
- its own side, and the two must be kept in sync.
+ rather than this ecosystem's +Z-up convention.
:param collision: Whether this shape participates in collision checking.
+
+ .. note::
+ For a :class:`Mesh`, ``color`` is a flat color
+ override applied to every face/vertex. If not given, the renderer
+ uses whatever per-vertex/per-face colors are baked into the mesh
+ file itself, when present.
+
+ Unlike the primitive shapes, a mesh's local origin is not guaranteed to
+ be at its geometric centre -- it's whatever origin the file was
+ authored/exported with, which may be off-centre or even outside the
+ mesh entirely.
+
"""
_repr_params = ("filename", "scale", "y_up")
@@ -388,7 +405,7 @@ def __init__(
self.y_up = y_up
# Overrides Shape.color's setter (keeping its getter) purely to track
- # whether a caller has explicitly asked for a flat colour at any point
+ # whether a caller has explicitly asked for a flat color at any point
# after construction too, not just via __init__'s color= above.
@Shape.color.setter
def color(self, value: ArrayLike) -> None:
@@ -428,6 +445,14 @@ def _init_coal(self) -> None:
@property
def scale(self) -> np.ndarray:
+ """
+ Scale factors along the local X, Y, Z axes. A scalar sets all
+ three axes equally; ``None`` resets to ``[1, 1, 1]``.
+
+ This is a read/write property.
+
+ :rtype: ndarray(3)
+ """
return self._scale
@scale.setter
@@ -452,9 +477,7 @@ def filename(self, value: str | None) -> None:
def y_up(self) -> bool:
"""
True if this mesh file was authored with +Y as "up" and needs
- the +Y -> +Z correction applied. See the ``_Y_UP_TO_Z_UP`` LOUD
- WARNING comment above ``Mesh`` -- Swift applies the matching
- correction on its own side, and the two must stay in sync.
+ the +Y -> +Z correction applied.
This is a read/write property.
@@ -495,7 +518,7 @@ def _local_corners(self) -> np.ndarray:
class Cylinder(CollisionShape):
"""
- A cylinder whose centre is at the local origin, axis along Z.
+ A cylinder whose centre is at the local origin and its axis along the z-axis.
:param radius: Radius in metres.
:param length: Total length in metres.
@@ -679,6 +702,14 @@ def _local_corners(self) -> np.ndarray:
class Box(Cuboid):
+ """
+ Deprecated alias for :class:`Cuboid` -- a rectangular prism whose
+ centre is at the local origin.
+
+ :param scale: [length, width, height] in metres.
+ :param collision: Whether this shape participates in collision checking.
+ """
+
def __init__(self, scale: ArrayLike, **kwargs) -> None:
warn("Box is deprecated, use Cuboid instead", FutureWarning)
super().__init__(scale, **kwargs)
diff --git a/src/spatialgeometry/geom/SceneGroup.py b/src/spatialgeometry/geom/SceneGroup.py
index b64f463..2a0ee6f 100755
--- a/src/spatialgeometry/geom/SceneGroup.py
+++ b/src/spatialgeometry/geom/SceneGroup.py
@@ -13,7 +13,11 @@
class SceneGroup(SceneNode, UserList):
"""
- An ordered, list-like collection of :class:`SceneNode` objects.
+ An ordered, list-like collection of :class:`SceneNode` objects (nodes can
+ be nested groups, shapes, or any other :class:`SceneNode` subclass) that
+ itself behaves like a single :class:`SceneNode`.
+
+ :param initlist: Initial elements to populate the group with.
A :class:`SceneGroup` is itself a :class:`SceneNode` so its elements can be
collectively, parented or nested like any other node in the scene graph. This class
@@ -26,6 +30,13 @@ class SceneGroup(SceneNode, UserList):
``del``) clears it back to ``None``. This is what makes moving the group
move its elements with it.
+ The reverse holds too, and isn't just a side effect -- ``append()`` is
+ nothing more than ``item.scene_parent = self``, so setting any node's
+ ``scene_parent`` to a :class:`SceneGroup` directly (with no ``append``
+ call at all) equally makes it a member of that group's list. List
+ membership and scene-graph parentage are the same relationship, not
+ two things kept in sync -- there's no way for them to disagree.
+
.. runblock:: pycon
>>> from spatialgeometry import SceneGroup, Cuboid, Sphere
diff --git a/src/spatialgeometry/geom/SceneNode.py b/src/spatialgeometry/geom/SceneNode.py
index 157fa44..15ab472 100644
--- a/src/spatialgeometry/geom/SceneNode.py
+++ b/src/spatialgeometry/geom/SceneNode.py
@@ -14,6 +14,14 @@
class SceneNode:
+ """
+ Base class for a node in a scene graph.
+
+ Subclassed for particular shapes and provides the shape's pose, a
+ parent/children relationship to other nodes, and the ability to
+ compute its pose in the world frame from the scene graph.
+ """
+
def __init__(
self,
pose: ndarray | SE3 = eye(4),
@@ -166,8 +174,16 @@ def __str__(self) -> str:
@property
def scene_parent(self) -> SceneNode | None:
"""
- Returns the parent node of this object
+ Return the parent node of this object in the scene graph.
+
+ Setting a new parent adds this object to the new parent's
+ ``scene_children``.
+
+ This is a read/write property.
+ :rtype: SceneNode | None
+
+ :seealso: :meth:`scene_children` :meth:`attach` :meth:`attach_to`
"""
return self._scene_parent
@@ -220,8 +236,17 @@ def _update_scene_parent(self, parent: SceneNode) -> None:
@property
def scene_children(self) -> list[SceneNode]:
"""
- Returns the child nodes of this object
+ Return the child nodes of this object in the scene graph.
+
+ Setting a new list of children updates each child's ``scene_parent`` to this
+ object, but does not remove this object from any previous parent's
+ ``scene_children``.
+
+ This is a read/write property.
+ :rtype: list(SceneNode)
+
+ :seealso: :meth:`scene_parent` :meth:`attach` :meth:`attach_to`
"""
return self._scene_children
@@ -230,7 +255,6 @@ def scene_children(self, children: list[SceneNode]) -> None:
"""
Sets the child nodes of this object, does not update childs
parent
-
"""
# Set our children
self._scene_children = children
@@ -315,6 +339,24 @@ def _T(self, T: ndarray):
@property
def T(self) -> ndarray:
+ """
+ Pose of the shape relative to its parent frame in the scene
+ graph (or the world frame if it has no parent), as a 4x4
+ homogeneous transformation matrix. Set via the ``pose``
+ argument of the constructor.
+
+ This is a read/write property. The getter always returns a plain
+ ``ndarray``; the setter also accepts an :class:`~spatialmath.SE3`.
+
+
+ .. warning::
+ Because the getter returns an ``ndarray``, in-place operators like
+ ``shape.T *= delta`` do an elementwise multiply, not a pose
+ composition, even when ``delta`` is an ``SE3`` -- use
+ ``shape.T = shape.T * delta`` (or ``shape.T @= delta.A``) instead.
+
+ :rtype: ndarray(4,4)
+ """
return self._T
@T.setter
@@ -418,9 +460,21 @@ def tree(self) -> str:
# --------------------------------------------------------------------- #
def attach(self, object: SceneNode) -> None:
+ """Attach a child node
+
+ :param object: the node to attach as a child of this node
+
+ :seealso: :meth:`attach_to` :meth:`scene_children`
+ """
new_childs = self.scene_children
new_childs.append(object)
self.scene_children = new_childs
def attach_to(self, object: SceneNode) -> None:
+ """Attach this node to a parent node
+
+ :param object: the node to attach this node to, this node will become a child of the parent
+
+ :seealso: :meth:`attach` :meth:`scene_parent`
+ """
self.scene_parent = object
diff --git a/src/spatialgeometry/geom/Shape.py b/src/spatialgeometry/geom/Shape.py
index 56306e6..0b1f773 100644
--- a/src/spatialgeometry/geom/Shape.py
+++ b/src/spatialgeometry/geom/Shape.py
@@ -78,10 +78,12 @@ def wrapper_mark_changed(*args, **kwargs):
class Shape(SceneNode, ABC):
"""
- Abstract base class for a single renderable/collidable object in the
- scene (a primitive, a mesh, or a Path). Not instantiated directly --
- see the concrete subclasses in this module and in
- :class:`~spatialgeometry.geom.CollisionShape.CollisionShape`.
+ Abstract base class for a renderable 3D shape in a scene graph.
+
+ It is a :class:`SceneNode` instance with atributes for its type, shape, and color.
+ The ``collision`` attribute is a read-only bool, used for objects that are drawn in
+ the scene but take no part in collision detection (see :class:`CollisionShape` for
+ that).
"""
#: Names of this class's own constructor arguments to include in
@@ -99,8 +101,8 @@ def __init__(
"""
:param pose: Local reference frame of the shape, defaults to the
identity transform.
- :param color: Colour as (r, g, b) or (r, g, b, a) in [0-1] (or
- [0-255], auto-normalised), or a matplotlib colour name. Defaults
+ :param color: Color as (r, g, b) or (r, g, b, a) in [0-1] (or
+ [0-255], auto-normalised), or a matplotlib color name. Defaults
to a mid-grey ``(0.3, 0.3, 0.3, 1.0)``.
:param stype: Shape type identifier used by the renderer/wire
protocol (e.g. ``"cuboid"``, ``"mesh"``) -- set by each concrete
@@ -237,19 +239,44 @@ def __repr__(self) -> str:
# a list/array input), which reprs as "np.float64(1.0)" instead of
# a plain "1.0". Harmless for JSON (float64 genuinely subclasses
# float, unlike int64), but ugly here specifically.
- args.append(f"color={tuple(float(c) for c in self.color[:3])!r}")
+ #
+ # round(..., 3) -- a named color like "green" round-trips through
+ # matplotlib as e.g. 0.5019607843137255 (128/255); this is a
+ # display repr, not a value anyone parses back, so trim it to a
+ # readable 3 decimal places rather than showing 8-bit-derived
+ # binary-fraction noise.
+ args.append(f"color={tuple(round(float(c), 3) for c in self.color[:3])!r}")
if self.color[3] != 1.0:
- args.append(f"opacity={float(self.color[3])!r}")
+ args.append(f"opacity={round(float(self.color[3]), 3)!r}")
args.append(f"pose={SE3(self._T, check=False).strline()!r}")
return f"{type(self).__name__}({', '.join(args)})"
@property
def collision(self) -> bool:
+ """
+ True if this shape is used for collision checking rather than
+ (or as well as) visual rendering, as set by the ``collision``
+ argument of a :class:`CollisionShape` subclass' constructor.
+
+ This is a read-only property.
+
+ :rtype: bool
+ """
return self._collision
@property
def v(self) -> ndarray:
+ """
+ Spatial velocity of the shape as a 6-vector: linear velocity
+ ``v[:3]`` followed by angular velocity ``v[3:6]``. Used to
+ integrate the shape's pose between frames, e.g. by
+ ``Swift.step()`` when no per-step callback is supplied.
+
+ This is a read/write property.
+
+ :rtype: ndarray(6)
+ """
return self._v
@v.setter
@@ -398,7 +425,8 @@ def extents(self, world: bool = False) -> ndarray:
class Axes(Shape):
- """An axes whose center is at the local origin.
+ """A set of 3D axes whose centre is at the local origin.
+
Parameters
:param length: The length of each axis.
@@ -415,8 +443,6 @@ class Axes(Shape):
and radius == 0. Passed straight through to each constituent
Arrow.
:type linewidth: float
- :param pose: Local reference frame of the shape
- :type pose: SE3
"""
@@ -438,6 +464,14 @@ def __init__(
@property
def length(self) -> float:
+ """
+ The length of each axis, as set by ``length`` in the
+ constructor.
+
+ This is a read/write property.
+
+ :rtype: float
+ """
return self._length
@length.setter
@@ -447,6 +481,15 @@ def length(self, value: float) -> None:
@property
def arrows(self) -> bool:
+ """
+ If ``True``, each axis is rendered as a colored :class:`Arrow`
+ (red/green/blue for X/Y/Z) instead of a plain line, as set by
+ ``arrows`` in the constructor.
+
+ This is a read/write property.
+
+ :rtype: bool
+ """
return self._arrows
@arrows.setter
@@ -456,6 +499,17 @@ def arrows(self, value: bool) -> None:
@property
def radius(self) -> float:
+ """
+ Shaft radius of each arrow. Only used when ``arrows`` is
+ ``True``; passed straight through to each constituent
+ :class:`Arrow` (``radius`` and ``linewidth`` are mutually
+ exclusive -- ``radius`` > 0 takes precedence). Set by
+ ``radius`` in the constructor.
+
+ This is a read/write property.
+
+ :rtype: float
+ """
return self._radius
@radius.setter
@@ -465,6 +519,16 @@ def radius(self, value: float) -> None:
@property
def linewidth(self) -> float:
+ """
+ Shaft width in pixels, only used when ``arrows`` is ``True``
+ and ``radius`` is 0. Passed straight through to each
+ constituent :class:`Arrow`. Set by ``linewidth`` in the
+ constructor.
+
+ This is a read/write property.
+
+ :rtype: float
+ """
return self._linewidth
@linewidth.setter
@@ -489,12 +553,8 @@ def to_dict(self) -> dict[str, Any]:
class Arrow(Shape):
- """An arrow whose center is at the local origin, and points
- in the positive z direction.
-
- The arrow is made using a cylinder and a cone
-
- Parameters
+ """An arrow whose centre is at the local origin, and points
+ in the positive z-direction.
:param length: The total length of the arrow.
:param radius: The radius of the arrow shaft. If radius is 0, the
@@ -504,14 +564,15 @@ class Arrow(Shape):
case (a real cylinder mesh has no notion of a pixel width).
:param linewidth: Width of the shaft in pixels. Only used when
radius == 0.
- :param head_length: The lenght of the cone (head of the arrow). This is
- represented as a fraction of the lenght. Must be a value between 0
+ :param head_length: The length of the cone (head of the arrow). This is
+ represented as a fraction of the length. Must be a value between 0
and 1.
:param head_radius: The width of the cone (head of the arrow). This is
represented as a fraction of the head_length.
- :param pose: Local reference frame of the shape
- :type pose: SE3
+ The arrow has a cylindrical shaft and a conical head.
+
+ .. note:: This shape cannot be used for collision detection, and is only for visualisation purposes.
"""
@@ -538,6 +599,14 @@ def __init__(
@property
def length(self) -> float:
+ """
+ The total length of the arrow, as set by ``length`` in the
+ constructor.
+
+ This is a read/write property.
+
+ :rtype: float
+ """
return self._length
@length.setter
@@ -547,6 +616,17 @@ def length(self, value: float) -> None:
@property
def radius(self) -> float:
+ """
+ The radius of the arrow shaft. If 0, the shaft is rendered as
+ a line instead of a cylinder -- see ``linewidth``. ``radius``
+ and ``linewidth`` are mutually exclusive: ``radius`` > 0
+ always takes precedence. Set by ``radius`` in the
+ constructor.
+
+ This is a read/write property.
+
+ :rtype: float
+ """
return self._radius
@radius.setter
@@ -556,6 +636,14 @@ def radius(self, value: float) -> None:
@property
def linewidth(self) -> float:
+ """
+ Width of the shaft in pixels. Only used when ``radius`` is 0.
+ Set by ``linewidth`` in the constructor.
+
+ This is a read/write property.
+
+ :rtype: float
+ """
return self._linewidth
@linewidth.setter
@@ -565,6 +653,15 @@ def linewidth(self, value: float) -> None:
@property
def head_length(self) -> float:
+ """
+ The length of the cone forming the arrow head, as a fraction
+ of ``length`` in the range [0, 1]. Set by ``head_length`` in
+ the constructor.
+
+ This is a read/write property.
+
+ :rtype: float
+ """
return self._head_length
@head_length.setter
@@ -574,6 +671,15 @@ def head_length(self, value: float) -> None:
@property
def head_radius(self) -> float:
+ """
+ The width of the cone forming the arrow head, as a fraction
+ of ``head_length``. Set by ``head_radius`` in the
+ constructor.
+
+ This is a read/write property.
+
+ :rtype: float
+ """
return self._head_radius
@head_radius.setter
@@ -599,9 +705,8 @@ def to_dict(self) -> dict[str, Any]:
class Path(Shape):
- """A polyline through a sequence of waypoints -- straight segments
- joining consecutive points, not a smoothed curve -- for drawing
- paths and trajectories in the scene.
+ """A polyline through a sequence of waypoints defined with respect
+ to the local frame of the shape.
:param points: waypoints defining the polyline
:type points: ArrayLike
@@ -613,8 +718,11 @@ class Path(Shape):
:param linewidth: Width of the line in pixels. Only used when
radius == 0.
- :param pose: Local reference frame of the shape
- :type pose: SE3
+ This shape is used for drawing paths and trajectories in the scene.
+ The line comprises straight segments joining consecutive points, not a smoothed curve.
+
+ .. note:: This shape cannot be used for collision detection, and is only for visualisation purposes.
+
"""
_repr_params = ("points", "radius", "linewidth")
@@ -652,6 +760,16 @@ def points(self, value: ArrayLike) -> None:
@property
def radius(self) -> float:
+ """
+ Tube radius; if 0, rendered as a line instead of a tube --
+ see ``linewidth``. ``radius`` and ``linewidth`` are mutually
+ exclusive: ``radius`` > 0 always takes precedence. Set by
+ ``radius`` in the constructor.
+
+ This is a read/write property.
+
+ :rtype: float
+ """
return self._radius
@radius.setter
@@ -661,6 +779,14 @@ def radius(self, value: float) -> None:
@property
def linewidth(self) -> float:
+ """
+ Width of the line in pixels. Only used when ``radius`` is 0.
+ Set by ``linewidth`` in the constructor.
+
+ This is a read/write property.
+
+ :rtype: float
+ """
return self._linewidth
@linewidth.setter
@@ -670,7 +796,7 @@ def linewidth(self, value: float) -> None:
def to_dict(self) -> dict[str, Any]:
"""
- to_dict() returns the shapes information in dictionary form
+ Returns the shape's information in dictionary form
:returns: All information about the shape
:rtype: dict