Note
Go to the end to download the full example code.
Morphing hull surfaces through shapes¶
run this example as a notebook — or grab the .ipynb (also linked at the bottom of the page)
Building on the Morphing through the shapes zoo example, HyperTools can
also render a smooth, lit convex-hull SURFACE around a moving point cloud
(the surface= hyp.plot kwarg – see hypertools.plot.meshutil
and hypertools.plot.surface, GH #109) instead of just the raw
points. Combining surface= with animate=’morph’ (PR #272, maintainer
request 2026-07-06) recomputes the moving cloud’s smoothed hull mesh from
scratch on every frame, shaded with a two-light Blinn-Phong model and
backface-culled for the current camera angle – so the “blob” skin flows
continuously as the underlying points rearrange themselves, all from one
hyp.plot call. Camera rotation speed (degrees/frame) is always constant
across the whole animation, so a per-segment rotations list (below)
controls how much SCREEN TIME each hold/transition gets, never how fast
it spins. Since a convex hull cannot reproduce concave features,
holds on concave shapes like the bunny necessarily render as a smooth,
rounded blob; that loss of concavity is an expected trade-off of the
hull-surface approach, not a bug. Hulls hug the data tightly BY
CONSTRUCTION (each smoothing round pulls stray vertices back onto the
original hull surface, see hypertools.plot.meshutil.smooth_hull_3d())
rather than via any fixed overshoot allowance, so the axes box never needs
a hand-computed fudge factor to contain the surface. A final, bounded,
grow-only rescale then guarantees at least 99% containment of the actual
points; for ordinary, reasonably-sampled clouds this rescale rarely does
more than nudge the mesh a few percent, and only grows large for very
sparse clouds (rule of thumb: fewer than ~10 points), where a coarse,
few-vertex hull loses proportionally more to smoothing and needs more
correction to recover that same 99% guarantee.
To keep the gallery build modest, only 5 of the 7 zoo shapes are used
(dropping the very high point-count dragon and biplane meshes), and each
shape is downsampled to 400 points before it’s ever handed to hyp.plot
(a hull’s shape is set by its extreme points, so fewer interior points
barely change the rendered surface). This matters for more than just the
per-frame ConvexHull cost: hyp.plot also builds one full hull per
morphing dataset up front (from whatever point cloud it’s given) to size
the axes box safely for every frame, and a raw shapes-zoo cloud is dense
enough that its hull can itself have tens of thousands of faces –
downsampling first keeps both that one-time sizing pass and every
per-frame rebuild fast. The mesh smoothing is also capped at 2 rounds
rather than the library default of 3, roughly a 4x per-frame speedup
(measured on a typical laptop).
/home/docs/checkouts/readthedocs.org/user_builds/hypertools/checkouts/latest/hypertools/plot/backend.py:1353: UserWarning: Failed to switch to any interactive backend (TkAgg, QtAgg, Qt5Agg, Qt4Agg, GTK4Agg, GTK3Agg, WXAgg). Falling back to 'Agg'.
warnings.warn(BACKEND_WARNING)
# Code source: Contextual Dynamics Laboratory
# License: MIT
# use a pre-rendered gif of the morphing lit surface as this example's gallery
# thumbnail, matching the other animated examples (animate_spin, chemtrails,
# plot_story_trajectories, ...). Without this, sphinx-gallery thumbnailed the
# trailing static-figure snapshot (the alpha-fade line below) as a frozen png.
# sphinx_gallery_thumbnail_path = '_static/thumbnails/sphx_glr_animate_surface_morph_thumb.gif'
import numpy as np
import hypertools as hyp
# a subset of the shapes zoo -- smaller point-cloud shapes are used so the
# per-frame convex-hull/smoothing pass stays fast (see module docstring)
shapes = ['bunny', 'cube', 'sphere', 'teapot', 'vase']
# a hull's shape is set by its extreme points, not its interior density, so
# downsampling every shape to 400 points barely changes the rendered
# surface while keeping both the one-time axes-sizing hull and every
# per-frame rebuilt hull cheap (see module docstring)
n_points = 400
rng = np.random.default_rng(42)
def normalize_shape(points):
"""Center and scale a point cloud into the hypertools [-1, 1] cube."""
points = np.asarray(points, dtype=np.float64)
points = points - points.mean(axis=0)
return points / np.abs(points).max()
def load_shape(name):
"""Load and normalize one shapes-zoo cloud, downsampled to `n_points`
(without replacement) so its hull stays cheap to compute."""
points = normalize_shape(hyp.load(name))
idx = rng.choice(len(points), size=min(n_points, len(points)),
replace=False)
return points[idx]
clouds = [load_shape(shape) for shape in shapes]
# frame schedule: hold, morph, hold, morph, ..., hold -- 2 * n_shapes - 1 =
# 9 segments in all. Holds get a slow, easy-to-watch full rotation (1)
# while transitions get a brisk quarter-turn (0.25), so the camera visibly
# steps forward every time one shape morphs into the next. Camera speed
# stays CONSTANT (degrees/frame) across the whole animation, so each
# segment's SCREEN TIME is proportional to its own rotation count instead
# of split evenly: the 5 full-rotation holds get 60 frames each and the 4
# quarter-turn transitions get 15 frames each (360 frames total @ 30 fps,
# 12 sec, same total length as an equal-time split would have given).
rotations = [1, 0.25] * (len(shapes) - 1) + [1]
# surface look: a solid, lit blue-teal hull with 2 rounds of smoothing
# (rather than the library default of 3) to keep per-frame cost low; the
# points are kept only as a faint texture underneath the surface (set
# below), which does the visual work
surface_spec = {
'alpha': 0.97,
'color': '#2E86AB',
'smoothing': 2,
'keep_points': True,
}
# morph_samples=n_points: every shape is ALREADY downsampled to exactly
# n_points above, so under the new full-sample-with-duplication semantics
# (PR #272 follow-up, 2026-07-06) this is a no-op safety cap -- no shape
# ever exceeds n_points, so none is downsampled again and no duplication
# is needed (all 5 clouds already share the same point count).
fig, ani = hyp.plot(clouds, fmt='.', color='k', markersize=0.6,
animate='morph', rotations=rotations,
duration=12, frame_rate=30,
morph_samples=n_points, surface=surface_spec)
# fade the point layer to a subtle texture underneath the hull surface.
# NOTE: under animate='morph' the per-dataset lines are hidden and the
# VISIBLE traveling point cloud is a separate artist, so select the visible
# line rather than assuming a position in get_lines()
visible_lines = [ln for ln in fig.axes[0].get_lines() if ln.get_visible()]
visible_lines[-1].set_alpha(0.25)
Total running time of the script: (3 minutes 29.522 seconds)