UI Shell Architecture
8.1 Viewer Loop
ViewerApplication(ui/shell/src/runner.rs) owns the winit event loop: under winit 0.30 it implementswinit::application::ApplicationHandler(resumed/window_event/device_event/about_to_wait) and is driven byevent_loop.run_app(&mut app).ViewerLoop(ui/shell/src/viewer_loop/runtime/state.rs, re-exported viamod.rs) owns the renderer, egui context, camera controller, and complete viewer state.ViewerApplicationforwards eachApplicationHandlercallback toViewerLoop’s matching method (window_event/device_event/about_to_wait), which translate winit events into UI interactions.- The event loop is decomposed into specialised modules:
key_actions/dispatches keyboard shortcuts viaKeyActionContext.menu_actions/mirrors the GUI menu tree (themenu/module) and triggers async loads, toggles, and exports.progress_overlay.rs,fragment_palette.rs,change_element/,smiles_builder_dialog.rs,conformer_dialog.rs, andresidue_mutation_dialog.rs,residue_protonation_dialog.rs,terminal_cap_dialog.rs, anddisulfide_dialog.rsisolate complex overlays/dialogs for readability.dialog_context.rsprovides a typed wrapper around egui state and viewer handles to simplify passing dependencies into modal dialogs.
- When introducing new interaction families, prefer a dedicated module under
viewer_loop/with a context struct that holds only the state you need. Wire the helper intoViewerLoop::window_eventbeside the existing keyboard/menu helpers to minimise borrow complexity and keep diffs localised. - Rendering updates go through
rebuild_render_data(orchestration.rs) which regeneratesRenderSceneDataand refreshes measurement overlays.request_redrawandrequest_redraw_with_titlehandle winit redraw scheduling while keeping the window title up to date with scene metadata and FPS counters.
8.1.1 Idle companions
The pixel cats that wander in when the viewer sits idle live in ui/shell/src/viewer_loop/idle_companions/:
mod.rsholds the state, the camera motion (a bat pushes the molecule, a head-bump spins it) and restore, and the drawing.planner.rsis the pure planner and simulation: visits, greetings, kittens, perching on the molecule, fleeing. It touches neither egui’s painter nor the camera, so behaviour is testable without a window.sprites.rsloads the atlases and holds the metadata contract with the generator.
The runner calls update() each tick and note_activity() on every window event other than a redraw; the overlay pass (viewer_loop/runtime/redraw/overlays.rs) calls draw() with the projected atoms. The camera is snapshotted when the companions start and restored on any input. Only the camera moves; the scene is never edited.
The sprites are generated, never hand-edited:
conda run -n orbitron-dev python scripts/generate_idle_companions.py [--preview DIR]It rigs the cats and writes ui/shell/assets/idle_companions/: <coat>.png, kitten_<coat>.png, emotes.png and companions.json. --preview also writes one GIF per action. The generator refuses any frame that touches the top, left or right edge of its frame, and a test in sprites.rs checks the same on the committed atlases.
8.2 UI State & Panels
UiState(ui/shell/src/ui_state/base.rs,ui/shell/src/ui_state/mod.rs) is the central model for persistent viewer state and is composed from focused sub-modules:SelectionUiState(ui/shell/src/ui_state/selection_state.rs) for highlights, history, and selection tools.PlacementUiState(ui/shell/src/ui_state/placement_state.rs) for edit-mode placement and change-element scope.ViewportUiState(ui/shell/src/ui_state/viewport_ui_state.rs) withViewUiStateandCameraUiState(ui/shell/src/ui_state/view_state.rs,ui/shell/src/ui_state/camera_state.rs).StyleUiState(ui/shell/src/ui_state/style_ui_state.rs) housing appearance presets and theme settings.ChromeUiState(ui/shell/src/ui_state/chrome_ui_state.rs) for panel visibility and dialogs.MarkupUiState(ui/shell/src/ui_state/markup_ui_state.rs) combining labels and annotations.OverlayUiState(ui/shell/src/ui_state/overlay_ui_state.rs) plus background overlay jobs. unified panel visibility, selection history, measurement buffers, trajectory playback, theme, orbital settings, and annotations.The remaining fields cover the rest of the viewer:
analysis(AnalysisPanelState),measurements(MeasurementController),settings,status,runtime,trajectory,edit,export,bundle,summaries,console, and the two the per-frame sync must not clobber,requests(PendingRequests) anddialogs(DialogUiState).Each frame the panels mutate a clone of
UiState, andsync_ui_state_from_temp(ui/shell/src/orchestration/sync.rs) copies it back field by field. A new field onEditUiStateorPlacementUiStatethat is not listed there is discarded every frame, which reads as a checkbox that will not stick. The regression testedit_settings_survive_the_per_frame_clonein that file uses exhaustive struct literals, so an unhandled field breaks the compile rather than the behaviour.Theme settings expose bond-specific controls (
bond_thickness,bond_inactive_alpha,bond_highlight_scale,bond_lane_spacing,bond_glossiness) that feed intoapply_theme_stylesfor immediate renderer updates.the per-frame redraw (driven from
ViewerLoop::about_to_wait) samplesegui::Context::available_rect()each frame and passes the resulting viewport to both the renderer and input handlers so docked panels reserve space instead of overlaying the 3D scene; picking and gizmo drags clamp to that rect.Export state (
image_export_settings,image_export_profile,export_custom_presets,batch_export_profiles,batch_export_customs) includes colour-profile metadata, explicit format flags (PNG/TIFF/JPEG/SVG/PDF/EPS), theme presets for export-only styling, and post-processing controls (exposure/contrast/saturation/vignette/grain/bloom/sharpen). The export dialog reads/writes those fields, enforces per-format transparency rules, and shares the queue/batch export helpers with session persistence so saved sessions remember which presets were staged.Annotation overlays use
orbitron_viewer_core::ui_state::AnnotationStateinsideMarkupUiState(ui/shell/src/ui_state/markup_ui_state.rs) and are rendered viaviewer_loop/annotation_overlay.rs. The Annotations panel (panels/annotations.rs) edits text/arrow items; export rendering composites them inexport/rendering/raster/overlays.rs.An endpoint is an
AnnotationAnchor:Screen(normalised viewport coordinates, as it always was),Atom, orWorld, the latter two with a normalised screen offset. Both drawing paths resolve an anchor through the sharedutil::annotation_anchor::resolve, which projects through the render data rather than the scene graph — so an anchor follows what is actually drawn, including the current trajectory frame, and an atom that is not drawn resolves toNoneand draws nothing. Sharing the resolver is what keeps the viewport and the exported image from disagreeing about where an annotation sits.Each frame the drawing path records where both endpoints landed in
AnnotationItem::last_resolved(aResolvedEndpoints,#[serde(skip)], one frame stale by the time the panel reads it), which is what lets Detach leave the annotation where the user can see it and lets a drag on an anchored item adjust its offset instead of its position. An arrow’s head and tail anchor independently; the panel drives both through oneanchor_rowhelper so they cannot drift apart in behaviour. That helper needs the scene, to turn a selection into a centroid for aWorldanchor, which is whyrender_annotations_paneltakes a&SceneGraph. The panel renders at the top of theUnifiedTab::Contextcard, aboverender_context_section:UnifiedTab::Annotationsexists but is in neither branch ofsections_for_mode, so the Scene card is the only home, and underneath the unit cell, the cartoon settings and the metadata the editor fell off the bottom of the window.Selection tooling lives on
UiState(active selection tool, history, highlights). There is no longer a Selection panel: it was dissolved into the toolbar, which owns the tool picker, the selected count, and Clear, while per-atom labels and the PDB structure controls moved into Appearance (see the comment inpanels/unified.rs). Viewport drag handling lives inviewer_loop/runtime/window_events/selection_drag.rs(right-drag box/lasso). The selection tool can also be cycled viaShift+S(edit mode) and is surfaced in the Selection menu. Edit commands invoked from panels use the edit helpers to emit status toasts for user feedback.Edit placement state lives on
UiState(seePlacementUiState). The Edit panel toggles placement, the change-element dialog supports a periodic table picker, andChangeElementScopeseparates placement-only updates from selection-only edits so “Choose…” doesn’t mutate highlighted atoms.The Edit panel owns the molecular charge/multiplicity controls. Menu actions open the SMILES, conformer, residue-mutation, side-chain-repair, alternate-location, water/ion-cleanup, terminal-cap, residue-protonation, and disulfide dialogs. Conformer selection previews coordinates without mutating history; accepting applies one
ApplyConformerCommand, while cancel restores the base scene. Residue mutation uses the same detached-preview pattern and shows the measured backbone angles, selected Top8000 bin, rotamer probability, chi angles, and geometric clash score before applying one exactMutateResidueCommand. Missing-side-chain repair adds source-fragment RMS and maximum displacement, missing-atom and explicit-hydrogen changes, and clash counts before and after. Choosing a row shows its complete detached scene; Apply records oneRepairSideChainCommand. Alternate-location editing previews one residue-local keep-only or delete operation. It reports shared atoms, affected atom names, location labels, and incident bonds before applying oneEditAlternateLocationCommand. Water/ion cleanup previews an exact whole-residue All/Within/Beyond match, can return the matches to ordinary atom selection, or applies oneRemoveWaterIonResiduesCommand. The live viewport always renders the same detached scene summarized by the dialog. Terminal capping previews the assigned ACE/NME residue, removed atoms, charge change, and heavy-atom geometry before one exactAddTerminalCapCommand. Residue protonation follows the same detached-scene pattern. It shows the explicit state, acidic-oxygen tautomer, residue and molecular formal charges, and removed or added hydrogens before one exactSetResidueProtonationCommand. Disulfide editing previews the two author residue sites, minimum-image SG distance, hydrogen changes, SSBOND state, and molecular formal charge before applying one exactEditDisulfideCommand. While that modal preview is open, the redraw path dismisses other edit dialogs and palettes before rendering. This prevents a concealed operation from receiving a click through the disulfide window.Presentation mode is a
boolatUiState.runtime.presentation_mode(ui/shell/src/ui_state/runtime_state.rs), read by the redraw setup step. When toggled it hides the toolbar/status bar/unified panel, shrinks panel chrome, and bumpstheme.label_font_size/label_outline_widthbefore drawing so demos keep atoms legible even on secondary displays. Saved sessions restore the mode throughSessionState::presentation_mode; a new session resets it to the default.
- For the user-facing walkthrough of these panels see the Graphical Viewer page of the User Guide.
- Panels are grouped by feature domain:
panels/contains egui layouts for analysis (including the unified Surfaces tab that replaced the old orbital-visualisation panel), tasks, and status overlays. The Tasks view owns method/basis/solvation provenance and SCF convergence plots; Populations owns computed bond-order tables; Bands & DOS owns the linked first-Brillouin-zone window.panels/mod.rsties them together. The toolbar lives intoolbar/and defines theToolbarActionenum (toolbar/mod.rs). Beyond the render-mode switcher it now owns the selection tool picker, the selected-atom count, and Clear (ToolbarAction::ClearSelection, intoolbar/session.rs) — the controls that used to be a Selection panel — plus camera stepping viaToolbarAction::{ZoomIn, ZoomOut}.ui/shell/src/appearance.rs,ui/shell/src/tasks.rs,ui/shell/src/fragments.rs, and theui/shell/src/measurement/module house feature-specific controllers invoked from the panels. They sit at the crate’s top level, not underpanels/, which has its ownappearance/andmeasurements/directories.ui/shell/src/preferences.rsrenders the “Preferences” window (preferences_window), with sections for “File loading limits” and “Symmetry”, persisting configuration, and pushing toast notifications back intoUiState.- Selection logic is shared via the
viewer_state/module —push_historyanddescribe_gaussian_calcinviewer_state/helpers.rs,focus_selectioninviewer_state/camera.rs— so the keyboard handler, panels, and background tasks remain in sync. - Edit actions live under
viewer_state/edit/. Molecular and atom-property actions remain inactions.rs; periodic cell transforms and slab commands are grouped inactions/cell.rsand retain the same public menu-action API.
- Camera interactions originate from
camera.rs(CameraController::update_camera) and feed into event handling utilities inevent_handlers.rs.
8.2.1 Analysis panel: atom-coloring halo system
The Analysis panel went through a significant redesign in early 2026. The user-facing description is in the User Guide §3.6; here are the moving parts:
Halo overlay rendering. Every atom carries a per-instance halo: vec4 slot on AtomInstance (core/render/src/data_builder/types.rs). When halo.a > 0, the atom shader (core/render/src/renderer/shaders/atom.wgsl) paints a sign-aware rim glow over the element color: red rim for positive values, blue for negative, intensity proportional to halo.a. The shader uses a mix(final_color, halo.rgb, blend) with a falloff that combines disc (dist_sq^3) and silhouette (1 - |dot(view_dir, world_normal)|) terms — concentrated at the rim but with a 0.25 base tint floor so high-magnitude atoms read clearly even at the disc center. Element color stays at the disc center so identity (which element) and value (charge / contribution) read independently.
Scheme state lives on AnalysisPanelState.population.halo: AtomHaloState (core/appearance/src/halo_scheme.rs, moved from viewer/core 2026-09-17 so the CLI can share it too). The AtomColorScheme enum picks among:
NoneMullikenCharges/LowdinCharges/HirshfeldCharges/NaturalCharges/AptCharges— backed byElectronicStructure.{mulliken,lowdin,hirshfeld,natural,apt}_population.MullikenSpinDensity— the signed spin population on the samemulliken_populationentries, for an open-shell run.AtomicSphereCharges/AtomicSphereSpinDensity— net charge and signed moment fromElectronicStructure.atomic_sphere_population, the sphere-integrated per-site values plane-wave codes report.BaderCharges— basin net charge, from the Bader population the analysis state holds rather than fromElectronicStructure, because the partitions differ: basins fill the cell and sum to zero, spheres do not.SceneMoContribution { mo_index, spin }— Σ|c|² per atom signed by which lobe (positive vs negative coefficient sum) dominates.NboContribution { family, index }— same convention but pulled fromNboWorkspace.signed_atom_contributions_for(family, index)since NBO data lives outside the backbone scene graph.BandEdgeContribution { edge }— per-atom weight at a band edge, precomputed when the band structure is absorbed because the projections can come from aPROCARthat never reaches the scene.
AnalysisPanelState::recompute_atom_halo(scene) fills in population.halo.values for the charges and SceneMo schemes. Its NBO branch returns nothing, because NboWorkspace is an io-pipelines type (orbitron_io_pipelines::formats::nbo::NboWorkspace) that the backbone scene never carries. The per-atom values are staged instead by the viewer-loop dispatcher (ui/shell/src/viewer_loop/runtime/redraw/nbo.rs), which calls set_atom_halo_scheme and then signed_atom_contributions_for(family, index); panels/analysis/nbo.rs only emits the action.
The scene-derived half of that computation — everything except the four families that need a host analysis (Bader, atomic-sphere, band-edge, NBO) — also lives in core/appearance now, as halo_values_from_scene (core/appearance/src/halo_values.rs), so it is shared by viewer/core and the CLI rather than living only behind the desktop. orbitron render --appearance reads it too (automation/cli/src/handlers/commands/render/presentation.rs), so a bundle recorded with a halo scheme now redraws that halo from the CLI, not only the desktop.
Rendering pipeline. apply_halo_overlay(data, halo_state) in ui/shell/src/render_helpers.rs walks halo_state.values and writes each atom’s halo slot via apply_atom_halos() (core/render/src/data_builder/halos.rs). It runs in DesktopSteps::after_build (ui/shell/src/orchestration/render.rs), the desktop’s hook into the shared view assembly described in §8.2.2, and only when the active scene is an output. (The old apply_charge_colors step was removed — atom coloring now flows entirely through the halo overlay.)
Render cache invalidation. RenderCacheKey (ui/shell/src/render_cache/key.rs) hashes analysis.population.halo so changing the scheme invalidates the cache. The hashing function (hashing.rs::hash_atom_halo) covers scheme + per-atom values + max_abs + colors + max_opacity. Without the cache key trip, apply_halo_overlay would be skipped on the next redraw. The same rule applies to every render-affecting view flag: any new field that changes RenderSceneData must be added to RenderCacheKey (e.g. focus_dim_unselected, atoms_only_for_selection) or toggling it hits a stale cache entry and appears dead.
Render rebuild trigger. Activating a halo from any tab must set render_data_changed = true in AnalysisPanelActions (ui/shell/src/panels/analysis_panel.rs). The viewer-loop dispatcher (viewer_loop/runtime/redraw/panels/mod.rs) maps that to should_rebuild_scene = true. Without this trip the render data isn’t rebuilt and the halo never paints.
UI surfaces. No standalone “Atom Coloring” tab — entry points live in their natural homes:
- Charges tab (
panels/analysis/population/) — per-scheme “Show as halo” / “Disable halo” buttons.apply_population_actioninactions.rssets the halo scheme and callsrecompute_atom_halo. - Orbitals tab (
panels/analysis/molecular.rs) — per-MO collapsible has a “Show as halo” button that resolves the spin block (Up/Down/Total) fromn_alphaand the global MO index. - NBO tab (
panels/analysis/nbo.rs) — “Show as halo” on the selected NBO orbital. Action isNboAction::ShowAsHalo { family, index }; the dispatcher maps the ioOrbitalFamilyto the ui-stateNboFamilyTagand stages the per-atom values. - Slice section (
panels/analysis/slice.rs, rendered byrender_slice_sectioninpanels/analysis/surfaces_tab/mod.rs) — a 2D MO/density contour on a plane through 3 picked atoms; gated on a parsed basis set. It is a section inside the Surfaces tab, not a tab of its own:AnalysisSubtab::Sliceinviewer/core/src/ui_state/analysis.rssurvives only so old saved layouts load, andrelocated_subtabinpanels/analysis_panel.rsredirects it to Surfaces. Distinct from the halo schemes (it draws its own heatmap, not an atom halo). - Halo appearance expander (
panels/analysis/halo_appearance.rs) — color pickers + max-opacity slider. Each tab callssuper::render_halo_appearance(ui, ui_state)at the bottom; the helper short-circuits when no halo is active.
Surfaces panel (panels/analysis/surfaces_tab/) is independent of the halo system — it manages 3D isosurface meshes via SurfaceEntry { source: SurfaceSource, render: bool, mesh: ... } (viewer/core/src/ui_state/surface.rs). reconcile_surface_entries walks the scene’s electronic structure + cube datasets + NBO workspace each frame and populates one row per source. “Compute selected” runs the appropriate generator (cube → marching cubes, SceneMo → convert_fchk_basis + generate_nbo_orbital_meshes_with_basis, NBO → workspace coefficients + same generator).
SurfaceSource::Molecular is the exception to every sentence above, and following the rebuild path with it breaks the panel. The first three variants (SceneMo, NboOrbital, CubeDataset) name something the file contains, so a row for each can be re-derived from the scene on every tab open — which is what reconciliation does, and why an entry caches no coefficients. A Molecular(MolecularSurfaceSpec) row is not derived from anything: the user asked for it, and its parameters (probe radius, kind, resolution) are part of its identity. Three consequences:
- Reconciliation carries molecular rows through rather than rebuilding them (
analysis_state.rs). It runs on every tab open, so without that branch a user’s surface silently disappears the moment they look away. - Their cache key mixes in a visible-atom digest, because a molecular surface depends on which atoms are drawn, not only on the scene. That is why
current_hashtakes aSurfaceInputscarrying that digest alongside the scene digest — and why it is mixed in for molecular rows only. Hiding a chain must not send every orbital stale. - The resolved atom ids travel on the
SurfaceRequest. The compute runs off the UI thread, and re-resolving them in the worker could disagree with what the cache key was built from.
One more trap in the same hash: do not hash a raw float that a widget writes back. color_edit_button_rgb round-trips its value through HSV and stores the result on the frame it is drawn, moving a channel by one ulp with no interaction at all. Because SurfaceEntry::current_hash hashed the raw colour, expanding any surface row marked its mesh stale and the next Compute redid correct work. Colour now enters the hash quantised to eight bits per channel.
8.2.2 Representation groups
core/appearance/src/groups.rs holds RepresentationGroup: a named atom set with its own visibility, opacity, color, coloring mode and a GroupRepresentation (Default / Cartoon / BallAndStick / SpaceFill / Hidden). effective_atoms returns the frozen atoms set, or re-evaluates selection_expr against the current scene when the group is live — the live path goes through orbitron_query::{parse_expression, evaluate_selection}, the same parser the CLI’s orbitron select uses.
A group’s representation is an input to the builder, not a patch on its output. groups::representation_plan(scene, base_mode, groups) resolves every group to a RepresentationPlan — one base RenderMode plus the atoms that depart from it — and build_scene_data_with_representation takes that plan. This is the direction the data has to flow: a post-process over finished RenderSceneData can hide a bond or shrink an atom, but it cannot add back a bond the builder never emitted, so a ball-and-stick group inside a space-filling scene had no sticks. The plan travels on RenderSceneData afterwards, because apply_theme_styles runs later and is the last word on bond thickness — a Sticks atom is a cap on a cylinder and has to be resized to match it, and a plan passed as a separate argument to that function could disagree with the data it was describing. RepresentationPlan’s fields are private for the same reason; construct with uniform and set, read with mode_for, has_sticks and draws_any_bonds.
Cartoon and Hidden stay in the post-process. They remove atoms from the base scene rather than choosing how one is drawn, which is genuinely subtractive.
A group can also carry its polar contacts. polar_contacts on the group, orbitron_appearance::contacts for the drawing, and orbitron_query::selection_polar_contacts for the search. The detection is a heavy-atom criterion — N/O within 3.5 A, at least one able to accept, and nothing closer than five bonds along the structure — because a crystal structure has no hydrogens and selection_hydrogen_bonds correctly finds nothing in it. The five-bond floor is measured rather than chosen: a carboxylate’s own two oxygens are 2.2 A apart and three bonds apart, and consecutive backbone nitrogens are 2.9 A apart and four, so without it every carboxylate and every peptide bond in the structure gets a dash across itself.
apply_polar_contacts reads what is drawn back out of RenderSceneData rather than taking a hidden set from its caller, so it cannot be handed a stale one — which also means it has to run after the chain, alternate-location and group filters. The dashes carry atom_ids: None, so clicking one selects nothing: a contact is not a bond.
This is a different thing from CartoonSettings::show_hbonds, which draws the backbone hydrogen bonds inside the ribbon pipeline (core/ribbon/src/hbond.rs) and only for residues tagged helix or sheet. That one decorates a fold; this one describes a site, and neither substitutes for the other.
A group can also carry the labels. label_residues on the group, and groups::atoms_labelled_by_groups returns the union over every labelling group that is actually drawn — an invisible or Hidden group labels nothing, because the label would point at empty space. The shell unions that with the live selection in orchestration/render_filters.rs::residue_label_atoms, which DesktopSteps supplies to the shared view assembly as ViewSteps::residue_label_atoms (the default labels only the groups’). It returns an Option: None means nothing asked for residue labels and the per-atom element labels stand, while an empty Some means the mode is on with nothing selected, which draws no labels at all. Those are different states and collapsing them puts an element symbol on every atom of a protein. The field exists because residue labels used to hang off the transient selection only, so a figure lost them on the next click; atom_label_mode was also never written into a session, and is now.
What a group does to the cartoon is orbitron_appearance::cartoon_scene. cartoon_hidden_atoms picks out the atoms a group draws instead of the ribbon (via the shared suppresses_cartoon), scene_without trims them and any hidden chains out of the scene, apply_group_colors writes the groups’ per-residue colours into CartoonSettings::color_overrides, and prepare does all three. The desktop redraw, the delta export and orbitron render call it — before the move each had assembled its own version of the sequence, and the CLI had assembled none of it, so it drew a ribbon straight through an active site a --group had opened up.
Groups reach the atoms through orbitron_appearance::view::assemble_view (core/appearance/src/view.rs), the one sequence the desktop, the CLI render paths and the web viewer all call. It builds RenderSceneData with the groups’ representation plan, applies the theme, derives the cell and periodic overlays, removes the atoms of hidden chains and of alternate conformers not shown, runs apply_group_filters, labels residues, and draws polar contacts. A viewer adds its own steps through the ViewSteps trait (after_build, after_theme, after_overlays, after_visibility, residue_label_atoms) rather than forking the order. The desktop’s are DesktopSteps in ui/shell/src/orchestration/render.rs: atom labels, the halo overlay and the frozen-atom halo after the build; measurement highlights and interaction overlays after the theme; focus dimming and atoms-only-for-selection after visibility. rebuild_render_data_with_hidden_atoms passes the hidden-chain atom set (cached by ui/shell/src/render_cache/hidden_atoms.rs) in as ViewRecipe::hidden_chain_atoms.
The cartoon takes a separate route, which is worth knowing before you go looking for one function that does both. Hidden chains are trimmed by filtered_scene_by_hidden_chains (viewer_loop/runtime/redraw/scene/filters.rs), which calls cartoon_scene::scene_without with an empty atom set, so the cartoon is built from a scene that already lacks them. Per-atom visibility is therefore a property of the render path, not of the scene — the SceneGraph is never edited.
Groups are frontend-neutral. They lived in ui/shell until 2026-09-04, which meant the desktop was the only thing that could express a figure. They now sit in orbitron-appearance, which the desktop, the CLI, the web viewer and the render-parity tests all already depend on, so any of them can build and apply the same groups. apply_group_filters takes a &[RepresentationGroup] rather than the desktop’s UiState; ui/shell/src/orchestration/render_filters.rs keeps a thin adapter, collect_group_atoms_hidden_from_base, that passes ui_state.viewport.view.groups through.
Two consequences worth keeping true. orbitron-appearance is checked against wasm32-unknown-unknown by scripts/check-rust-lints.sh, so a dependency that is not wasm-safe breaks the web viewer rather than merely this crate — the group model’s orbitron-query dependency was added under that constraint. And groups.rs carries a test that applies a group to render data with no frontend involved; if it ever needs a UiState to run, the move has been undone.
The web viewer applies them too. groups.set carries a RepresentationGroupUpdate list — a structural mirror of the model defined in orbitron-delta-types, because that crate sits below orbitron-appearance and cannot name RepresentationGroup; the conversions live in groups.rs, both directions in one place. A live group travels as its expression rather than as the atoms it matched at export time, so it stays live in the browser, which has the scene and re-evaluates. An unrecognised representation string reads as Default, so a newer exporter cannot blank a group in an older viewer.
The subtle part is why the op is needed at all: an exported cartoon already has its groups baked in, because the desktop builds that mesh and ships it. The ball-and-stick layer is built by the receiving viewer from the scene, so without groups.set a page would draw every atom a desktop figure had hidden — the same file, two pictures. viewer/wasm applies orbitron_appearance::groups::apply_group_filters at the same point the desktop does, after the cell overlays, so a group hides its periodic ghosts along with its atoms, and contacts::apply_polar_contacts right after it.
polar_contacts travels as the flag, not as resolved line segments — the opposite of the labels, and for a reason. The receiving viewer already re-evaluates a group’s selection against the scene it was given, so it can run the same search; a label has no such handle, because the selection that produced it is not part of an exported page. Sending the flag also keeps the dashes right when the page’s scene differs from the exporter’s.
The web viewer builds with the plan too, as of 2026-09-05. It had applied only the visibility and colour half of a group: apply_group_filters used to resize space-filling groups as a post-process, and when that moved into the builder the browser kept calling the filter and lost the representation with it, so a spheres group crossed to a page as ordinary atoms. It now calls representation_plan and build_scene_data_with_representation like the other two frontends.
The global render mode crosses too, as of the same day. A bundle has always recorded it in render_flags.render_mode, and the viewer read the other four flags beside it and dropped that one, so a space-filling or licorice figure arrived as ball-and-stick. It is UiOverlayState::render_mode now, the base of the plan, settable from JS with set_render_mode.
Nothing in that module is host-testable — viewer/wasm/src/wasm is entirely #[cfg(target_arch = "wasm32")], so a unit test written next to it never runs, and tests/render-parity/tests/cross_viewer.rs needs a real browser frame. The browser suite is where these are asserted, through scene_instance_counts and scene_appearance_digest: space fill swallows the bonds and licorice keeps them while moving every radius, so the modes are told apart without a screenshot. Both exports exist for exactly this — a mode that is dropped renders a perfectly good picture, just not the saved one.
Residue labels share the same arrangement. orbitron_appearance::labels::label_residues replaces the per-atom element labels build_scene_data produces with one per residue for a selection; the desktop calls it from orchestration/render.rs when ViewUiState::atom_label_mode is SelectedResidues, and the CLI from --label-residues. show_atom_labels stays the master switch and the mode says what the labels are, rather than a second flag competing with it.
atom_label_mode is a plain ViewUiState field, like every view flag (the viewer loop kept its own copies of render_mode and five show_* flags until 2026-09-17), so it follows atom_index_base in sync_ui_state_from_temp: copied back from the temp state and marking the scene for rebuild when it changes. A field missing from that function is discarded every frame — the control moves and moves back — which is what the_label_mode_survives_the_per_frame_clone_and_asks_for_a_rebuild pins.
Labels travel the same way, as labels.set, and free-text annotations as annotations.set. Both are resolved before export rather than derived in the viewer, because an exported page has no selection to derive them from — residue_labels returns the labels and exportable_annotations resolves each annotation’s anchor to a world point. Without those ops an exported binding-site figure arrives with its ribbon, its sticks and its groups intact and none of its residues named.
Only anchored annotations travel. A screen-anchored one names a place on the glass of the window it was written in, which is a different place in a browser at another size, and an arrow needs raster line drawing the web viewer has no equivalent of; exportable_annotations drops both rather than putting them somewhere the figure did not ask for.
Both ops need something to draw them, and until 2026-09-04 nothing did. RenderSceneData::labels is data each frontend renders its own way — the desktop through an egui overlay, the CLI through overlay_diagram_labels — and the web viewer never rendered it, so labels.set arrived, populated the field, and showed nothing. collect_scene_text_specs (viewer/wasm/src/wasm/overlay/overlay_label_specs.rs) now builds billboard text meshes for scene labels and annotations through the same machinery measurements use, outside the overlay_labels_enabled gate because that toggle governs the names a viewer puts on comparison overlays, not the text an exporter asked for. The lesson is in the test: the browser check read scene_label_texts(), which is the list of strings, and passed on a blank page. scene_text_mesh_vertices() exists so a check can ask what was built.
orbitron render uses this: automation/cli/src/handlers/commands/render/figure.rs turns --show/--hide, --ribbon-opacity, --atoms-for and --group into cartoon meshes and RepresentationGroups, and both render paths — plain and appearance-bundle — go through the one function, so the two cannot drift. The CLI builds its own cartoon through orbitron_ribbon::build_cartoon and passes the meshes to render_scene_data_with_meshes alongside any isosurfaces from a bundle.
Ribbon opacity is separate and lower down the stack: CartoonSettings::opacity in core/ribbon, applied to the mesh instances in pipeline.rs after build_meshes and scaled onto the nucleic overlays too. It multiplies rather than assigns, so a nucleic ring’s own ring_transparency still composes. The transparency itself was always supported — mesh instances carry an opacity and the cartoon is submitted as MeshBlendMode::Alpha — only the setting and the slider were missing.
8.3 Detached export window and file exporters
File > Export has ten commands. Only Export Image… opens the detached window (ui/shell/src/menu/file_menu.rs):
Export Image… →
MenuAction::Export, the detached export window described below.Export XYZ… / Export PDB… →
MenuAction::{ExportXyz, ExportPdb}, handled inline inviewer_loop/menu_actions/files.rsover the writers inexport/utils.rs; both write all atoms plus visible overlays.Export GROMACS GRO… →
MenuAction::ExportGro, which writes the base scene, residue identity, velocities, and periodic box throughExporterService::export_gro. It reports every field that GRO cannot carry.Export POSCAR… →
MenuAction::ExportPoscar, which requires a unit cell and preserves frozen atoms as Selective dynamics flags.Export Molfile… →
MenuAction::ExportMolfile, which writes the base scene and connectivity. It deliberately excludes overlays because a Molfile is a connection table rather than a composed view.Export Web View… →
MenuAction::ExportWebView(the page, its templateviewer/web-page/src/template.htmland the embedded viewer areorbitron_web_page, shared withorbitron web;export/web_view.rskeeps only the desktop’s preset-name mapping): a self-contained HTML file with the wasm viewer embedded, opening to the current view. Everything the bare scene JSON cannot carry — cartoon ribbons, isosurfaces, halos, cell overlays, the Slice plane, the vibrational modes — is gathered into aDisplayCaptureand serialized as a delta envelope byexport/render_deltas.rs, which the page applies viaapplyDeltaafterscene-loaded. Adding another one means a field onDisplayCaptureand an op; if the op is new,core/delta-types’ enum is shared with the wasm, whose exhaustive match will force you to handle it there.Export Bundle… →
MenuAction::ExportBundle: the source’s canonical document and attachments plus the current camera, surfaces, halos, and appearance. Save Session… uses the same container and also records desktop-window state.Export Appearance Bundle… →
MenuAction::ExportAppearanceBundle: theme, camera, diagram, and cartoon settings as portable JSON, replayable in the WASM viewer or viaorbitron render.Import Appearance Bundle… →
MenuAction::ImportAppearanceBundle: applies that portable JSON to the current scene without replacing its scientific data.Detached popups are managed by
ExportWindowManager(ui/shell/src/export/manager.rs), which tracksWindowId→WindowKind(currently only Export). Routing is keyed onis_managed_window(window_id)insideViewerLoop::window_event.Export window opens via
MenuAction::Export→runtime.export_window_open_request→ theabout_to_waithandler →open_export, and is torn down byclose_export_windowwhen it closes or export completes.runtime.export_window_idrecords the active window.Export host (
export/host.rs) owns its own winit window + wgpu surface/device for egui painting. It reuses the main renderer for preview/export rendering but snapshotsUiState.exporton open and restores it on cancel/close (kept when an export succeeds).To add another popup: add a
WindowKindvariant and corresponding open/close/redraw/handle methods in the manager, add a runtime open-request flag, wireabout_to_waitto spawn it, and route events/redraws byWindowId. Keep window-specific snapshot/restore logic near each host.
8.4 Background workflows
- Long-running operations (load new file, parse task summaries, export orbitals) run via the
backgroundmodule which spawns threads sendingProgressMsgupdates over channels.CancelHandleallows the user to abort transfers; the viewer loop pollsbg_rx/tasks_rxeach frame.