Skip to content

feat: add render_k3d() for k3d 3D plots - #281

Merged
cpsievert merged 20 commits into
mainfrom
render-k3d
Oct 27, 2026
Merged

cpsievert merged 20 commits into
mainfrom
render-k3d

Conversation

@cpsievert

@cpsievert cpsievert commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

You can now return a k3d plot from a Shiny app and get a live WebGL scene that fills its card/sidebar layout, with typed .widget access (a k3d.plot.Plot) and the camera (and other plot state) readable reactively via reactive_read(). Requires pip install 'shinywidgets[k3d]'.

Part of #278.

Notes

  • k3d.plot() already returns a widget, so no coercion is needed.
  • k3d hard-codes a pixel height (default 512) on its inner container. In a filling layout this only acts as a flex basis, so the plot grows/shrinks with its container (measured: canvas == output box, 842x386 -> 542x386 as the window narrows).
  • Opting out of filling: k3d.plot(height=N) with N != 512, or an explicit Layout(height=...) on the widget, keeps the fixed height. Caveat: since k3d always sets height, exactly 512 is indistinguishable from "unset" and fills.
  • output_widget(height="250px") is respected via a small scoped CSS rule (without it the 512px plot was clipped: canvas 512 in a 250px output).
  • WebGL works in stock headless Chromium (SwiftShader), no CI flags needed; tests verify actual drawn pixels.
  • k3d ships its 2.5 MB frontend module over the widget comm as a binary buffer; this works through shinywidgets' buffer transport unchanged.

Verification

format, pyright, unit tests (187 passed); Playwright render_k3d (5 tests, stable across repeated runs) plus render_maplibre and render_matplotlib_fill; example app run and screenshot.

🤖 Generated with Claude Code

Renders k3d.plot() widgets, filling their container by default (k3d's pixel
height only applies when the user sets a non-default height), and following an
explicit output_widget() height instead of overflowing it.

Part of #278
Every re-render of a k3d plot leaked the old plot: k3d's Plot.close() only
clears the ipywidgets Outputs it's shown in and never calls Widget.close(),
so its comm stayed registered in Python and its model stayed alive in the
browser (8 re-renders left 8 of each), along with the plot's data.

For k3d widgets, init_shiny_widget() now wraps close() so it also runs
Widget.close() (a no-op once the comm is closed). New unit test checks the
comm is closed on re-render; new Playwright test checks the browser's model
count settles back after re-renders.
A Layout(height="300px") on a k3d plot opts out of filling, but it only
sized the widget's .lm-Widget wrapper: k3d draws at its own pixel `height`
(512 by default), so the bottom 212px of the scene was cut off.

Make the wrapper a flex column and let k3d's inner container flex to it.
Its pixel height stays the flex basis, so plots without a definite Layout
height keep it. New Playwright case covers Layout(height="300px").
Returning a drawable such as `k3d.points(...)` from @render_k3d rendered an
empty output with no error: drawables are model-only widgets that draw
nothing by themselves. In Jupyter, displaying one puts it on a new
`k3d.plot()`, so render_k3d now does the same (Groups included).

Also name the plot class by its runtime path (`k3d.Plot`; `k3d.plot.Plot`
doesn't resolve because `k3d.plot` is the factory function). Unit tests
moved into tests/unit/test_k3d.py.
# Conflicts:
#	CHANGELOG.md
#	pyproject.toml
#	shinywidgets/__init__.py
#	shinywidgets/_render_widget.py
#	shinywidgets/_render_widget_base.py
#	tests/unit/test_render_widget.py
#	tests/unit/test_render_widget_base_layout_defaults.py
#	uv.lock
…d-9ceabf5-67c7-s0-t0

# Conflicts:
#	CHANGELOG.md
#	pyproject.toml
#	tests/unit/test_render_widget_base_layout_defaults.py
Merges origin/main (per-renderer extras #330, AGENTS.md consolidation
#331, @render_ipydatagrid, @render_jscatter) into render-k3d:

- Accept CLAUDE.md deletion; AGENTS.md is now the real file
- Add k3d = ["k3d"] extra and keep k3d in test/dev deps
- Point @render_k3d docstring and ImportError at
  pip install 'shinywidgets[k3d]'
- Add @render_k3d row to the README extras table
- examples/k3d/requirements.txt now uses shinywidgets[k3d]
# Conflicts:
#	shinywidgets/_render_widget.py
#	tests/unit/test_render_widget.py
# Conflicts:
#	CHANGELOG.md
#	README.md
#	pyproject.toml
#	shinywidgets/__init__.py
#	shinywidgets/_render_widget.py
#	shinywidgets/_render_widget_base.py
#	tests/unit/test_render_widget.py
The sandbox/ folder is local scratch space (probe scripts, screenshots,
port files) already covered by .gitignore. It was committed by accident
while developing render_k3d(); drop it from the tree so the PR only
contains the renderer, tests, and docs.

This comment was marked as resolved.

- Apply the k3d comm-close workaround through the class MRO (matching the
  perspective/itables hooks and the layout-defaults dispatch), so
  user-defined Plot subclasses don't leak comms and browser models on
  re-render.
- Exclude intrinsic heights (auto/fit-content/...) from the k3d pixel-height
  CSS overrides, as the bqplot rule already does, so output_widget(height=
  "auto") keeps the plot's intrinsic height instead of collapsing to 100%
  of an indefinite-height parent.

Adds a unit test (Plot subclass comm cleanup) and a Playwright assertion
(intrinsic output height keeps k3d's 512px).
@cpsievert
cpsievert marked this pull request as ready for review October 27, 2026 21:05
@cpsievert
cpsievert merged commit 927b9d2 into main Oct 27, 2026
5 checks passed
cpsievert added a commit that referenced this pull request Oct 27, 2026
Only conflict: the runtime alias chain in _render_widget.py's
non-TYPE_CHECKING block -- keep both EmbeddingAtlasWidget and K3dPlot.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants