UI Shell Architecture

8.1 Viewer Loop

  • ViewerApplication (ui/shell/src/runner.rs) owns the winit event loop: under winit 0.30 it implements winit::application::ApplicationHandler (resumed / window_event / device_event / about_to_wait) and is driven by event_loop.run_app(&mut app).
  • ViewerLoop (ui/shell/src/viewer_loop/runtime/state.rs, re-exported via mod.rs) owns the renderer, egui context, camera controller, and complete viewer state. ViewerApplication forwards each ApplicationHandler callback to ViewerLoop’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 via KeyActionContext.
    • menu_actions/ mirrors the GUI menu tree (the menu/ module) and triggers async loads, toggles, and exports.
    • progress_overlay.rs, fragment_palette.rs, change_element/, smiles_builder_dialog.rs, conformer_dialog.rs, and residue_mutation_dialog.rs, residue_protonation_dialog.rs, terminal_cap_dialog.rs, and disulfide_dialog.rs isolate complex overlays/dialogs for readability.
    • dialog_context.rs provides 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 into ViewerLoop::window_event beside the existing keyboard/menu helpers to minimise borrow complexity and keep diffs localised.
  • Rendering updates go through rebuild_render_data (orchestration.rs) which regenerates RenderSceneData and refreshes measurement overlays. request_redraw and request_redraw_with_title handle 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.rs holds the state, the camera motion (a bat pushes the molecule, a head-bump spins it) and restore, and the drawing.
  • planner.rs is 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.rs loads 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) with ViewUiState and CameraUiState (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) and dialogs (DialogUiState).

    • Each frame the panels mutate a clone of UiState, and sync_ui_state_from_temp (ui/shell/src/orchestration/sync.rs) copies it back field by field. A new field on EditUiState or PlacementUiState that is not listed there is discarded every frame, which reads as a checkbox that will not stick. The regression test edit_settings_survive_the_per_frame_clone in 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 into apply_theme_styles for immediate renderer updates.

    • the per-frame redraw (driven from ViewerLoop::about_to_wait) samples egui::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::AnnotationState inside MarkupUiState (ui/shell/src/ui_state/markup_ui_state.rs) and are rendered via viewer_loop/annotation_overlay.rs. The Annotations panel (panels/annotations.rs) edits text/arrow items; export rendering composites them in export/rendering/raster/overlays.rs.

      An endpoint is an AnnotationAnchor: Screen (normalised viewport coordinates, as it always was), Atom, or World, the latter two with a normalised screen offset. Both drawing paths resolve an anchor through the shared util::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 to None and 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 (a ResolvedEndpoints, #[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 one anchor_row helper so they cannot drift apart in behaviour. That helper needs the scene, to turn a selection into a centroid for a World anchor, which is why render_annotations_panel takes a &SceneGraph. The panel renders at the top of the UnifiedTab::Context card, above render_context_section: UnifiedTab::Annotations exists but is in neither branch of sections_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 in panels/unified.rs). Viewport drag handling lives in viewer_loop/runtime/window_events/selection_drag.rs (right-drag box/lasso). The selection tool can also be cycled via Shift+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 (see PlacementUiState). The Edit panel toggles placement, the change-element dialog supports a periodic table picker, and ChangeElementScope separates 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 exact MutateResidueCommand. 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 one RepairSideChainCommand. 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 one EditAlternateLocationCommand. Water/ion cleanup previews an exact whole-residue All/Within/Beyond match, can return the matches to ordinary atom selection, or applies one RemoveWaterIonResiduesCommand. 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 exact AddTerminalCapCommand. 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 exact SetResidueProtonationCommand. Disulfide editing previews the two author residue sites, minimum-image SG distance, hydrogen changes, SSBOND state, and molecular formal charge before applying one exact EditDisulfideCommand. 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 bool at UiState.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 bumps theme.label_font_size/label_outline_width before drawing so demos keep atoms legible even on secondary displays. Saved sessions restore the mode through SessionState::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.rs ties them together. The toolbar lives in toolbar/ and defines the ToolbarAction enum (toolbar/mod.rs). Beyond the render-mode switcher it now owns the selection tool picker, the selected-atom count, and Clear (ToolbarAction::ClearSelection, in toolbar/session.rs) — the controls that used to be a Selection panel — plus camera stepping via ToolbarAction::{ZoomIn, ZoomOut}.
    • ui/shell/src/appearance.rs, ui/shell/src/tasks.rs, ui/shell/src/fragments.rs, and the ui/shell/src/measurement/ module house feature-specific controllers invoked from the panels. They sit at the crate’s top level, not under panels/, which has its own appearance/ and measurements/ directories.
    • ui/shell/src/preferences.rs renders the “Preferences” window (preferences_window), with sections for “File loading limits” and “Symmetry”, persisting configuration, and pushing toast notifications back into UiState.
    • Selection logic is shared via the viewer_state/ module — push_history and describe_gaussian_calc in viewer_state/helpers.rs, focus_selection in viewer_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 in actions.rs; periodic cell transforms and slab commands are grouped in actions/cell.rs and retain the same public menu-action API.
  • Camera interactions originate from camera.rs (CameraController::update_camera) and feed into event handling utilities in event_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:

  • None
  • MullikenCharges / LowdinCharges / HirshfeldCharges / NaturalCharges / AptCharges — backed by ElectronicStructure.{mulliken,lowdin,hirshfeld,natural,apt}_population.
  • MullikenSpinDensity — the signed spin population on the same mulliken_population entries, for an open-shell run.
  • AtomicSphereCharges / AtomicSphereSpinDensity — net charge and signed moment from ElectronicStructure.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 from ElectronicStructure, 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 from NboWorkspace.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 a PROCAR that 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_action in actions.rs sets the halo scheme and calls recompute_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) from n_alpha and the global MO index.
  • NBO tab (panels/analysis/nbo.rs) — “Show as halo” on the selected NBO orbital. Action is NboAction::ShowAsHalo { family, index }; the dispatcher maps the io OrbitalFamily to the ui-state NboFamilyTag and stages the per-atom values.
  • Slice section (panels/analysis/slice.rs, rendered by render_slice_section in panels/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::Slice in viewer/core/src/ui_state/analysis.rs survives only so old saved layouts load, and relocated_subtab in panels/analysis_panel.rs redirects 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 calls super::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_hash takes a SurfaceInputs carrying 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 in viewer_loop/menu_actions/files.rs over the writers in export/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 through ExporterService::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 template viewer/web-page/src/template.html and the embedded viewer are orbitron_web_page, shared with orbitron web; export/web_view.rs keeps 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 a DisplayCapture and serialized as a delta envelope by export/render_deltas.rs, which the page applies via applyDelta after scene-loaded. Adding another one means a field on DisplayCapture and 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 via orbitron 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 tracks WindowId → WindowKind (currently only Export). Routing is keyed on is_managed_window(window_id) inside ViewerLoop::window_event.

  • Export window opens via MenuAction::Export → runtime.export_window_open_request → the about_to_wait handler → open_export, and is torn down by close_export_window when it closes or export completes. runtime.export_window_id records 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 snapshots UiState.export on open and restores it on cancel/close (kept when an export succeeds).

  • To add another popup: add a WindowKind variant and corresponding open/close/redraw/handle methods in the manager, add a runtime open-request flag, wire about_to_wait to spawn it, and route events/redraws by WindowId. 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 background module which spawns threads sending ProgressMsg updates over channels. CancelHandle allows the user to abort transfers; the viewer loop polls bg_rx / tasks_rx each frame.