Skip to content

feat: add render_pythreejs() for pythreejs 3D scenes - #324

Merged
cpsievert merged 9 commits into
feat-348-shared-widgetsfrom
render-pythreejs
Oct 28, 2026
Merged

cpsievert merged 9 commits into
feat-348-shared-widgetsfrom
render-pythreejs

Conversation

@cpsievert

@cpsievert cpsievert commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #356 (shared, process-global widgets; replacement for the auto-merged #351), on top of #346 (browser-created widget comms) and #343 (generic AMD-externals resolution). The _dependencies.py changes described below live in #343; this PR adds the pythreejs renderer. #329 (ipyvolume) is stacked on this PR. Merge order: #343 → #346 → #356 → this PR → #329.

Add @render_pythreejs for pythreejs 3D scenes

pythreejs is the ipywidgets bridge to three.js: scenes are built from meshes/cameras/lights and displayed through a pythreejs.Renderer widget. It's also the foundation other packages build on (e.g., pyvista's jupyter_backend='pythreejs'). With this PR, Shiny apps get a dedicated renderer:

@render_pythreejs
def scene():
    camera = p3.PerspectiveCamera(position=[0, 0, 3])
    return p3.Renderer(
        scene=p3.Scene(children=[p3.Mesh(...), p3.AmbientLight()]),
        camera=camera,
        controls=[p3.OrbitControls(controlling=camera)],
    )

Highlights:

  • Fill that actually works. A pythreejs Renderer draws into a canvas fixed to its _width/_height pixel traits (200×200 default), and three.js never updates the camera's aspect ratio on resize. A new WIDGET_BEHAVIORS["pythreejs"] entry in js/src/fill.ts observes 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 explicit Renderer(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 explicit output_widget(height=...) sizes the canvas to the output instead of overflowing it.
  • Camera round trip. pythreejs syncs camera position/quaternion/zoom back to Python when an OrbitControls drag ends, so reactive_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).
  • Offline-safe JS loading. pythreejs's nbextension index.js require()s a sibling three AMD module (in classic Jupyter, extension.js maps it), and the sibling is a UMD bundle that only sets window.THREE under shiny's define-shim. The require.js map + shim config 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): the three external is discovered from the bundle's define([...]) header, served under an alias scoped to jupyter-threejs (so it can never shadow another package's three.js build, unlike a global path mapping), and the THREE shim 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

  • The old fix/pythreejs-preview-loop branch (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 on main (init_shiny_widget only calls _repr_mimebundle_() for plotly/ipympl), so the renderer needs no special-casing. Its regression test (test_widget_display.py) still passes.
  • Measured fill behavior (headless Chromium, 1280×720 viewport, 420px-tall card): canvas renders at exactly 1222×386, matching the output container to within 1px; narrowing the viewport 900→600px shrinks the canvas with the container. Renderer(width=400, height=300) keeps a 400×300 canvas; output_widget(height="250px") yields a 250px-tall canvas.
  • WebGL renders fine in headless Chromium (SwiftShader) with no extra launch flags; the Playwright test asserts the red cube's composited pixels, not just a non-empty canvas.
  • Re-render lifecycle: repeated re-renders dispose old views and the browser's model count settles back to the initial one (no WebGL context or model accumulation).
  • The camera-aspect management stops touching aspect if the user changes it from either side (it only updates while the trait holds a value the fill behavior itself set).
  • pythreejs 2.4.2 is pure Python and resolves for Python 3.10–3.14 (it's also installed and tested here on 3.15); no environment markers needed. uv.lock is intentionally untracked in this repo (ci: test Python 3.15 (drop 3.10), untrack uv.lock, drop py- make prefix #319), so only pyproject.toml changed.
  • Task B8 (pyvista) can stack onto this branch: pyvista's jupyter_backend='pythreejs' returns a pythreejs.Renderer, which this renderer handles directly.

Verification

  • make check-format ✅, make pyright ✅ (0 errors), make test-unit ✅ (249 passed, 3 skipped, 1 xfailed)
  • New unit tests: renderer export, _as_widget passthrough, layout defaults (default fills / explicit size opts out / layout height opts out / 200×200 treated as unset), require.js sibling-module shim config.
  • New Playwright suite 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_read round trip; re-render lifecycle (views disposed, model count settles).
  • Shared-code changes (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_lonboard can't run locally on Python 3.15 — lonboard has no 3.15 wheels, an existing marker exclusion unrelated to this change.)
  • cd js && yarn build leaves no diff after commit; examples/pythreejs/ app starts and renders.

Part of #278

This comment was marked as resolved.

This comment was marked as resolved.

@cpsievert
cpsievert changed the base branch from main to generic-amd-externals October 26, 2026 22:20
@cpsievert
cpsievert added this pull request to stack #345 October 26, 2026 22:28
@cpsievert
cpsievert marked this pull request as ready for review October 26, 2026 22:30
@cpsievert
cpsievert removed this pull request from stack #345 October 26, 2026 23:09
@cpsievert
cpsievert changed the base branch from generic-amd-externals to frontend-comm-open October 26, 2026 23:09
@cpsievert
cpsievert added this pull request to stack #347 October 26, 2026 23:09
@cpsievert
cpsievert removed this pull request from stack #347 October 27, 2026 22:45
@cpsievert
cpsievert added this pull request to stack #352 October 27, 2026 22:46
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
cpsievert removed this pull request from stack #352 October 28, 2026 00:10
@cpsievert
cpsievert changed the base branch from frontend-comm-open to feat-348-shared-widgets October 28, 2026 00:12
@cpsievert
cpsievert added this pull request to stack #357 October 28, 2026 00:13
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
cpsievert merged commit f6cb1aa into main Oct 28, 2026
5 checks passed
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