You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: add render_ipyvolume() for ipyvolume 3D plots - #329
Stacked on #324 (@render_pythreejs), on top of #356 (shared, process-global widgets; replacement for the auto-merged #351), #346 (browser-created widget comms), and #343 (generic AMD-externals resolution). This PR adds the ipyvolume renderer; its dependency machinery lives in #343 and its shared-widget handling in #356. Merge order: #343 → #346 → #356 → #324 → this PR. Part of #278.
Add @render_ipyvolume for ipyvolume 3D plots
ipyvolume is the classic Jupyter 3D plotting package (scatter, quiver, volume rendering). With this PR, Shiny apps can render ipyvolume figures reactively — the WebGL canvas fills (and resizes with) its container, and camera state and selections round-trip to Python:
importipyvolumeasipvimportnumpyasnpfromshinyimportApp, uifromshinywidgetsimportoutput_widget, render_ipyvolumeapp_ui=ui.page_fillable(output_widget("plot"))
defserver(input, output, session):
@render_ipyvolumedefplot():
x, y, z=np.random.normal(size=(3, 1000))
ipv.quickscatter(x, y, z, size=1, marker="sphere")
returnipv.gcf()
app=App(app_ui, server)
Requires pip install 'shinywidgets[ipyvolume]' (the extra also installs ipython_genutils, which ipyvolume 0.6.3 — the latest release, from 2023 — imports but doesn't declare).
Both ways of building a figure work, including in outputs that re-render:
The pylab API: ipv.quickscatter(...), or ipv.figure() plus drawing calls — return ipv.gcf() (or the object quickscatter()/gcc() returns, which is unwrapped to its figure).
Direct construction: ipv.Figure() with ipv.widgets.Scatter and friends added to fig.scatters. Set size_selected/color_selected explicitly on scatters (their trait defaults are 0-d numpy arrays, which don't JSON-serialize; pylab sets them for you) and set fig.xlim/ylim/zlim (directly constructed figures default to a [0, 1]³ axis box with no auto-fit).
Interaction state round-trips to Python: orbit/zoom camera state via reactive_read(), and rectangle/lasso selections via fig.on_selection(fn) (selections arrive as custom messages, not trait changes, and the payload is a pixel/device region, not selected indices).
Sizing
A figure's width/height traits are its WebGL canvas size in pixels, with no built-in fit-to-container behavior, so a WIDGET_BEHAVIORS entry (js) drives the canvas from the container through those traits (throttled ResizeObservers on the output and the widget view, minus toolbar height; the measured size round-trips to Python). Explicit sizes are always respected:
The two "default" sizes (the 500x400 trait default and the 400x500 ipv.figure() always passes) are treated as unset, as is a size matching the last browser sync (so redisplaying a cached figure still fills).
An explicit ipv.figure(width=..., height=...) keeps its pixel size, even inside an output_widget(height=...).
output_widget(height=...) sizes the canvas to the output.
A widget Layout(height=...) — including viewport-relative values like "50vh" — sizes the canvas to the widget view.
Implementation notes
Shared ipyvue/ipyvuetify widgets come from feat: share process-global widgets across sessions and re-renders (#348) #351.import ipyvolume constructs module-level singleton widgets (ipyvue's force_load_instance, ipyvuetify's theme), and pylab's never-rendered Container uses ipyvue Template widgets that are shared across the whole process. feat: share process-global widgets across sessions and re-renders (#348) #351 treats these as process-global widgets (tolerated at import time, mirrored into every session, never closed by render cleanup), so this PR needs no ipyvolume-specific workaround: apps can import ipyvolume at module level, and pylab figures re-render cleanly across re-renders and sessions.
require.js externals (from feat: resolve widget bundles' AMD externals generically #343). ipyvolume's and pythreejs's AMD bundles each declare an external three but ship incompatible three.js builds, and an ipyvolume figure needs both (its scene/camera are pythreejs widgets); jupyter-vuetify's bundle similarly externalizes jupyter-vue. The generic resolution in feat: resolve widget bundles' AMD externals generically #343 parses the bundles' define([...]) headers at render time and maps/shims those externals per-module — without it, require.js requests /three.js at the app root (404) and the figure never renders.
ipywidgets 8. ipyvolume 0.6.3 works with the repo's ipywidgets 8.1+ floor and the bundled @jupyter-widgets/html-manager 1.0.
Caveats
ipyvolume's FigureView.remove() never calls renderer.dispose(), so WebGL contexts leak per re-render (upstream; unmaintained since 2023) — the same theme as k3d/ipysigma in docs/widget-reports/.
Rough benchmark (base64 buffer transport, page load → first composited frame, headless Chromium): ~10k float32 points ~1.6 s; ~100k points ~8.5 s. Transport isn't the bottleneck at 10k but dominates at 100k. (Measured with direct construction.)
Verification
make check-format and make pyright (0 errors) on this head
js/src and the committed shinywidgets/static/output.js are byte-identical to the previously verified head of this PR (where cd js && yarn build reproduced the bundle)
Unit tests and the Playwright suites (tests/playwright/render_ipyvolume: rendering, fill, explicit sizing, selection + camera round trips; tests/playwright/render_ipyvolume_rerender: re-render lifecycle over the pylab API, old models closed) are left to CI on this head
Testing notes for future widget work: WebGL renders fine in headless Chromium (SwiftShader), and tests assert composited screenshot pixels since WebGL canvases can't be read back; clicks that land while the main thread is busy with WebGL setup can sit in the renderer's event queue for a long time, so tests wait for the first frame before interacting.
In CDN-only mode the local jupyter-vue path mapping was still being
served, bypassing the local-dependency guard. Skip it in CDN-only mode
and disable CDN-only mode in test_vuetify_widget_open_serves_jupyter_vue
(which asserts the mapping is present in local mode).
Addresses #329 (comment)
Exclude auto layout height from intrinsic view measurement
js/src/ipyvolume.ts:47
With output_widget(height="250px") and Layout(height="auto"), this treats the auto-height view as explicitly sized and measures its intrinsic height instead of the 250px output. The canvas therefore retains its default height and overflows or is clipped, even though set_layout_defaults() treats "auto" as unset. Exclude intrinsic sizing keywords when deciding whether to measure the view, add a regression case combining an explicit output height with an auto Layout height, and regenerate shinywidgets/static/output.js.
In CDN-only mode the local jupyter-vue path mapping was still being
served, bypassing the local-dependency guard. Skip it in CDN-only mode
and disable CDN-only mode in test_vuetify_widget_open_serves_jupyter_vue
(which asserts the mapping is present in local mode).
Addresses #329 (comment)
The local jupyter-vue require.js path mapping is skipped in CDN-only mode
(the local-dependency guard), so test_vuetify_widget_open_serves_jupyter_vue,
which asserts the mapping is present, now turns CDN-only mode off.
Addresses #329 (comment)
The local jupyter-vue require.js path mapping is skipped in CDN-only mode
(the local-dependency guard), so test_vuetify_widget_open_serves_jupyter_vue,
which asserts the mapping is present, now turns CDN-only mode off.
Addresses #329 (comment)
- render_ipyvolume() accepts an ipyvolume.Figure, whether built with the
pylab API (ipv.gcf() after drawing) or constructed directly, and
unwraps the Container returned by ipv.quickscatter()/ipv.gcc() to its
figure
- The figure's WebGL canvas fills (and tracks) its container unless the
figure has an explicit width/height, via a new ipyvolume entry in
WIDGET_BEHAVIORS (js/src/fill.ts + js/src/ipyvolume.ts) and
ipyvolume_layout_defaults in _render_widget_base.py
- Selection (on_selection) and camera traits round-trip to Python
- A shinywidgets[ipyvolume] extra (ipyvolume 0.6.3 also needs the
undeclared ipython_genutils)
- examples/ipyvolume, unit tests, and Playwright tests (rendering, fill
and explicit sizing, selection/camera round trips, pylab re-render
lifecycle and model cleanup)
pylab's never-rendered Container shares ipyvue's process-wide Template
widgets; shared-widget support (#348) keeps those alive across
re-renders and sessions, so pylab works for reactive outputs too.
Exiting a card's fullscreen mode left the ipyvolume canvas ~10px too
tall (slightly clipped) until the next container resize: the auto-resize
sampled the figure toolbar's height mid-transition and no further event
fired to correct it. After applying a new canvas size, schedule one
trailing re-measure (350ms) so post-transition toolbar heights are
picked up. Includes the rebuilt shinywidgets/static/output.js.
…size
With Layout(height="300px") inside a fillable card, output_widget() stays
a fill item (taller than the widget) even though the widget disables
filling, so measuring the output sized the canvas to the container and
the 300px widget clipped it. Measure the explicitly sized view instead
when the Layout supplies an inline width/height; otherwise keep measuring
the output. Adds a Playwright regression test for a Layout-height figure
inside a fillable card (fails before the fix with the canvas sized to
the taller output).
The browser resizer saves its measured canvas size back to the figure's
width/height traits, so re-rendering a cached, session-lifetime figure
mistook those measurements for an explicit user size and flipped fill
from True to False (the output then stopped tracking container resizes).
Record the size last synced from the front-end (via a wrapper on
set_state(), the method front-end 'update' messages go through) and treat
a matching size as unset, while a size the user assigned after that sync
still counts as an intentional explicit size.
The local jupyter-vue require.js path mapping is skipped in CDN-only mode
(the local-dependency guard), so test_vuetify_widget_open_serves_jupyter_vue,
which asserts the mapping is present, now turns CDN-only mode off.
Addresses #329 (comment)
Two Copilot review fixes:
- setupFixedSize gated the resizer on any CSS height, so an explicit
ipv.Figure(width=400, height=300) inside output_widget(height=...)
got overwritten. A Layout height on the widget still drives the
canvas (that's a size to fill), but the output's CSS height now only
applies while the widget hasn't opted out of filling.
- A viewport-relative Layout height (e.g., Layout(height="50vh"))
resizes the view without touching its inline style or the output's
size, which neither observer caught; observe the view's geometry
too (safe: with no explicit Layout size the view tracks the canvas
and resizeToFit() measures the output behind its size cache).
Adds Playwright regression tests for both combinations.
Put the ipyvolume renderer export, fill behavior, layout defaults, and
their tests in alphabetical order alongside the other renderers, move
code that earlier rebases displaced back to its upstream position, and
rebuild shinywidgets/static/output.js from the ipyvolume JS sources.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Add
@render_ipyvolumefor ipyvolume 3D plotsipyvolume is the classic Jupyter 3D plotting package (scatter, quiver, volume rendering). With this PR, Shiny apps can render ipyvolume figures reactively — the WebGL canvas fills (and resizes with) its container, and camera state and selections round-trip to Python:
Requires
pip install 'shinywidgets[ipyvolume]'(the extra also installsipython_genutils, which ipyvolume 0.6.3 — the latest release, from 2023 — imports but doesn't declare).Both ways of building a figure work, including in outputs that re-render:
ipv.quickscatter(...), oripv.figure()plus drawing calls — returnipv.gcf()(or the objectquickscatter()/gcc()returns, which is unwrapped to its figure).ipv.Figure()withipv.widgets.Scatterand friends added tofig.scatters. Setsize_selected/color_selectedexplicitly on scatters (their trait defaults are 0-d numpy arrays, which don't JSON-serialize; pylab sets them for you) and setfig.xlim/ylim/zlim(directly constructed figures default to a [0, 1]³ axis box with no auto-fit).Interaction state round-trips to Python: orbit/zoom camera state via
reactive_read(), and rectangle/lasso selections viafig.on_selection(fn)(selections arrive as custom messages, not trait changes, and the payload is a pixel/device region, not selected indices).Sizing
A figure's
width/heighttraits are its WebGL canvas size in pixels, with no built-in fit-to-container behavior, so aWIDGET_BEHAVIORSentry (js) drives the canvas from the container through those traits (throttledResizeObservers on the output and the widget view, minus toolbar height; the measured size round-trips to Python). Explicit sizes are always respected:ipv.figure()always passes) are treated as unset, as is a size matching the last browser sync (so redisplaying a cached figure still fills).ipv.figure(width=..., height=...)keeps its pixel size, even inside anoutput_widget(height=...).output_widget(height=...)sizes the canvas to the output.Layout(height=...)— including viewport-relative values like"50vh"— sizes the canvas to the widget view.Implementation notes
import ipyvolumeconstructs module-level singleton widgets (ipyvue'sforce_load_instance, ipyvuetify'stheme), and pylab's never-renderedContaineruses ipyvueTemplatewidgets that are shared across the whole process. feat: share process-global widgets across sessions and re-renders (#348) #351 treats these as process-global widgets (tolerated at import time, mirrored into every session, never closed by render cleanup), so this PR needs no ipyvolume-specific workaround: apps canimport ipyvolumeat module level, and pylab figures re-render cleanly across re-renders and sessions.threebut ship incompatible three.js builds, and an ipyvolume figure needs both (its scene/camera are pythreejs widgets); jupyter-vuetify's bundle similarly externalizesjupyter-vue. The generic resolution in feat: resolve widget bundles' AMD externals generically #343 parses the bundles'define([...])headers at render time and maps/shims those externals per-module — without it, require.js requests/three.jsat the app root (404) and the figure never renders.@jupyter-widgets/html-manager1.0.Caveats
FigureView.remove()never callsrenderer.dispose(), so WebGL contexts leak per re-render (upstream; unmaintained since 2023) — the same theme as k3d/ipysigma indocs/widget-reports/.Verification
make check-formatandmake pyright(0 errors) on this headjs/srcand the committedshinywidgets/static/output.jsare byte-identical to the previously verified head of this PR (wherecd js && yarn buildreproduced the bundle)tests/playwright/render_ipyvolume: rendering, fill, explicit sizing, selection + camera round trips;tests/playwright/render_ipyvolume_rerender: re-render lifecycle over the pylab API, old models closed) are left to CI on this headTesting notes for future widget work: WebGL renders fine in headless Chromium (SwiftShader), and tests assert composited screenshot pixels since WebGL canvases can't be read back; clicks that land while the main thread is busy with WebGL setup can sit in the renderer's event queue for a long time, so tests wait for the first frame before interacting.