Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
965f91a
feat: add render_k3d() for k3d 3D plots
cpsievert Oct 21, 2026
ab3204e
test: make render_k3d pixel checks poll and target the new view
cpsievert Oct 21, 2026
ff23330
docs: note k3d height CSS must stay in sync with _output_widget.py
cpsievert Oct 21, 2026
8d0cd3f
fix: close a k3d plot's comm when shinywidgets closes the plot
cpsievert Oct 22, 2026
5ad318a
fix: size a k3d scene to its widget's Layout(height=...)
cpsievert Oct 22, 2026
1ec59a3
fix: render a bare k3d object on a new plot
cpsievert Oct 22, 2026
77f35e2
Merge remote-tracking branch 'origin/main' into HEAD
cpsievert Oct 22, 2026
6f5f65f
Merge remote-tracking branch 'origin/main' into pi-subagents/pr281-k3…
cpsievert Oct 23, 2026
7f15877
Merge main and add k3d extra
cpsievert Oct 23, 2026
96c675f
Merge remote-tracking branch 'origin/main' into render-k3d
cpsievert Oct 23, 2026
7749918
Merge remote-tracking branch 'origin/main' into render-k3d
cpsievert Oct 23, 2026
c15ef22
Merge remote-tracking branch 'origin/main' into render-k3d
cpsievert Oct 23, 2026
9e03f3f
Merge remote-tracking branch 'origin/main' into render-k3d
cpsievert Oct 23, 2026
9afce9d
Merge remote-tracking branch 'origin/main' into render-k3d
cpsievert Oct 23, 2026
b396b01
Merge remote-tracking branch 'origin/main' into render-k3d
cpsievert Oct 23, 2026
f614252
Merge remote-tracking branch 'origin/main' into render-k3d
cpsievert Oct 23, 2026
218379c
chore: untrack scratch sandbox/ directory
cpsievert Oct 23, 2026
bdd60e1
Merge remote-tracking branch 'origin/main' into render-k3d
cpsievert Oct 23, 2026
4f87b5b
Merge remote-tracking branch 'origin/main' into render-k3d
cpsievert Oct 24, 2026
53fca04
fix: address Copilot review on render_k3d
cpsievert Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* `@render_ipyaladin` for [ipyaladin](https://cds-astro.github.io/ipyaladin/) interactive sky viewers. The viewer fills its container unless given an explicit `height`, and `reactive_read()` gives you access to the view's center and field of view (via the `_target`/`_fov` traits) as the user pans and zooms. (#278)
* `@render_ipyleaflet` for [ipyleaflet](https://ipyleaflet.readthedocs.io) maps and subclasses like [leafmap](https://leafmap.org)'s `Map`, with typed access to the map via `.widget` and map state (e.g., `zoom`, `center`, `bounds`) via `reactive_read()`. Maps now also respect an explicit `height` on `output_widget()` instead of always being 400px tall. (#278)
* `@render_jscatter` for [jupyter-scatter](https://jupyter-scatter.dev) GPU-accelerated scatter plots. Plots fit their output (in a filling layout or with an explicit `output_widget(height=)`) unless they have a height of their own, and `reactive_read(widget, "selection")` brings the selected points back to Python, e.g., to link brushing across plots and tables. (#278, #293)
* `@render_k3d` for [k3d](https://k3d-jupyter.org) 3D plots. Plots fill their container in filling layouts (unless `k3d.plot(height=...)` is set), and the plot's state (e.g., `camera`) can be read reactively with `reactive_read()`. (#278)
* `@render_keplergl` for [kepler.gl](https://kepler.gl) maps. Return a pandas `DataFrame` or geopandas `GeoDataFrame` (or a `keplergl.KeplerGl` widget) to get the full interactive kepler.gl interface, and use `reactive_read()` to react to its configuration. The map fills its container (or takes an explicit `height`) instead of staying at 400px. (#278)
* `@render_leafmap` for [leafmap](https://leafmap.org) maps. Renders leafmap's widget backends -- `leafmap.Map` (an ipyleaflet map, including leafmap's toolbar and basemap gallery), `leafmap.maplibregl.Map`, `leafmap.plotlymap.Map` (a Plotly `FigureWidget`), and `leafmap.kepler.Map` -- and treats leafmap's 600px default height as unset, so maps fill their container. Its folium and pydeck backends aren't ipywidgets, so they raise a `TypeError` that points to an alternative. The `leafmap` extra installs a compatible Plotly version, and `examples/leafmap/app_plotly.py` demonstrates in-place zoom and marker updates. (#278)
* `@render_lonboard` for [lonboard](https://developmentseed.org/lonboard/) maps. Return a `Map`, one or more layers, or any data `lonboard.viz()` accepts (e.g., a GeoPandas `GeoDataFrame` or a GeoArrow table). Maps fill their container unless given an explicit `height`, and `reactive_read()` gives you access to map state (e.g., `view_state`, or a layer's `selected_index`). (#278)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ everything it needs:
| `@render_ipysigma` | `pip install 'shinywidgets[ipysigma]'` |
| `@render_itables` | `pip install 'shinywidgets[itables]'` |
| `@render_jscatter` | `pip install 'shinywidgets[jscatter]'` |
| `@render_k3d` | `pip install 'shinywidgets[k3d]'` |
| `@render_keplergl` | `pip install 'shinywidgets[keplergl]'` |
| `@render_leafmap` | `pip install 'shinywidgets[leafmap]'` |
| `@render_lonboard` | `pip install 'shinywidgets[lonboard]'` (Python < 3.15) |
Expand Down
5 changes: 5 additions & 0 deletions examples/k3d/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## k3d app

<a href='https://connect.posit.cloud/publish?framework=shiny&sourceRepositoryURL=https%3A%2F%2Fgithub.com%2Fposit-dev%2Fpy-shinywidgets&sourceRef=main&sourceRefType=branch&primaryFile=examples%2Fk3d%2Fapp.py&pythonVersion=3.11'><img src='https://cdn.connect.posit.cloud/assets/deploy-to-connect-blue.svg' align="right" /></a>

What `@render_k3d` adds over a static or exported 3D plot: a live WebGL scene that fills its container, can be edited in place from Shiny inputs without resetting the camera, and reports the camera position back to Python via `reactive_read()`.
55 changes: 55 additions & 0 deletions examples/k3d/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from __future__ import annotations

import k3d
import numpy as np
from shiny import App, reactive, render, ui
from shinywidgets import output_widget, reactive_read, render_k3d

app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_slider("n", "Number of points", min=100, max=5000, value=1000),
ui.input_slider("point_size", "Point size", min=0.01, max=0.2, value=0.05),
ui.input_action_button("shuffle", "Resample"),
ui.h6("Camera position"),
ui.output_text_verbatim("camera"),
),
ui.card(
ui.card_header("Drag to orbit, scroll to zoom"),
output_widget("scene"),
full_screen=True,
),
title="k3d",
fillable=True,
)


def server(input, output, session):
@reactive.calc
def positions():
input.shuffle()
rng = np.random.default_rng()
return rng.normal(size=(input.n(), 3)).astype(np.float32)

@render_k3d
def scene():
xyz = positions()
plot = k3d.plot()
plot += k3d.points(xyz, attribute=xyz[:, 2], point_size=0.05)
return plot

# Changing the point size updates the existing scene in place (the camera
# stays wherever the user left it) rather than re-rendering the output
@reactive.effect
def _():
plot = scene.widget
with reactive.isolate():
points = plot.objects[0]
points.point_size = input.point_size()

@render.text
def camera():
position = reactive_read(scene.widget, "camera")[:3]
return ", ".join(f"{x:.2f}" for x in position)


app = App(app_ui, server)
3 changes: 3 additions & 0 deletions examples/k3d/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
shiny
shinywidgets[k3d]
numpy
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ ipyleaflet = ["ipyleaflet>=0.20.0"]
ipysigma = ["ipysigma"]
itables = ["itables[widget]"]
jscatter = ["jupyter-scatter"]
k3d = ["k3d"]
# An explicit pre-release specifier, so uv/pip accept it without --pre.
# keplergl 0.3.x imports pkg_resources (removed in setuptools 82), so 0.4
# (an anywidget rewrite) is the first version that imports there.
Expand Down Expand Up @@ -94,6 +95,7 @@ test = [
"matplotlib",
"jupyter_bokeh>=4.1",
"jupyter-scatter",
"k3d",
"keplergl>=0.4.0rc4",
# geoarrow-rust-core (a lonboard dependency) doesn't publish wheels for
# Python 3.15+ yet, so lonboard can't be installed there
Expand Down Expand Up @@ -140,6 +142,7 @@ dev = [
"matplotlib",
"jupyter_bokeh>=4.1",
"jupyter-scatter",
"k3d",
"keplergl>=0.4.0rc4",
# geoarrow-rust-core (a lonboard dependency) doesn't publish wheels for
# Python 3.15+ yet, so lonboard can't be installed there
Expand Down Expand Up @@ -189,6 +192,7 @@ test = [
"matplotlib",
"jupyter_bokeh>=4.1",
"jupyter-scatter",
"k3d",
"keplergl>=0.4.0rc4",
# geoarrow-rust-core (a lonboard dependency) doesn't publish wheels for
# Python 3.15+ yet, so lonboard can't be installed there
Expand Down
2 changes: 2 additions & 0 deletions shinywidgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
render_ipysigma,
render_itables,
render_jscatter,
render_k3d,
render_keplergl,
render_leafmap,
render_lonboard,
Expand Down Expand Up @@ -63,6 +64,7 @@
"render_ipysigma",
"render_itables",
"render_jscatter",
"render_k3d",
"render_keplergl",
"render_leafmap",
"render_lonboard",
Expand Down
60 changes: 59 additions & 1 deletion shinywidgets/_render_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from itables.widget import ITable
from jscatter.widget import JupyterScatter
from jupyter_bokeh import BokehModel
from k3d.plot import Plot as K3dPlot
from keplergl import KeplerGl
from lonboard import Map as LonboardMap # pyright: ignore[reportMissingImports]
from maplibre.ipywidget import MapWidget
Expand All @@ -39,7 +40,7 @@
BokehModel
) = MapWidget = FigureWidget = DeckGLWidget = MosaicWidget = BqplotFigure = (
CytoscapeWidget
) = base = LeafletMap = object
) = base = K3dPlot = LeafletMap = object
Aladin = IpycanvasCanvas = MultiCanvas = MultiRoughCanvas = RoughCanvas = (
LonboardMap
) = QuakWidget = PerspectiveWidget = object
Expand Down Expand Up @@ -91,6 +92,7 @@
"render_ipysigma",
"render_itables",
"render_jscatter",
"render_k3d",
"render_keplergl",
"render_leafmap",
"render_lonboard",
Expand Down Expand Up @@ -485,6 +487,62 @@ class render_jscatter(render_widget_base[ValueT, JupyterScatter]):
"""


class render_k3d(render_widget_base[ValueT, K3dPlot]):
"""
Reactively render a [k3d](https://k3d-jupyter.org) 3D plot as a Shiny output.

The decorated function should return a `k3d.Plot` (i.e., the result of
`k3d.plot()` with objects added to it; requires ``pip install 'shinywidgets[k3d]'``),
or a bare k3d object such as `k3d.points(...)`, which is placed on a new plot. By
default, the plot fills its container; pass `height` to `k3d.plot()` to use a fixed
pixel height instead (a `height` of 512, the k3d default, is treated as unspecified).

The decorated function also gains two read-only attributes: `.value`, the value
most recently returned by the render function, and `.widget`, the rendered
`Plot` instance. Use `.widget` to programmatically update the rendered plot in
place (trait changes on the widget are synced to the browser), or to reactively
read widget state with :func:`~shinywidgets.reactive_read`.

Use :func:`~shinywidgets.output_widget` in the UI to hold the output.

Parameters
----------
width
A CSS length unit (e.g., ``"400px"`` or ``"100%"``) for the output's width.
height
A CSS length unit for the output's height. When ``None`` (the default), the
output is designed to fill the height of its parent container (see ``fill``
and ``fillable``).
fill
Whether the output should grow or shrink to fit its parent container. When
``None`` (the default), this is ``True`` if ``height`` is not specified.
fillable
Whether the output container should fill available space (scrolling its
contents on overflow). When ``None`` (the default), this is ``True`` if
``height`` is not specified.
"""

def _as_widget(self, value: ValueT) -> K3dPlot:
try:
# Import the factory from its own module: `k3d.plot` is also the name of
# the submodule defining Plot, which is what type checkers resolve
from k3d.factory.plot import plot as new_plot
from k3d.objects import Drawable
except ImportError:
raise ImportError(
"Install the k3d package to use render_k3d with shinywidgets (pip install 'shinywidgets[k3d]')."
)

# A drawable is a model-only widget that draws nothing by itself, so (like
# its Jupyter display does) put it on a new plot
if isinstance(value, Drawable):
plot = new_plot()
plot += value
# k3d annotates `+=` as returning its PlotObjectsMixin, but it returns self
return cast(K3dPlot, plot)
return cast(K3dPlot, super()._as_widget(value))


class render_ipycytoscape(render_widget_base[ValueT, CytoscapeWidget]):
"""
Reactively render an [ipycytoscape](https://github.com/cytoscape/ipycytoscape)
Expand Down
16 changes: 16 additions & 0 deletions shinywidgets/_render_widget_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,21 @@ def ipysigma_layout_defaults(widget: DOMWidget, fill: bool) -> bool:
return fill and sigma.height == "100%"


K3D_DEFAULT_HEIGHT = 512


def k3d_layout_defaults(widget: DOMWidget, fill: bool) -> bool:
from k3d.plot import Plot

# k3d.plot() always sets `height` explicitly (in pixels, default 512), so the
# default value is the only signal that the user didn't ask for a height. When
# filling, the plot's pixel height is only a flex basis (the `.lm-Widget`
# container stretches it), so no adjustment is needed.
if isinstance(widget, Plot) and widget.height != K3D_DEFAULT_HEIGHT:
return False
return fill


def itables_layout_defaults(widget: DOMWidget, fill: bool) -> bool:
# DataTables only gets a scrollable body (which is what lets the table fill
# its container instead of growing past it) when scrollY is set, so default it
Expand Down Expand Up @@ -612,6 +627,7 @@ def leafmap_layout_defaults(widget: DOMWidget, fill: bool) -> bool:
"ipysigma": ipysigma_layout_defaults,
"itables": itables_layout_defaults,
"jscatter": jscatter_layout_defaults,
"k3d": k3d_layout_defaults,
"keplergl": keplergl_layout_defaults,
"lonboard": lonboard_layout_defaults,
"jupyter_bokeh": bokeh_layout_defaults,
Expand Down
23 changes: 23 additions & 0 deletions shinywidgets/_shinywidgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ def _cleanup_session_state():
}
# BokehModel.close() is not idempotent; guard against double close.
guard_widget_close(w)
elif any(widget_pkg(cls) == "k3d" for cls in type(w).__mro__):
close_comm_on_close(w)

if any(widget_pkg(cls) == "itables" for cls in type(w).__mro__):
# itables' bundle replaces the global `$` when its module is evaluated (see
Expand Down Expand Up @@ -545,6 +547,27 @@ def close_widget(w: Widget) -> None:
del WIDGET_INSTANCE_MAP[model_id]


def close_comm_on_close(w: Widget) -> None:
"""
Make a widget's close() also close its comm.

k3d's Plot overrides close() (to clear the ipywidgets Outputs it's displayed
in) without calling Widget.close(), so its comm never closes: every re-render
would leave the old plot's comm registered here and its model in the browser.
Widget.close() is a no-op once the comm is gone, so this is safe for widgets
whose close() already closes it.
"""
close = w.close

def close_with_comm() -> None:
close()
Widget.close(w)

# Assigning to a method isn't expressible to the type checker, but patching
# the instance is the point (we don't control k3d's class)
w.close = close_with_comm # type: ignore[method-assign]


def walk_widget_tree(roots: Iterable[object]) -> Iterator[Widget]:
"""
Yield every widget reachable from `roots` through materialized synced traits.
Expand Down
51 changes: 51 additions & 0 deletions shinywidgets/static/shinywidgets.css
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,57 @@
flex: 0 0 auto;
}

/*
* k3d sets a fixed pixel height (its `height` trait) as an inline style on its inner
* container, which would overflow (and get clipped by) an output with an explicit height.
* (`[style*="height:"]` would also match min-/max-height, but output_widget() only emits
* width and height. Keep in sync with _output_widget.py.)
* Intrinsic heights (`auto`, `fit-content`, ...) are excluded, as in the bqplot rule
* above: 100% of an indefinite-height parent would replace the plot's own pixel height
* (which the flex rules below treat as its intrinsic height) and collapse the plot.
*/
.shiny-ipywidget-output[style*="height:"]:not(
[style*="height:auto"],
[style*="height: auto"],
[style*="height:fit-content"],
[style*="height: fit-content"],
[style*="height:min-content"],
[style*="height: min-content"],
[style*="height:max-content"],
[style*="height: max-content"]
)
> .k3d-plot {
height: 100%;
}
.shiny-ipywidget-output[style*="height:"]:not(
[style*="height:auto"],
[style*="height: auto"],
[style*="height:fit-content"],
[style*="height: fit-content"],
[style*="height:min-content"],
[style*="height: min-content"],
[style*="height:max-content"],
[style*="height: max-content"]
)
> .k3d-plot
> div {
height: 100% !important;
}

/*
* Likewise, a Layout(height=...) on the plot sizes only its .lm-Widget, so let k3d's
* inner container flex to it (its pixel height is just the flex basis, so the plot
* keeps that height when the widget's own height is intrinsic).
*/
.shiny-ipywidget-output > .k3d-plot {
display: flex;
flex-direction: column;
}
.shiny-ipywidget-output > .k3d-plot > div {
flex: 1 1 auto;
min-height: 0;
}

/*
* itables (DataTables): render_itables() adds the .shinywidgets-itables-fill class
* (and defaults scrollY) unless the user chose their own scrollY (see
Expand Down
Loading
Loading