Skip to content

Orbital isosurface: image export, live controls, and py3Dmol-only rendering (v0.6.0) - #43

Merged
NCCU-Schultz-Lab merged 4 commits into
mainfrom
orbexport-isosurface
Aug 5, 2026
Merged

Orbital isosurface: image export, live controls, and py3Dmol-only rendering (v0.6.0)#43
NCCU-Schultz-Lab merged 4 commits into
mainfrom
orbexport-isosurface

Conversation

@NCCU-Schultz-Lab

Copy link
Copy Markdown
Collaborator

Closes M-ORBEXPORT ORBX.1, ORBX.2 and ORBX.6, plus the py3Dmol-only routing decision and nine bugs found in live testing.

What a user gets

  • Save PNG under the isosurface viewer — captures exactly what is on screen, camera and all. Filename, DPI (72–600), and a transparent-background option that applies only to the saved file.
  • Tunable grid (Coarse → Very fine), remembered between launches.
  • Live isovalue, opacity and colour-scheme controls that update the existing viewer, so the orientation you rotated into survives.
  • The isovalue explained in chemical terms — "encloses 98.4% of the density" beside the slider, because an amplitude threshold is rarely the question anyone has.
  • Cancel for a running generation, and the molecule shown before you generate anything so the panel is never empty.

Two changes that reduce choice, deliberately

Orbital isosurfaces are py3Dmol only. Plotly downsamples the volume by construction and cannot carry the image export. Reverting is one line in _TASK_POLICY; the Plotly renderer is kept and still tested, with a test asserting it still exists so the revert stays cheap.

Vibrational and trajectory exports now come from py3Dmol — the renderer that drew them on screen. The Plotly export branch was removed rather than left unreachable, because it duplicated animation-building logic that would rot behind a flag.

These are why this is 0.6.0 and not a patch.

The measurement that shaped the implementation

Building the viewer from Python embedded the cube three times — once for addModel, once per addVolumetricData — which is ~39 MB of HTML per render at the 100³ grid. Embedding it once and driving 3Dmol from JS is what made the fine grids usable and made live controls possible, since the camera cannot survive a Python re-render (GOTCHAS: camera state does not survive an atomic HTML swap).

Bugs, and what caused them

  • Surfaces stacked instead of replacing. addVolumetricDataaddIsosurfacethis.shapes.push(...): an isosurface is a shape, not a surface, so removeAllSurfaces() iterated an empty collection. One cause for every symptom — a lower isovalue engulfed the old surface, a higher one hid inside it, and stacked translucent layers read as ever more opaque.
  • Sticky 3-D backgrounds on theme change, across the isosurface, vibrational, trajectory and Analysis-tab molecule viewers.
  • Page jumped on generate — clear_output() + display() collapsed the panel; _set_html_output already existed for exactly this.
  • Busy overlay could stick — a retry from a superseded request landing on the next viewer.

Reviewer notes

A regression I caused, twice. _PANEL_REGISTRY order is load-bearing in two ways: the first auto_select that returns True wins, and _pop_energies loads the orbital state _pop_isosurface checks. I put Isosurface last (no effect), then first (broke the dependency — "required data is missing" on results that had it). Both of my tests had encoded my assumption rather than the rule documented directly above the code. Correct order is Energies → Isosurface → Trajectory, and the dependency now has its own test.

Honest limits, stated rather than implied: DPI sets print size, not pixel count. Cancel discards the result but cannot interrupt PySCF. standalone_html was a no-op pass-through, so exports were bare fragments — now complete UTF-8 documents, which matters because they carry and .

Still open: ORBX.7 (surface finish) — 3Dmol's Lambert shading has no specular term, so "metallic" is not achievable on this renderer. Scoped with the evidence in the roadmap.

Full suite: 2116 passed, 23 skipped.

🤖 Generated with Claude Code

NCCU-Schultz-Lab and others added 4 commits August 4, 2026 16:44
Two changes that arrived together and touch the same files, so they ship
together rather than as an artificial split.

── ORBX.1: client-side PNG export ──────────────────────────────────────

A Save-PNG button inside the isosurface viewer, capturing the canvas
exactly as rotated. It lives on the viewer rather than in the control row
because it has to: pngURI() reads the live canvas, and a Python button
would need kernel -> JS -> kernel, which the kernel cannot do
synchronously.

That needed a JS -> kernel direction this codebase did not have. Route C
(M-LOGSCROLL) goes the other way. The mechanism here is the standard
ipywidgets one: JS writes into a hidden Textarea's DOM node and
dispatches an 'input' event, which the widget's own view already listens
for, so it syncs like a user typing. The button is opt-in by
construction — without a capture_class no button renders at all, so it
cannot ship somewhere with nowhere to deliver.

── ORBX.2: tunable resolution ──────────────────────────────────────────

Four grid presets (40/60/80/100 per axis), persisted via UserSettings,
with cost multipliers in the labels because the jump is steep and the
wait is what users are choosing between.

The roadmap's two-knob warning is narrower than written. Only the Plotly
path strides a cube down to a point cap; py3Dmol isosurfaces in-browser
at full resolution, so there a finer grid is visible immediately. The cap
is fixed anyway: it now scales with grid VOLUME (100^3 is 4.6x the points
of 60^3, not 1.7x) and is ceilinged at 250k, since a fallback that locks
the tab is worse than one that is smooth and slightly coarse.

No settings schema bump. The loader reads every viz key with a default,
so a v1 file written before iso_resolution existed still loads — verified
against an actual old-format file rather than assumed.

── py3Dmol-only for orbitals and vib export ────────────────────────────

User: "plotlymol is really only good for the molecule viewing windows."

ORBITAL_ISOSURFACE and VIB_EXPORT are now single-backend in _TASK_POLICY,
so preference is ignored for both. Molecule viewing still honours it, and
a test asserts the narrowing does not leak.

The router change alone was not enough for vib export:
build_vib_export_html chose its own backend independently, so it would
have kept emitting Plotly animations while the router said otherwise.

The two dead paths are treated differently on purpose. The Plotly
isosurface renderer is kept and still tested — reverting is one line, and
a test asserts the function still exists so it stays that cheap. The
Plotly vib-export branch is removed, because it duplicated
animation-building logic that would rot silently behind a flag.

Still plotlymol-only and deliberately untouched: TRAJECTORY_EXPORT. The
same principle applies to it, but it was outside the request.

── Tests ───────────────────────────────────────────────────────────────

Two caught themselves passing for the wrong reason. Mine wrote settings
JSON without _schema_version, so the loader discarded the file and
returned defaults — the "invalid value falls back" assertion passed
without the validator ever running. The existing to_dict test pinned the
exact viz dict, which is precisely what a new field should trip.

Full suite: 2056 passed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs from live testing.

── Backgrounds did not follow the theme ────────────────────────────────

py3Dmol paints the background INTO the WebGL scene at render time rather
than reading it from CSS, so nothing re-reads it on a theme toggle.
_rerender_plotly_theme already refreshed the orbital energy diagram, the
IR/UV charts, the PES plot and the molecule viewer — but not the
isosurface or the vibrational animation, which is why they stayed stale
until the user happened to regenerate one.

Both now re-render, and both stay cheap by construction: the isosurface
re-reads the cube already on disk and never re-runs cubegen. That is the
whole design constraint — cubegen is 15-30 s at the default grid and
~4.6x that at the finest, so regenerating on a theme click would make the
toggle unusable. A test asserts the re-render path cannot reach the
generate path.

The vib viewer needed a full rebuild rather than __quantuiVibSetMode.
That bridge switches frames on the existing viewer client-side — which is
exactly why the camera survives a mode change — but it never touches the
scene background, so it could not have fixed this.

── The page jumped on Generate Isosurface ──────────────────────────────

on_iso_generate did clear_output() then display() to show the
"Generating…" placeholder. That leaves the panel empty for a moment: it
collapses from ~660px to zero, the document shrinks, the browser clamps
scrollTop to the new maximum, and the returning content does not scroll
back.

The codebase had already solved this. _set_html_output swaps `outputs` in
one assignment specifically "so the browser never observes an
intermediate empty state", and its docstring cites the IR-toggle flicker
this same pattern caused. The isosurface path simply never adopted it —
four call sites converted. The one remaining bare clear_output() is a
genuine reset when a new result loads, where empty is correct.

A regression test greps for the clear-then-repopulate shape on that
output so it cannot creep back.

Full suite: 2062 passed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six controls in the isosurface accordion: isovalue, opacity, transparent
background, PNG filename, PNG dpi — plus a readout that explains what the
isovalue actually means.

── Isovalue, expressed as enclosed density ─────────────────────────────

The request was for "the amount of density contained within the
isosurface". An isovalue is an amplitude threshold on psi; the density
enclosed is the integral of |psi|^2 inside the surface. Those are
different numbers, and the cube already on disk gives the second from the
first for free — so the slider stays on isovalue (the quantity that goes
in a methods section) and a label reads "encloses 97.4% of the density"
beside it. Inverting the mapping to make the SLIDER a percentage is
possible; this way both numbers are visible.

── The appearance controls never recompute ─────────────────────────────

Isovalue, opacity and transparency are properties of the render, not of
the grid, so all three redraw the cube on disk rather than re-running
cubegen. That difference — sub-second versus 15-30 s, and ~4.6x that at
the finest grid — is the entire reason they can be sliders instead of
sitting behind an Apply button. A test asserts the redraw path cannot
reach the generate path.

All three render sites (first draw, slider redraw, theme redraw) now read
from one iso_render_options(), so they cannot disagree about what the
viewer should look like.

── Transparency is real, not simulated ─────────────────────────────────

3Dmol's WebGL context is created with alpha:true and preserveDrawingBuffer
(checked in the vendored bundle), so setBackgroundColor(colour, 0.0)
produces genuinely transparent pixels that survive pngURI(). Verified the
alpha argument is emitted, and that re-encoding the PNG to stamp dpi
preserves RGBA — flattening it there would have silently undone the whole
option, so that has its own test.

── DPI sets print size, not pixel count ────────────────────────────────

Written into the PNG pHYs chunk via Pillow. Re-encoding cannot invent
detail: at 300 dpi a 760 px capture declares itself 2.5 inches wide,
which is what makes it land correctly in Word or LaTeX. Genuinely more
pixels would mean supersampling the canvas before capture — a different
change, and better refused than implied. A failed stamp returns the
original bytes: losing metadata is mild, losing the export is not.

── Robustness found by tests ───────────────────────────────────────────

iso_render_options promised to fall back to defaults for missing widgets
but only handled missing ATTRIBUTES — a widget holding an uncoercible
value raised out and took the render with it. It now coerces to the
default's type. Likewise the filename path assumed a str.

One older test asserted the capture landed byte-for-byte; deliberate
re-encoding made that the wrong invariant, so it now compares pixels.

Also moved TRAJECTORY_EXPORT in the router test lists, missed when its
policy changed. _PLOTLYMOL_ONLY_TASKS is now empty and kept deliberately,
so restoring such a task stays a one-line change with live coverage.

Full suite: 2088 passed, 23 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes out the round of live-testing feedback on the isosurface panel.

── Live controls, one JS bridge ────────────────────────────────────────

Isovalue, opacity, colours and background now update the EXISTING viewer
rather than re-rendering it. A Python re-render replaces the viewer, so
it cannot preserve the camera (GOTCHAS: camera state does not survive an
atomic HTML swap) — the bridge saves getView()/setView() around a surface
rebuild instead. No output swap also means no collapse and no scroll jump.

That rewrite came out of a measurement: building the viewer from Python
embedded the cube THREE times — once for addModel, once per
addVolumetricData — which is ~39 MB of HTML per render at the 100^3 grid.
Embedding once and driving 3Dmol from JS is what made the finer grids
usable and the live controls possible at all.

── Bugs found by live testing ──────────────────────────────────────────

Surfaces stacked instead of being replaced. addVolumetricData routes to
addIsosurface, which does this.shapes.push(...) — an isosurface is a
SHAPE, not a surface — so removeAllSurfaces() iterated an empty
collection and removed nothing. One cause for every symptom reported: a
lower isovalue engulfed the old surface (looked like it worked), a higher
one hid inside it (looked dead), and stacked translucent layers read as
steadily more opaque, so toggling the palette brightened the surface.
Shapes are now tracked and removed by reference.

The busy overlay could stick. The bridge retries up to 2 s for its hook;
a busy(true) issued while no viewer existed kept polling and fired
against the NEXT viewer, after busy(false) had run. A sequence guard
makes only the newest call able to apply, and a fresh viewer bumps the
sequence so nothing in flight can land on it.

Transparency changed the live view. It is an export property now: the
capture hook drops the background, grabs the canvas and restores it, all
inside one synchronous call, with the restore in a finally.

── Panel behaviour ─────────────────────────────────────────────────────

The molecule is shown before the first Generate, so the panel is never
empty and the first isosurface fades in over an existing viewer. It also
lets the user orient the structure first — the camera carries across,
keyed on a scene hash so a different ORBITAL keeps the view and a
different MOLECULE re-frames.

Generate moved above the viewer, the viewer is framed like the others,
and a Cancel button abandons an in-flight generation. cubegen cannot be
interrupted, so Cancel bumps the render token: the result is discarded on
arrival and the controls return immediately.

── A regression I caused, and why ──────────────────────────────────────

Making Isosurface the default panel for geometry_opt took three tries.
_PANEL_REGISTRY order is load-bearing twice: the FIRST auto_select that
returns True wins, AND _pop_energies is what loads the orbital state
_pop_isosurface checks. I first put Isosurface last (no effect), then
first (which broke the dependency and reported "required data is
missing" on results that had it). Correct order is Energies, Isosurface,
Trajectory. Both of my tests had encoded my assumption rather than the
rule documented directly above the code; both are fixed, and the
dependency now has its own test.

── Version ─────────────────────────────────────────────────────────────

0.6.0, not a patch: plotlymol is no longer selectable for orbitals and
both animation exports changed renderer. That is a deliberate reduction
in user-facing choice, which a patch number would not communicate.

Full suite: 2116 passed, 23 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NCCU-Schultz-Lab
NCCU-Schultz-Lab merged commit db6a890 into main Aug 5, 2026
5 checks passed
@NCCU-Schultz-Lab
NCCU-Schultz-Lab deleted the orbexport-isosurface branch August 5, 2026 17:51
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.

1 participant