@tenmol/viewport
The 3-D viewport: the canvases, both render modes, input forwarding, client-side picking, and the compositor that decides which renderer owns which rep. Framework-free —apps/web/src/features/viewport is the React binding, and
nothing in here imports React.
The two modes
They composite: Mode P blits to a 2-D canvas, Mode G draws into a transparent
WebGL2 canvas stacked on top (which is also the pointer target, because it is
topmost), and both are clipped to PyMOL’s scene rectangle
(
cmd.get_viewport()), which is not always the whole window.
renderPolicy.ts resolves the per-rep toggle and records why a rep degraded —
no-accessor, unsupported-rep, webgl-unavailable, extraction-failed,
preshader-disposed, payload-too-large, user-preference. Degradations are
sticky per rep so a failed rep does not thrash between modes frame by frame. A
rep asked for in Mode G is never silently blank: it is either drawn client-side
or drawn server-side with a named reason.
Layout
Subpath exports:. ./input ./modeP ./modeG ./stream ./webgl
./picking ./materials ./compositor.
Things that are easy to get wrong (all measured, not assumed)
view[17] > 0means ORTHOSCOPIC.SceneGetViewwritesortho ? fov : -fov(packages/engine/layer1/Scene.cpp:902), so PyMOL’s default perspective camera reports-20. Reading the sign the other way renders Mode G ~3 % large — caught by comparing the two modes’ silhouettes in a real browser (IoU 0.83 -> 0.96 after the fix).cmd.get_viewport()!= the window.OrthoReshape(packages/engine/layer1/Ortho.cpp:2383-2390) subtractsMovieGetPanelHeight()and the internal feedback lines. Measured: a 1176x644 window reports 1176x629 as soon as an object has two states, becausemovie_panelis on. Mode P letterboxes into that rectangle (top-anchored — PyMOL’s origin is bottom-left and the panel is at the bottom) and Mode G sets its GL viewport to match.- The Y flip happens in CSS pixels, before the dpr multiply, and
int()truncates (packages/engine/modules/pmg_qt/pymol_gl_widget.py:169-176). - Drags may be coalesced, never reordered. Dropping an intermediate position
is invisible to
SceneDrag; reordering corrupts the backend’s drag state. The pending drag is flushed before any button event. The coalescer is driven by a clock, notrequestAnimationFrame— rAF stops dead in a hidden or occluded tab, and the rAF version turned a whole 60-sample drag into one jump atpointerup. - The wheel is a DOWN/UP pair and is not sent at all while a button is held,
because
OrthoButtondrops it (packages/engine/layer1/Ortho.cpp:2503-2510). - Spheres and cylinders are instanced impostors, ports of
packages/engine/data/shaders/sphere.*andcylinder.*, writinggl_FragDepth. Client-side tessellation is what turned 1UBQmeshinto 31,710 cylinders in PyMOL’s exporters; it is not done here. Strips and fans ARE re-indexed to triangles — the same geometry — because three.js drawsGL_TRIANGLESonly. - Lines are quads, and their width is a camera quantity. WebGL2 core clamps
gl.lineWidthto 1.0 (measuredALIASED_LINE_WIDTH_RANGE == [1,1]in the headless Chromium the e2e suite uses), somesh_widthwould be inert. Each segment becomes a screen-space quad, exactly as PyMOL’s owntrilinespath does. The rasterised width isclamp(dynamic_width_factor / vertex_scale, min, max) * mesh_widthandvertex_scaledepends on the projection — so it cannot be baked into a cached geometry frame and is recomputed inonBeforeRenderevery draw. - Picking reproduces two backend conventions or it disagrees with Mode P: the cRange=7 outward square-ring scan, and — for a triangle mesh — the atom of the last index of the hit triangle, because the pick pass is flat-shaded and GL’s default provoking vertex is the last one. Taking the nearest barycentric corner scores 10/15 against a real GL pick; the provoking vertex scores 15/15.
- A pick has a routing table, not a hard-coded destination. Features call
registerPickRoute()from@tenmol/viewport/picking; routes are consulted most-recently-registered first and a route that returnstrueconsumes the pick, suppressing the defaultselect('sele', ...). That is how the Builder gets clicks in editing mode without this package importing the app. - A rep is drawn by exactly one renderer, and the bridge decides which.
src/compositor/splits this into an advisory client -> bridge declaration (“these are the reps I can draw”) and an authoritative bridge -> client answer (PixelFrameHeader.reps, “these reps are IN this bitmap”). Assuming your own declaration took effect is the bug that double-drew every Mode-G rep.
Mode-G fixtures
The bridge serves real Mode-G frames now, so fixtures are only needed to work on the decoder/renderer without a bridge:apps/web/src/features/viewport/devFixtures.ts:
?viewportFixtures=, ?viewportHandle=1 (publishes the live ViewportHandle
on window.__tenmolViewport — what the e2e suite measures), ?viewportPull=off
and ?viewportModeP=off. All are inert in a production build.
Tests
TENMOL_GEOMETRY_FIXTURES is unset. Tests live next to the code they
cover (src/**/*.test.ts) plus five broader suites in test/; a file named
*.dom.test.ts runs under jsdom, everything else under node.