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-valuestatusvocabulary, theobject|rep|statecache key and thekindpayload shapes are exactly whatpackages/bridge/tenmol_bridge/render/modeg.pyconsumes 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 Å againstget_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, andCmd.cppnow carries 10 guarded lines in 2 blocks, not 7 — spike 08 addedweb_get_versionsandweb_resolve_pickto the same two sentinel regions this spike created. §10’s gap list is largely CLOSED, by spike 08 and by the wave after it. Read08-native-changes.md§4–§5 before acting on any of it:§8.1’s merge-check instruction still holds but the script is gone: runpackages/bridge/.venv/bin/python -m pytest packages/bridge/tests -qinstead, and if any rep reportslayout-mismatch, re-diff that struct againstnamespace 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):
- Identity. The payload is keyed
object|rep|stateand every vertex/instance carries the PyMOL atom index and bond flag. Client-side picking and per-object/per-rep toggling become possible. - Reps the exporters silently drop. Ellipsoids:
get_vrml/get_povray/get_idtfemit 0 bytes; the accessor returns 367 ellipsoid instances on 1EJG — the exact count the ray tracer reports. Transparency:get_vrml(2)contains the wordtransparency0 times; the accessor returns a per-vertex alpha buffer. Ambient occlusion: not in any exporter; returned. - Correct primitives for
mesh/dots/lines. 1UBQmeshthrough 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 “emitRepSurface::{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: harvestCRay 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 reps —
cgoLooksSane()checkscgo->G == G(CGO::Gis the first data member,packages/engine/layer1/CGO.h:772), thatopis non-null whenc > 0, and that every opcode reached by walking the buffer is< CGO_sz_size()and that the walk terminates withinc + 2steps. - Surface —
checkSurfaceMirror()requiresV.size() == 3N,VN.size() == 3N,T.size() == 3·NT,VC/VA/VAO/Vis/ATeither empty or exactly3N/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 withinNTot + 1entries. - Dots — the run-length stream must consume exactly
RepDot::Ndots.
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 returnsops (opcode → count) and unhandled_ops. unhandled_ops is empty
for every supported rep — nothing is dropped silently.
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 fromspikes/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.
.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:
.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_STRIP → nverts − 2, GL_TRIANGLE_FAN → nverts − 2, GL_TRIANGLES → nverts/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():
5.5 Data no exporter carries
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 367ANISOU 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 returnsstatus: "unsupported".state— 0-indexed, or-1for the object’s current state (resolved viaCObject::getCurrentState(), sostatic_singletonsis honoured). The resolved value comes back in the payload.rep— a name ("cartoon","surface","sticks","nb_spheres", …; singular aliases accepted) or an integercRep_t. An unknown name or an out-of-range index raisesCmdException.update— when true, runsSceneUpdate(G, false)first. That is whatcmd.refresh()and the exporters do, and spike 03 §2 established it builds rep geometry with no GL context.
_cmd entry point, it assumes the API lock is held. Callers do:
PyObjects.
7.1 Status vocabulary
Observed:
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:
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
Runwp26/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 tospikes/build.md §3, no additional flags):
<scratch>/wp26/, combined transcript in <scratch>/wp26/ALL.txt):
Regression:
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
labelsare not extracted.RepLabel::shaderCGOis the only geometry and it is built at render time from a texture atlas; there is no primitive CGO. The accessor returnsstatus: "unsupported". Mode P must render labels, or a future task must emitRepLabel::labelV(positions +lexidx_ttext ids) plus the glyph metrics.slice,volume,cell,cgo,callback,dashes,angles,dihedralsare likewiseunsupported.dashes/angles/dihedralshang offDistSet, notCoordSet, so they need a different lookup path;cell/cgo/slice/volumehang off non-molecular objects. All of them are a bounded follow-up, not a design problem.- Non-molecular objects (
ObjectSurface,ObjectMesh,ObjectMap,ObjectCGO) returnunsupported.cmd.dump()already covers isosurface/isomesh (spike 03 §3). - 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. - No dirty tracking yet. Every call re-reads the rep. That is 3-8 ms on 1AON, so polling is
viable, but the
ReprVersioncounter of plan §4 task 6 is what makes it free. CGO_ALPHA_TRIANGLE(35 floats, transparency-sorted triangles) is counted inunhandled_opsrather 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.