Repository navigation
feat: add render_pythreejs() for pythreejs 3D scenes - #324
Merged
Merged
Conversation
This was referenced Oct 26, 2026
cpsievert
marked this pull request as ready for review
October 26, 2026 22:30
cpsievert
changed the base branch from
generic-amd-externals
to
frontend-comm-open
October 26, 2026 23:09
cpsievert
force-pushed
the
render-pythreejs
branch
from
October 27, 2026 15:19
84e8b17 to
477ec81
Compare
This was referenced Oct 27, 2026
cpsievert
added a commit
that referenced
this pull request
Oct 27, 2026
…plicit target/shadow
Two-session Playwright regression tests for the shared_lifetime() known
limitation: pythreejs's JavaScript creates a default light's target/shadow
models in the first browser, and a second session referencing them waits
30s for models that never arrive ('Timeout waiting for widget model') and
the affected outputs never render. The remedy app (explicit target/shadow
built inside shared_lifetime()) renders in both sessions.
Uses render_widget (not render_pythreejs from #324) so the test runs on
this branch's base; pythreejs is added to the test dependency groups.
(With render_pythreejs, the failure was also seen to cascade to unrelated
outputs via 'Syncing error: no comm channel defined'.)
cpsievert
force-pushed
the
render-pythreejs
branch
from
October 28, 2026 00:09
24cbdd1 to
7dff484
Compare
cpsievert
changed the base branch from
frontend-comm-open
to
feat-348-shared-widgets
October 28, 2026 00:12
A render_pythreejs output in a container whose height comes from its content (plain page_fluid, or a card with no height) entered an unbounded ResizeObserver feedback loop: the inline <canvas> leaves a ~6px baseline strut below it, so the output was always slightly taller than the canvas, and sizing the canvas to the output grew the output again. The canvas grew ~400-700px/s until it hit browser texture limits and the WebGL context died. Give the canvas display:block in setupPythreejsResize, which removes the strut so sizing the canvas to the output is a fixed point (verified by a new Playwright test that fails without the fix: heights sampled at [230, 320, 416, ...] and climbing). Includes the rebuilt output.js bundle.
A DirectionalLight with default target/shadow makes pythreejs create those sub-objects in the browser, and browser-created widget models have no Python counterpart -- so each render logged TraitErrors when the browser synced the light's state back. Give the example's light an explicit target (added to the scene) and shadow (with a camera), which renders identically with a clean server log, and add a Notes section to the render_pythreejs docstring with this recipe plus the Scene(background=...) and mesh.quaternion guidance.
Every third-party widget dependency's require.config() included an empty
"shim": {} after the pythreejs sibling-module support landed; only
include the shim key when SIBLING_REQUIRE_MODULES actually has entries
for the module. Also document that the sibling path mapping is
registered globally, so a bare "three" require() from any other widget
on the page would get pythreejs's bundled three.js.
- js/src/fill.ts: re-capture the active camera on change:camera instead of only at setup, applying the current container size to a replacement camera immediately and detaching listeners on cleanup, so resizes no longer update a stale camera's aspect - _render_widget_base.py: track a pythreejs Renderer's sizing intent separately from its live width/height, so a default-sized Renderer cached with session_lifetime() keeps filling when re-rendered after the browser syncs its container size back (front-end syncs are detected via set_state); explicit user size changes still opt out of filling - Playwright: add camera-swap and cached-renderer regression tests. Harden view-identity checks to use the view root element rather than the <canvas>, which pythreejs's RendererPool recycles into new views, and synchronize the camera-swap test on the swap's actual round-trip (via a MutationObserver) so a viewport resize can't race ahead of the change:camera message
- Keep camera-aspect ownership per camera across view lifetimes (a module-level WeakMap), so an explicit user-set aspect survives a re-render of a cached Renderer instead of being adopted as the new view's managed baseline and overwritten on resize - Only size the canvas to a fixed-size output when the output has an explicit height, so output_widget(fillable=False) without a height leaves a default-sized Renderer at its own 200x200 size - Add Playwright regressions for both cases and rebuild the bundle
Browser-created widgets sync to Python now (#346), so the old 'don't sync / TraitError' phrasing is wrong. The explicit target/shadow workaround still earns its keep: browser-created widgets live until the session ends, so without it every re-render leaks 3 models per light (and logs a transient console error).
cpsievert
added a commit
that referenced
this pull request
Oct 28, 2026
…plicit target/shadow
Two-session Playwright regression tests for the shared_lifetime() known
limitation: pythreejs's JavaScript creates a default light's target/shadow
models in the first browser, and a second session referencing them waits
30s for models that never arrive ('Timeout waiting for widget model') and
the affected outputs never render. The remedy app (explicit target/shadow
built inside shared_lifetime()) renders in both sessions.
Uses render_widget (not render_pythreejs from #324) so the test runs on
this branch's base; pythreejs is added to the test dependency groups.
(With render_pythreejs, the failure was also seen to cascade to unrelated
outputs via 'Syncing error: no comm channel defined'.)
cpsievert
force-pushed
the
render-pythreejs
branch
from
October 28, 2026 00:32
7dff484 to
e8970d7
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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_pythreejsfor pythreejs 3D scenespythreejs is the ipywidgets bridge to three.js: scenes are built from meshes/cameras/lights and displayed through a
pythreejs.Rendererwidget. It's also the foundation other packages build on (e.g., pyvista'sjupyter_backend='pythreejs'). With this PR, Shiny apps get a dedicated renderer:Highlights:
Rendererdraws into a canvas fixed to its_width/_heightpixel traits (200×200 default), and three.js never updates the camera's aspect ratio on resize. A newWIDGET_BEHAVIORS["pythreejs"]entry injs/src/fill.tsobserves the output container and drives the canvas size and camera aspect from it (both are synced traits, so the Python side sees the same values). An explicitRenderer(width=..., height=...)opts out and is respected (a 200×200 default can't be told apart from an explicit one, so it's treated as unset — same convention as maplibre's 400px). An explicitoutput_widget(height=...)sizes the canvas to the output instead of overflowing it.position/quaternion/zoomback to Python when anOrbitControlsdrag ends, soreactive_read(scene.widget.camera, "position")re-reads the camera after every interaction (covered by a Playwright test that orbits with the mouse and watches the Python-side text update).index.jsrequire()s a siblingthreeAMD module (in classic Jupyter,extension.jsmaps it), and the sibling is a UMD bundle that only setswindow.THREEunder shiny's define-shim. The require.jsmap+shimconfig for this now comes from the generic AMD-externals resolution in feat: resolve widget bundles' AMD externals generically #343 (which this PR is stacked on): thethreeexternal is discovered from the bundle'sdefine([...])header, served under an alias scoped tojupyter-threejs(so it can never shadow another package's three.js build, unlike a global path mapping), and theTHREEshim is sniffed from the UMD wrapper automatically. Verified with all external requests blocked: the scene renders identically offline, and zero CDN-fallback log lines appear.Notes
fix/pythreejs-preview-loopbranch (issue pythreejs hangs the session: infinite Preview widget loop via _repr_mimebundle_ side effects (ipywidgets 8) #244: pythreejs's display hook created preview widgets, looping widget creation and hanging sessions) is already fixed onmain(init_shiny_widgetonly calls_repr_mimebundle_()for plotly/ipympl), so the renderer needs no special-casing. Its regression test (test_widget_display.py) still passes.Renderer(width=400, height=300)keeps a 400×300 canvas;output_widget(height="250px")yields a 250px-tall canvas.aspectif the user changes it from either side (it only updates while the trait holds a value the fill behavior itself set).uv.lockis intentionally untracked in this repo (ci: test Python 3.15 (drop 3.10), untrack uv.lock, drop py- make prefix #319), so onlypyproject.tomlchanged.jupyter_backend='pythreejs'returns apythreejs.Renderer, which this renderer handles directly.Verification
make check-format✅,make pyright✅ (0 errors),make test-unit✅ (249 passed, 3 skipped, 1 xfailed)_as_widgetpassthrough, layout defaults (default fills / explicit size opts out / layout height opts out / 200×200 treated as unset), require.js sibling-module shim config.tests/playwright/render_pythreejs/(6 tests, ran the file 5× clean): visible WebGL render with no console/page errors; fill measurements above; explicit sizes respected; camera orbit →reactive_readround trip; re-render lifecycle (views disposed, model count settles).js/src/fill.ts,js/src/output.ts,_dependencies.py) also exercised by existing suites:render_matplotlib_fill,fill_to_fixed_rerender,render_maplibre(7),ipyleaflet_marker_click,test_dependencies.py— all pass. (render_lonboardcan't run locally on Python 3.15 — lonboard has no 3.15 wheels, an existing marker exclusion unrelated to this change.)cd js && yarn buildleaves no diff after commit;examples/pythreejs/app starts and renders.Part of #278