Skip to main content

Spike 06 — The C++ geometry accessor (_cmd.web_get_rep_geometry)

Status: IMPLEMENTED AND VERIFIED. Plan code-ownership.md §4 tasks 1 and 2 are done. The product owner moved Mode G onto the critical path; this is the C++ that makes it possible. Every number and transcript below was produced on this machine by running the scripts named in §9. Nothing is inferred from source reading alone.

STATUS — re-verified on 2026-08-02

§7 is the live contract and is unchanged. _cmd.web_get_rep_geometry(_self._COb, object, state, rep, update=1), the six-value status vocabulary, the object|rep|state cache key and the kind payload shapes are exactly what packages/bridge/tenmol_bridge/render/modeg.py consumes today — that module names this spike in its own docstring. §5’s fidelity numbers (10,472/10,472 and 6,454/6,454 triangles, max delta ~1.2e-5 Å against get_vrml(2)) and §6’s speed-ups are the reason Mode G exists and have not been re-litigated. §0 counts are historical. The file is 2,660 lines, not 1,451, and Cmd.cpp now carries 10 guarded lines in 2 blocks, not 7 — spike 08 added web_get_versions and web_resolve_pick to the same two sentinel regions this spike created. §10’s gap list is largely CLOSED, by spike 08 and by the wave after it. Read 08-native-changes.md §4–§5 before acting on any of it: §8.1’s merge-check instruction still holds but the script is gone: run packages/bridge/.venv/bin/python -m pytest packages/bridge/tests -q instead, and if any rep reports layout-mismatch, re-diff that struct against namespace mirror. The nine mirrors in §8.1’s table are all still present; spike 08 added a tenth, mirror::RepDistLines.

0. TL;DR

Three things this accessor gives the client that no PyMOL exporter can (spike 03 §0):
  1. Identity. The payload is keyed object|rep|state and every vertex/instance carries the PyMOL atom index and bond flag. Client-side picking and per-object/per-rep toggling become possible.
  2. Reps the exporters silently drop. Ellipsoids: get_vrml/get_povray/get_idtf emit 0 bytes; the accessor returns 367 ellipsoid instances on 1EJG — the exact count the ray tracer reports. Transparency: get_vrml(2) contains the word transparency 0 times; the accessor returns a per-vertex alpha buffer. Ambient occlusion: not in any exporter; returned.
  3. Correct primitives for mesh/dots/lines. 1UBQ mesh through the exporters becomes 31,710 cylinders + 63,420 spheres / 31.9 MB .wrl. Through the accessor it is 542 line strips, 32,252 vertices, 0 cylinders, 0 spheres.

1. The problem that shaped the design

Plan §4 task 1 says “emit RepSurface::{V,VN,VC,VA,VAO,T,AT,Vis} (packages/engine/layer2/RepSurface.cpp:74-85)”. The line reference is to the .cpp, and that is not an accident:
RepSurface — and RepCartoon, RepCylBond, RepWireBond, RepRibbon, RepNonbonded, RepNonbondedSphere, RepEllipsoid, RepMesh — are declared inside their own translation unit. Only RepSphere (packages/engine/layer2/RepSphere.h:27) and RepDot (packages/engine/layer2/RepDot.h:28) have public struct definitions. A new file in packages/engine/layer4/ therefore cannot see nine of the eleven rep types without either (a) editing packages/engine/layer2/*, which this work package does not own, or (b) a layout mirror.

1.1 Two routes were considered and one was rejected on evidence

Rejected: harvest CRay primitives. Rep::render(info) with info->ray set is public and generic — it needs no mirrors at all, works for every rep, and even sees the ellipsoids. It is what every text exporter uses. It was rejected because CGORenderRay converts lines to sausages and points to spheres (packages/engine/layer1/CGO.cpp:6067-6130), which reproduces exactly the degradation the task forbids: 1UBQ mesh → 31,710 cylinders. CPrimitive (packages/engine/layer1/Basis.h:66-82) also has no atom index field, so identity is lost too. Both of the accessor’s reasons to exist die on this route. Chosen: layout mirrors + the primitive CGO. packages/engine/layer4/CmdWebGeometry.cpp declares a namespace mirror struct per rep that repeats, in order and with identical types, the data members the upstream derived class declares. The Rep base sub-object comes from the real packages/engine/layer1/Rep.h, so only the derived tail can ever drift.

1.2 Every mirror is validated before it is dereferenced

This is the part that makes the technique safe to carry across upstream merges.
  • CGO repscgoLooksSane() checks cgo->G == G (CGO::G is the first data member, packages/engine/layer1/CGO.h:772), that op is non-null when c > 0, and that every opcode reached by walking the buffer is < CGO_sz_size() and that the walk terminates within c + 2 steps.
  • SurfacecheckSurfaceMirror() requires V.size() == 3N, VN.size() == 3N, T.size() == 3·NT, VC/VA/VAO/Vis/AT either empty or exactly 3N/N, and every triangle index in [0, N).
  • Mesh — strip lengths must be positive and sum to NTot, and the list must be zero-terminated within NTot + 1 entries.
  • Dots — the run-length stream must consume exactly RepDot::N dots.
If any check fails the call returns status: "layout-mismatch" with a message naming the file that changed. It never reads garbage and never returns silently-empty buffers.

2. Where each rep’s CPU geometry actually lives

Measured, per rep, by asking the accessor which field it read (source in the payload):

2.1 The exact CGO op histogram of every supported rep (1UBQ / 1EJG)

The payload always returns ops (opcode → count) and unhandled_ops. unhandled_ops is empty for every supported rep — nothing is dropped silently.
Note that the cartoon preshader already contains CGO_DRAW_ARRAYS, not BEGIN/VERTEX/END: RepCartoonNew calls CGOCombineBeginEnd(&preshadercgo) when has_begin_end (packages/engine/layer2/RepCartoon.cpp:4292-4294). That is exactly the block the plan says to emit verbatim, and it is emitted verbatim.

3. Never reading a VBO — the two traps, and proof they are handled

packages/engine/layer1/CGO.h:183-186: once a CGO has been uploaded, the CPU copy is deliberately dropped. The accessor only ever reads the primitive CGO each rep keeps for the ray tracer, and reports status: "vbo-only" if only GPU buffers remain. Two specific hazards were called out in the task: Trap 1 — RepCartoonCGOGenerate calls disposePreshaderCGO() (packages/engine/layer2/RepCartoon.cpp:240). Reading the implementation (:80-89) shows it does not free the preshader when ray is null — it moves it: std::swap(ray, preshader). So the primitive geometry survives a GL render under a different name. The accessor reads ray ? ray : preshader and reports which one it used. Trap 2 — Rep::update on worker threads when async_builds is on. The accessor is a _cmd entry point and follows the same “API is locked” contract as every other Cmd* function; the bridge must call it from the engine thread with the API lock held (§7).

3.1 Empirically: the payload is byte-identical before and after 30 real GL frames

Run on the headless CGL + FBO context from spikes/picking.md §3 (GL: ('Apple', 'Apple M4 Max', '2.1 Metal - 89.4'), use_shaders=on, so the reps genuinely were converted to VBOs):

4. Instance buffers, never tessellation

CGO_SPHERE, CGO_SHADER_CYLINDER, CGO_SHADER_CYLINDER_WITH_2ND_COLOR, CGO_CYLINDER, CGO_SAUSAGE, CGO_CUSTOM_CYLINDER[_ALPHA], CGO_CONE, CGO_ELLIPSOID and CGO_VERTEX_CROSS are decoded into flat typed instance buffers. Nothing is expanded into triangles.
Compare with the exporters on the same scene (spike 03 §4): .wrl emits 1,127 Cylinder{} nodes for the same 718 bonds (half-bonds are split), .obj emits 0 bytes, and .dae emits 1,127 separate <geometry> nodes / 4.5 MB. mesh and dots never enter the CGO path at all — they are read from RepMesh::{N,V,VC} and RepDot::V as line strips and points:
(38,567 is exactly the sphere count the exporters produce for the same rep — spike 03 §4 — so no dot is lost; they are simply points instead of 658 MB of .dae spheres.) CGO_VERTEX_CROSS is emitted as a centre point plus the nonbonded_size setting, so the client expands it to three axis-aligned segments exactly as CGORenderRay does (packages/engine/layer1/CGO.cpp:5879-5896):

5. Correctness — agreement with cmd.get_vrml(2)

5.1 Triangle counts, three structures, both triangle reps

acc_tris for cartoon is derived from the CGO_DRAW_ARRAYS GL modes actually present (GL_TRIANGLE_STRIPnverts − 2, GL_TRIANGLE_FANnverts − 2, GL_TRIANGLESnverts/3); on 1UBQ the modes are {GL_TRIANGLE_STRIP: 6564 verts, GL_TRIANGLE_FAN: 160 verts} → 6,454 triangles. 860,040 for 1AON cartoon is the same number spike 03 §0 measured out of get_vrml(2).

5.2 Not just counts — the actual coordinates

get_vrml emits camera space (SceneRay applies the view matrix), the accessor emits model space. Applying cmd.get_view()’s rotation about get_view()[12:15] puts them in the same frame; the residual centroid offset is [0, 0, -4e-06]. Triangles were then lexicographically ordered on both sides, corner order canonicalised, and compared element-wise:
get_vrml prints 4 decimals, so ~1e-4 Å is its own quantisation floor. The two agree to within the exporter’s printing precision.

5.3 The accessor’s surface is indexed; the exporter’s is not

get_vrml(2) emits 31,416 point[] entries for 10,472 triangles — 3 unduplicated corners each. The accessor emits 5,235 unique vertices plus a 3×10,472 int32 index buffer, a 6× vertex reduction, and that is why the binary payload is 356 KB against 2.8 MB of ASCII.

5.4 Atom mapping is real

RepSurface::AT[v] is a 0-based index into the object’s AtomInfo array (it is fed straight into AtomInfoIsMasked(obj, I->AT[idx]) at packages/engine/layer2/RepSurface.cpp:402). Verified against cmd.get_model():
Two of the three sit exactly at the atom’s van der Waals radius, which is what a solvent-excluded surface vertex assigned to its closest atom should do.

5.5 Data no exporter carries

Note the semantics: PyMOL only materialises RepSurface::VA when transparency varies per atom. A uniform transparency is carried in default_alpha instead, so the client must use alpha[i] if alpha else default_alpha. Colours are per-vertex and include surface_color_smoothing interpolation — colouring resi 1-20 red and 21-76 blue produced 17 distinct vertex colours, not 2.

5.6 The rep the exporters throw away

1EJG has 367 ANISOU records. Spike 03 §4.1 measured: ray tracer “processed 367 graphics primitives”, .wrl 234 bytes, .pov 0 bytes, .obj 0 faces, .dae 0 geometry nodes.

6. Performance

Wall-clock for the accessor call only, all reps built beforehand, headless, single call: Payload size (sum of all bytes buffers) vs the ASCII the exporter produces for the same scene: Spike 03 §7 measured that parsing the 246 MB of 1AON cartoon VRML costs 2.25 s and 1.77 GB RSS in V8. The 42.0 MB the accessor returns is already float32/int32 and needs no parsing at all — it is bytes that the bridge forwards straight into a Float32Array view. Both numbers still argue for the plan’s incremental strategy: 42 MB is a lot to push per frame, so the client must cache on key and only re-fetch on a ReprVersion bump (plan §4 task 6). The accessor makes that cheap because the payload is already keyed and self-describing.

7. The Python API

  • object_name — molecular objects only; anything else returns status: "unsupported".
  • state — 0-indexed, or -1 for the object’s current state (resolved via CObject::getCurrentState(), so static_singletons is honoured). The resolved value comes back in the payload.
  • rep — a name ("cartoon", "surface", "sticks", "nb_spheres", …; singular aliases accepted) or an integer cRep_t. An unknown name or an out-of-range index raises CmdException.
  • update — when true, runs SceneUpdate(G, false) first. That is what cmd.refresh() and the exporters do, and spike 03 §2 established it builds rep geometry with no GL context.
Locking. Like every other _cmd entry point, it assumes the API lock is held. Callers do:
It uses the blocked entry convention (the GIL is retained) because it builds large PyObjects.

7.1 Status vocabulary

Observed:
Multi-state, 3 states:

7.2 Payload shape

Always present:
kind == "surface" — indexed triangle mesh: kind == "cgo" — instance buffers plus verbatim vertex arrays: draw_arrays block layout mirrors packages/engine/layer1/CGO.cpp:1645-1672 exactly: vertex 3·nverts, normal 3·nverts, colour 4·nverts (RGBA), then a packed-RGBA slot of 1·nverts that is skipped (it is regenerated at pick time), then 2·nverts of (atom index, bond) returned as int32, then accessibility 1·nverts. kind == "mesh"n_vert, n_strip, strips (i32, per-strip vertex counts), vertex (f32 3n), color/rgb/one_color_flag, mesh_type (cIsomeshMode: 0 = isomesh/line strips, 1 = isodot). kind == "dots"n_vert, vertex/normal/color (f32 3n each), atom (i32 n or None), dot_size, width. In the normal (rendering) flavour RepDot::V is a run-length encoded interleaved stream [count, r, g, b, (nx ny nz x y z)*count]… (packages/engine/layer2/RepDot.cpp:402-425) and Atom is null; the accessor decodes the stream into flat arrays. Only the cRepDotAreaType flavour carries a per-dot atom index.

8. Upstream-merge surface

packages/engine/layer4/Cmd.cpp gains 7 lines and loses none, in two sentinel-marked regions:
Two regions rather than one is a hard C++ constraint: a forward declaration cannot live inside an array initialiser. Both are grep-able on tenmol web client. packages/engine/layer4/CmdWebGeometry.cpp is a file upstream does not have and can never conflict. No build-file change was needed — the build succeeded on the first attempt, confirming setup.py:808-816’s packages/engine/layer4/*.cpp glob.

8.1 What to check at each upstream merge

Run wp26/probe_reps.py. If any rep reports layout-mismatch, re-diff the corresponding struct against namespace mirror in CmdWebGeometry.cpp. The mirrored structs, with their upstream homes, are listed in one block at the top of the file: A cleaner permanent fix, if the project ever decides to carry a larger patch (plan §8 decision 6), is a one-line accessor on each rep or moving those structs into their headers. That would delete namespace mirror entirely. It is deliberately not done here because this work package does not own packages/engine/layer2/.

9. Reproducing

Build (identical to spikes/build.md §3, no additional flags):
Probes (all in <scratch>/wp26/, combined transcript in <scratch>/wp26/ALL.txt): Regression:
Identical to the spikes/build.md §0 baseline (testglTF needs an external collada2gltf binary; symop_py is a pre-existing upstream failure). git status --porcelain shows only M packages/engine/layer4/Cmd.cpp and ?? packages/engine/layer4/CmdWebGeometry.cpp from this work package.

10. Known gaps, stated rather than hidden

  1. labels are not extracted. RepLabel::shaderCGO is the only geometry and it is built at render time from a texture atlas; there is no primitive CGO. The accessor returns status: "unsupported". Mode P must render labels, or a future task must emit RepLabel::labelV (positions + lexidx_t text ids) plus the glyph metrics.
  2. slice, volume, cell, cgo, callback, dashes, angles, dihedrals are likewise unsupported. dashes/angles/dihedrals hang off DistSet, not CoordSet, so they need a different lookup path; cell/cgo/slice/volume hang off non-molecular objects. All of them are a bounded follow-up, not a design problem.
  3. Non-molecular objects (ObjectSurface, ObjectMesh, ObjectMap, ObjectCGO) return unsupported. cmd.dump() already covers isosurface/isomesh (spike 03 §3).
  4. Pick colours remain unshippable (plan §1.4); only the (atom index, bond) pair is exported. That is what the client needs anyway, and it is present on every bucket.
  5. No dirty tracking yet. Every call re-reads the rep. That is 3-8 ms on 1AON, so polling is viable, but the ReprVersion counter of plan §4 task 6 is what makes it free.
  6. CGO_ALPHA_TRIANGLE (35 floats, transparency-sorted triangles) is counted in unhandled_ops rather than decoded. It did not appear in any rep measured here; if it shows up, the histogram will say so instead of dropping it silently.