Skip to main content

@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] > 0 means ORTHOSCOPIC. SceneGetView writes ortho ? 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) subtracts MovieGetPanelHeight() and the internal feedback lines. Measured: a 1176x644 window reports 1176x629 as soon as an object has two states, because movie_panel is 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, not requestAnimationFrame — rAF stops dead in a hidden or occluded tab, and the rAF version turned a whole 60-sample drag into one jump at pointerup.
  • The wheel is a DOWN/UP pair and is not sent at all while a button is held, because OrthoButton drops it (packages/engine/layer1/Ortho.cpp:2503-2510).
  • Spheres and cylinders are instanced impostors, ports of packages/engine/data/shaders/sphere.* and cylinder.*, writing gl_FragDepth. Client-side tessellation is what turned 1UBQ mesh into 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 draws GL_TRIANGLES only.
  • Lines are quads, and their width is a camera quantity. WebGL2 core clamps gl.lineWidth to 1.0 (measured ALIASED_LINE_WIDTH_RANGE == [1,1] in the headless Chromium the e2e suite uses), so mesh_width would be inert. Each segment becomes a screen-space quad, exactly as PyMOL’s own trilines path does. The rasterised width is clamp(dynamic_width_factor / vertex_scale, min, max) * mesh_width and vertex_scale depends on the projection — so it cannot be baked into a cached geometry frame and is recomputed in onBeforeRender every 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 returns true consumes the pick, suppressing the default select('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:
The dev query switches live in 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

The skipped test is the real-accessor fixture suite; it is skipped rather than faked when 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.