Skip to main content

The molecular builder

The Builder dock widget and the molecular editor state machine behind it, read out of packages/engine/, which is unmodified upstream. Where the port stands. apps/web/src/features/builder/ (BuilderPanel.tsx, controller.ts, tables.ts, ringIcons.ts, sculptTicker.ts, viewportPicking.ts) over packages/bridge/tenmol_bridge/panels/builder.py and packages/protocol/src/topics/{builder,editor}.ts. Every button press goes to cmd.builder_action and the reply is the new state, so the client never keeps a shadow copy of pk1..pk4; a 4 Hz poll covers state the panel did not cause (a viewport pick, a set valence, 0 typed in the console, a wizard armed by a script). The 13 action wizards of §6 render through the generic wizard protocol — see docs/wizards.md §8. Sources read: Entry point: pmg_qt/pymol_qt_gui.py:613 open_builder_panel()pmg_qt/builder.py:1573 BuilderPanelDocked() wraps _BuilderPanel in a floating QDockWidget docked to TopDockWidgetArea. It is also reachable from the quick-button row ('Builder', self.open_builder_panel) at pmg_qt/pymol_qt_gui.py:250.

0. Architecture of this area

The Builder is 100% a control-plane surface. Every single button issues cmd.* calls; nothing about it needs client-side geometry. The 3D consequences (new atoms, bonds, sculpt CGO bumps) come back as ordinary object/rep updates through the geometry channel that the viewport agent owns. No contradiction with the target architecture. Two things do NOT fit the plain request/response model and need explicit bridge support:
  1. The wizard system. 12 of the ~40 buttons do not act immediately — they arm a wizard, which then (a) publishes a prompt string + a button panel that PyMOL normally draws inside the GL viewport, and (b) receives do_pick / do_select callbacks when the user clicks an atom in the viewport. The wizard object lives in Python. The web client must render the wizard prompt/panel as React and must receive a wizard_refresh event whenever cmd.refresh_wizard() is called.
  2. Picking. The editor’s pk1..pk4 selections are created by the C++ scene-click handler (packages/engine/layer1/SceneMouse.cpp:429 cButModePickAtom). Client-side WebGL picking must round-trip an (object, atom_index) to the backend, which then calls the same EditorSelect path (exposed as cmd.edit(...)). See §7.

1. Panel-scoped globals and helpers

collectPicked is the single most important helper: every bottom-row button branches on it. If atoms are already picked → act immediately. If not → arm a wizard.

Panel show side effects (showEvent, builder.py:1337-1341)

Web: fire these four on panel mount. Note cmd.set(name) with no value sets 1.

2. Tab structure

QTabWidget with three top-level tabs (builder.py:1068-1070):
  1. “Chemical”QGridLayout, 3 rows of fragment/element buttons.
  2. “Protein”QGridLayout, 2 rows of residue buttons + a secondary-structure QComboBox on row 2.
  3. “Nucleic Acid”QVBoxLayout containing a nested QTabWidget with sub-tabs “DNA” and “RNA” (builder.py:1164-1172).
Below the tabs, a QVBoxLayout (self.buttons_layout) holds 3 always-visible rows of editing actions (§5). Layout margins 5,5,5,5, spacing 5, plus a trailing stretch.

3. Tab 1 — “Chemical”

3a. Row 0 — atom replacement + two small O/F fragments (builder.py:1075-1087)

Every element button calls self.replace(symbol, geometry, valence, text) (builder.py:1352-1359):
Geometry codes come from packages/engine/layer2/AtomInfo.h:129-133: 1=Single, 2=Linear, 3=Planar, 4=Tetrahedral, 5=None. Note: the internal text strings differ from tooltips: "Phosphorous" (builder.py:1079).

3b. Row 1 — functional-group fragments (builder.py:1088-1099)

All call self.grow(fragment, hydrogen_id, anchor, text) (builder.py:1343-1350):
editor.attach_fragment(selection, fragment, hydrogen, anchor) (editor.py:51-86):
  • If selection is not an existing named selection → cmd.fragment(fragment) creates a new object (errors if an object of that name exists); then cmd.remove("(hydro and <fragment>)") if auto_remove_hydrogens.
  • Else → cmd.fragment(fragment, tmp_name, origin=0), cmd.fuse(f"{tmp} and id {hydrogen}", f"({selection})", 1), then cmd.remove("(hydro and pkmol)") if auto_remove_hydrogens else cmd.h_fill() when cmd.count_atoms('hydro and (neighbor pk2)'), finally cmd.delete(tmp).
  • anchor is documented as unused (editor.py:62).

3c. Row 2 — cyclic / aromatic fragments, icon buttons (builder.py:1100-1111)

Labels starting with # are rendered as icon buttons, not text. Icons are loaded in getIcons() (builder.py:1323-1335) from $PYMOL_DATA/pmg_tk/bitmaps/builder/{aro*,cyc*}.gif; each is loaded twice — normal and image.invertPixels() — and stored as (QIcon, QIcon_inverted) in self.icons[name]; icon size = icons[1].actualSize(QSize(48,48)). The inverted variant is stored in self.btn_icons[btn] but never swapped in in this file (dead code — intended for dark theme). All 23 fragment .pkl files referenced by the Chemical tab were verified present in packages/engine/data/chempy/fragments/.

3d. doAutoPick (builder.py:1412-1427) — runs after every immediate grow/replace

doZoom (builder.py:1429-1431): if pk1 exists → cmd.center("%pk1 extend 9", animate=-1).

4. Tab 2 — “Protein” and Tab 3 — “Nucleic Acid”

4a. Protein tab — 23 residue buttons (builder.py:1132-1144)

Row 0: Ace Ala Arg Asn Asp Cys Gln Glu Gly His Ile Leu (12 buttons) Row 1: Lys Met Phe Pro Ser Thr Trp Tyr Val NMe NHH (11 buttons) Tooltip is "Build %s residue" % label. The command is self.attach(label.lower()). _BuilderPanel.attach(aa) (builder.py:1361-1374):
Note the bare except: fin = -1 at builder.py:1369-1370 swallows all attach errors silently and assigns an unused local — reproduce as a logged, non-fatal error in the web client (do not reproduce the silence).

4b. Secondary-structure combo (builder.py:1146-1154)

QLabel("Secondary Structure:") spanning grid cols 0..2 on row 2, QComboBox spanning cols 3..6 on row 2. Items, in order: currentIndexChanged[int]ssIndexChanged(index) (builder.py:1376-1379): if the current wizard is an AminoAcidWizard, call w.setSecondaryStructure(index + 1) live. Backing dihedrals in editor.attach_amino_acid (editor.py:151-162):

4c. editor.attach_amino_acid semantics (editor.py:98-292)

Signature: attach_amino_acid(selection, amino_acid, center=0, animate=-1, object="", hydro=-1, ss=-1).
  • hydro < 0hydro = not auto_remove_hydrogens.
  • New-object branch (selection empty / 0 atoms): cmd.fragment(amino_acid, object); cmd.remove("(hydro and <obj>)") if not hydro; then cmd.edit("((obj) and name C)") if a C exists else cmd.edit("((obj) and name N)"). Errors if an object with that name already exists.
  • Validation errors (each prints and raises QuietException, after cmd.delete("_tmp_editor*")):
    • cmd.select(tmp_connect, "(sel) & elem N,C") != 1“invalid connection point: must be one atom, name N or C.”
    • amino_acid in ["nhh","nme"] and the picked atom is not elem C“invalid connection point: must be C for residue ‘<x>’”
    • amino_acid == "ace" and the picked atom is not elem N“invalid connection point: must be N for residue ‘<x>’”
    • picked atom is elem H“please pick a nitrogen or carbonyl carbon to grow from.”
    • otherwise → “unable to attach fragment.”
  • Backward (grow off N) editor.py:167-222: renumber new residue resi = resv-1; cmd.set_geometry(tmp_connect, 3, 3) (make N planar); cmd.fuse("(tmp_editor and name C)", tmp_connect, 2); cmd.select(tmp_domain,"byresi (pk1 \| pk2)"); cmd.remove("(pkmol and hydro)") if not hydro; cmd.set_dihedral(CA/CH3', pk2, pk1, CA/CH3, 180.0) (omega); cmd.h_fix(tmp2) if hydro; PHI/PSI cmd.set_dihedral (skipped for pro*); re-cmd.edit on the new terminal N; optional cmd.center(..., animate=animate).
  • Forward (grow off C) editor.py:223-281: mirror image, resi = resv+1, cmd.set_geometry(tmp_editor+" & name N", 3, 3), cmd.fuse("(tmp_editor and name N)", tmp_connect, 2), cmd.h_fix("pk1"), special amide-H fix for nhh (set_dihedral(O, C, N, H1, 180)), PHI/PSI, then cmd.edit new terminal C or cmd.unpick().
  • Always finishes with cmd.delete("_tmp_editor*").
Temp selection names used (editor.py:15-23): _tmp_editor0, _tmp_editor_con, _tmp_editor_dom, _tmp_editor1..4, wildcard _tmp_editor*. Related non-GUI commands in the same module, worth surfacing in the web client as a text/sequence builder: fab (editor.py:1062, fab ACDEFGH, helix, ss=1, one-letter codes in _aa_codes, editor.py:294-317) and fnab (editor.py:1100, fnab ATGCGATAC, name=myDNA, mode=DNA, form=B, dbl_helix=1).

4d. Nucleic Acid tab → “DNA” sub-tab (builder.py:1176-1203)

Single grid row, columns in this exact order: Encoding convention in the source table: @ prefix → label + open a new radio group; # prefix → radio button; otherwise → push button. Radios connect on toggled, so the handler fires for both the deselected and selected member — the web version should use onChange on the selected value only.

4e. Nucleic Acid tab → “RNA” sub-tab (builder.py:1206-1224)

The RNA sub-tab has no Form/Helix radios; attach_nuc_acid forces form='A' and dbl_helix=False for RNA (editor.py:803-805).

4f. _BuilderPanel.attach_nuc_acid (builder.py:1381-1402)

editor.attach_nuc_acid (editor.py:789-854) — the single most complex backend routine in this area:
  • Fragment naming: nascent.fragment_name = nuc_acid + form (e.g. atpB); double-helix fragment name is nuc_acid + "_" + _base_pair["DNA"][nuc_acid] + form (e.g. atp_ttpB). Base-pair tables at editor.py:412-416.
  • New object (count_atoms(selection)==0): cmd.fragment(frag_string, object) for the duplex, or cmd.fragment(fragment_name, object, origin=0) + cmd.alter(object, "segi='A';chain='A';resv=1") + rename_three_to_one for single strand; RNA additionally calls add2pO; then cmd.edit(f"{object} & segi A & name P" or "... name O3'"), cmd.select("pk1", f"{object} & name O3' & chain A").
  • Extend (cmd.select(tmp_connect, selection)==1): reads chain,name,model via iterate_to_list. If the picked atom is O5'attach_O5_phosphate() first. Accepts only P or O3', else raises "invalid connection point: must be one atom, name O3' or P".
  • Always ends with cmd.show("cartoon", f"byobject {selection}") and cmd.delete("_tmp_editor*").
  • extend_nuc_acid (editor.py:856-1060) does: chain→segi normalization via cmd.alter, fragment load, resv ±1, check_valid_attachment (raises "P already bonded!" / "O3' already bonded!"), move_new_res (cylindrical twist/rise: B-form twist=-36.0, rise=-3.375; A-form twist=-32.7, rise=-2.548, editor.py:541-548; raises ValueError("Form not recognized") for anything else), cmd.fuse(..., mode=3), cmd.bond(...) via bond_single_stranded / bond_double_stranded, cmd.pair_fit in fit_sugars / fit_DS_fragment, opposing-chain auto-detection (get_chains_oppo, within 15.0), base-pair detection (check_DNA_base_pair, within 3.5), new-chain naming (get_new_chain, increments the last chain letter, ZZA, zza), bond-distance sanity check (within 3.0).
  • attach_O5_phosphate (editor.py:612-646) prints “This building selection has an unphosphorylated O5’ end.”, calls attach_fragment("pk1","phosphite",4,0), then a fixed sequence of cmd.select/cmd.remove/cmd.unbond/cmd.bond(...,1)/cmd.bond(...,2)/cmd.alter(P,"name='P'")/cmd.select("pk1", P).
  • add2pO (editor.py:497-506): for RNA (skipped for utp), cmd.edit(C2') + cmd.attach("O",4,4) + cmd.alter(... name O01 ..., "name=\"O2'\"").
All these print a lot of diagnostic text to stdout (editor.py:722, 736, 738, 775, 785, 962, 988, 1037, 664, 689, 692). The web client must surface the PyMOL feedback stream next to the Builder, or these become invisible failures.

5. The three always-visible action rows

Encoding (builder.py:1267-1298): @ prefix → QLabel; $ prefix → QCheckBox bound directly to a PyMOL setting (checked = setting truthy); # prefix → QCheckBox bound inverted to a setting (checked = not value); otherwise → QPushButton.

Row 1 (builder.py:1227-1240)

removeAtom (builder.py:1507-1522) exact sequence when something is picked:
setCharge(charge, text) (builder.py:1433-1443):

Row 2 (builder.py:1241-1255)

setOrder(order, text) (builder.py:1469-1479, decorated @undoablemethod("(?pk1 ?pk2) extend 1")):
Order strings map through editing.py:598 order_dict: '0'..'4', 'aromatic'=4, 'guess'=-1, 'copy'=-2.

Row 3 (builder.py:1256-1264)

setUndoEnabled(checked) (builder.py:1300-1321):
Note the truncation bug: the truncated list including the literal "[N more]" pseudo-name is what gets passed to cmd.unset — reproduce carefully or fix (fixing is safer; document the divergence).

6. The wizard layer (13 classes)

6.0 Base machinery

  • ActionWizard (builder.py:39-86)
    • actionHash defaults to str(self.__class__); setActionHash(h) lets a wizard be identified by its arguments.
    • activateOrDismiss() (builder.py:48-60): if the current wizard is the same class and same hash → actionWizardDone() (deactivate, toggle-off); else cmd.set_wizard(self, replace=1) + cmd.refresh_wizard(). This is the toggle semantics every Builder button has: clicking the same button twice cancels it.
    • actionWizardDone(): cmd.delete("_builder_active"), cmd.unpick(), cmd.set_wizard(), cmd.refresh_wizard().
    • activeSeleValid() (builder.py:68-86): revalidates _builder_active — deletes it if it spans ≠1 object or the object is not enabled; if pk1 exists → cmd.select("_builder_active","byobj pk1"); else if exactly one object is enabled → select it.
  • RepeatableActionWizard (builder.py:228-263): adds repeating flag, repeat(), getRepeating(), activateRepeatOrDismiss() (first click arms + always sets repeating=1 — see builder.py:255 "always repeating for now..."), cleanup()cmd.unpick().
  • Panel format from Wizard.get_panel() (packages/engine/modules/pymol/wizard/__init__.py:50): a list of [type, text, command]; type==1 is a title row, type==2 is a clickable button whose command is a PyMOL command string evaluated by the engine. Prompt from get_prompt() is a list of strings drawn in the viewport.
  • Event mask default is event_mask_pick + event_mask_select (wizard/__init__.py:56).
Web plan for the whole wizard layer: the bridge must emit {prompt: string[], panel: [type, text, command][]} on every refresh_wizard, and expose wizard_panel_click(command) which does cmd.do(command). Render as a floating overlay anchored to the viewport (PyMOL draws it top-left inside the GL canvas) — this is the faithful clone. Also expose cmd.set_wizard() as the universal “Done”.

6.1 CleanWizard (builder.py:89-131)

  • toggle()activateOrDismiss(); if activeSeleValid()run_job().
  • run_job(): if _builder_active names exactly one object → cmd.unpick(), cmd.set_wizard(), cmd.refresh_wizard(), cmd.do("_ cmd.clean('_builder_active', message='''Cleaning <obj>...''', async_=1)").
  • do_pick(bondFlag): normalize _builder_active to byobj pk1, cmd.unpick(), cmd.deselect(), then run_job(); prints “Error: can only clean one object at a time” if >1 object.
  • Prompt: ["Pick object to clean..."]. Panel: [1,'Clean',''] , [2,'Done','cmd.set_wizard()'].
  • cmd.clean raises pymol.IncentiveOnlyException in this tree (packages/engine/modules/pymol/computing.py:20-29). The Clean button is dead in open-source PyMOL. Web client must either hide it, or surface the exception cleanly, or the team must supply an MMFF94 minimizer.

6.2 SculptWizard (builder.py:134-225)

  • toggle()activateOrDismiss() + activeSeleValid()sculpt_activate().
  • sculpt_activate() (builder.py:140-155): requires exactly one object in _builder_active; then cmd.push_undo(obj), cmd.sculpt_activate(obj), cmd.set("sculpting", 1), cmd.sculpt_activate(obj) (called twice — redundant), and if cmd.get("sculpt_vdw_vis_mode") is truthy → cmd.show("cgo", obj); then cmd.unpick(), cmd.refresh_wizard(). Error: “cannot sculpt more than one object at a time”.
  • sculpt_deactivate() (builder.py:157-165): cmd.set("sculpt_vdw_vis_mode","0",obj), cmd.sculpt_iterate(obj, cmd.get_state(), 0), cmd.unset("sculpt_vdw_vis_mode",obj), cmd.sculpt_deactivate(obj), refresh_wizard.
  • do_pick: if not yet sculpting → cmd.select("_builder_active","byobj pk1") + activate; else return 0 (fall through to normal editing drag).
  • finish_sculpting(): deactivate, cmd.set("sculpting", 0), cmd.delete("_builder_active"), cmd.set_wizard(), cmd.refresh_wizard().
  • scramble(mode) (builder.py:193-210): selects <obj> and not (fixed or restrained) for mode 0, <obj> and not (fixed) for mode 1; computes radius = 1.25 * cpv.length(cpv.sub(extent[0], extent[1])) from cmd.get_extent, center from cmd.get_position, then cmd.alter_state(cmd.get_state(), sel, "(x,y,z)=rsp(pos,rds)", space={'rsp': cpv.random_displacement, 'pos': center, 'rds': radius}), then deletes _scramble_tmp.
  • Prompt: ["Pick object to sculpt..."] / ["Sculpting <obj>..."].
  • Panel: Sculpt (title); Undocmd.undo(); Switch Objectcmd.get_wizard().sculpt_deactivate(); Scramble Unrestrained Coords.scramble(0); Scramble Unfixed Coords.scramble(1); Donefinish_sculpting().
  • cleanup()sculpt_deactivate().
  • Relevant settings: sculpting (SettingInfo.h:246), sculpt_field_mask (:259, default 0x1FF), sculpt_vdw_vis_mode (:544), sculpt_max_scale/weight/min/max (:597-600). Sculpting runs as a per-frame iteration in the engine — the web bridge must stream coordinate updates while it is active.

6.3 ReplaceWizard (builder.py:266-299)

  • toggle(symbol, geometry, valence, text) sets actionHash = (symbol, geometry, valence, text)activateRepeatOrDismiss().
  • do_pick: cmd.select("_builder_active","bymol pk1"), cmd.replace(symbol, geometry, valence), then actionWizardDone() if not repeating.
  • Prompt: "Pick atoms to replace with <text>..." / "Pick atom to replace with <text>...".
  • Panel (repeating): Replacing Multiple Atoms, Done. (non-repeating): Replacing an Atom, Replace Multiple Atomsrepeat(), Done.

6.4 AttachWizard (builder.py:302-365)

  • toggle(fragment, position, geometry, text), hash (fragment, position, geometry, text).
  • mode 0 (do_pick): cmd.select("_builder_active","bymol pk1") + editor.attach_fragment("pk1", fragment, position, geometry).
  • mode 1 (do_pick): editor.combine_fragment("pk1", fragment, position, geometry) (editor.py:88-96cmd.fragment(f, "_tmp_editor0"), optional cmd.remove("(hydro and ?_tmp_editor0)"), cmd.fuse("?_tmp_editor0","(pk1)",3), cmd.delete), then mode resets to 0.
  • Always cmd.unpick() after.
  • create_new() (builder.py:330-335): cmd.unpick(), name = cmd.get_unused_name("obj"), cmd.fragment(self.fragment, name).
  • combine() sets mode 1.
  • Prompts: "Pick location(s) to attach <text>..." / "Pick object to combine <text> into...".
  • Panel (repeating): Attaching Multiple Fragments, Create As New Object, Combine w/ Existing Object, Done. (non-repeating): Attaching One Fragment, Create As New Object, Combine w/ Existing Object, Attach Multiple Fragments, Done.

6.5 BioPolymerWizard (builder.py:368-471) — base for amino acid + nucleic acid

  • Class attr HIGHLIGHT_SELE (empty in the base).
  • highlight_attachment_points(show=True) (builder.py:389-397): cmd.show('spheres', HIGHLIGHT_SELE) / cmd.hide('spheres', HIGHLIGHT_SELE), only when self._highlighting_enabled.
  • toggle(monomer) (builder.py:425-434): hash (monomer,); on activation, _highlighting_enabled = HIGHLIGHT_SELE and cmd.count_atoms('(rep spheres) & (<HIGHLIGHT_SELE>)') == 0 — i.e. only auto-highlight when no spheres are already shown; then show highlights.
  • Context manager __enter__/__exit__ temporarily hides the highlight spheres during the attach.
  • do_pick mode 0: cmd.select("_builder_active","bymol ?pk1") then with undocontext(cmd,"bymol ?pk1"): self.attach_monomer(), catching pymol.CmdException and printing it.
  • do_pick mode 1: cmd.select("_builder_active","bymol ?pk1") then editor.combine_monomer().
  • create_new() (builder.py:436-443): cmd.unpick(), name = cmd.get_unused_name("obj"), self.attach_monomer(name).
  • Prompts: "Pick location(s) to attach <monomer>..." / "Pick object to combine <monomer> into...".
  • Panel (repeating): Attaching Multiple Residues, Create As New Object, Done. (non-repeating): Attaching Amino Acid (hard-coded string, wrong for nucleic acids), Create As New Object, Attach Multiple..., Done.

6.6 AminoAcidWizard (builder.py:473-492)

  • HIGHLIGHT_SELE = "(name N &! neighbor name C) \| (name C &! neighbor name N)" — free N/C termini.
  • attach_monomer(objectname="")editor.attach_amino_acid("?pk1", monomer, object=objectname, ss=self._secondary_structure).
  • setSecondaryStructure(ss) is called live by the SS combo (builder.py:1376-1379).
  • combine_monomer()editor.combine_fragment("pk1", monomer, 0, 1).

6.7 NucleicAcidWizard (builder.py:494-512)

  • HIGHLIGHT_SELE = "(name O3' &! neighbor name P) \| (name P &! neighbor name O3') \| (name O5' &! neighbor name P) " — free 3’/5’ ends.
  • _init(form, dbl_helix, nuc_type) is a fluent initializer called right before toggle().
  • attach_monomer(objectname="")editor.attach_nuc_acid("?pk1", monomer, object=objectname, nuc_type=..., form=..., dbl_helix=...).
  • combine_monomer()editor.combine_nucleotide("pk1", monomer + form, 0, 1).

6.8 ValenceWizard (builder.py:514-563)

  • toggle(order, text), hash (order, text). On activation it forces bond-picking mouse mode: cmd.button('double_left','none','PkBd') and cmd.button('single_left','none','PkBd').
  • do_pick(bondFlag) (decorated @undoablemethod("(?pk1 ?pk2) extend 1")): cmd.select("_builder_active","bymol pk1"); if bondFlagcmd.valence(order,"pk1","pk2") + cmd.h_fill() when int(order) >= 0, else cmd.cycle_valence(); if not bondFlag → re-arm PkBd on double_left and single_left. Then cmd.unpick().
  • cleanup() restores cmd.button('single_left','none','PkAt') and cmd.button('double_left','none','MovA').
  • Prompt: "Pick bond(s) to set as <text>...". Panel: Set a Bond Valence / Setting Multiple Valences, Set Multiple Valences, Done.

6.9 ChargeWizard (builder.py:566-604)

  • toggle(charge, text), hash (charge, text).
  • do_pick (@undoablemethod("bymol ?pk1")): cmd.select("_builder_active","bymol pk1"), cmd.alter("pk1","formal_charge=<charge>"), cmd.h_fill(); then cmd.label("pk1","'''<text>'''") if abs(float(charge)) > 0.0001 else cmd.label("pk1") (clears); cmd.unpick().
  • Prompt: "Pick atom(s) to set charge = <text>...". Panel: Setting Atom Charge / Setting Multiple Charges, Modify Multiple Atoms, Done.

6.10 InvertWizard (builder.py:607-643)

  • do_pick (@PopupOnException.decorator): cmd.select("_builder_active","bymol pk1"); only when collectPicked() == ["pk1","pk2","pk3"]cmd.invert() + cmd.unpick(); always cmd.refresh_wizard().
  • Prompt is a 3-state progress indicator: no pk1 → "Pick origin atom for inversion..."; pk1 only → "Pick the first stationary atom..."; pk1+pk2 → "Pick the second stationary atom...".
  • Panel: Inverting Stereocenter / Inverting Multiple, Invert Multiple, Done.
  • Backend errors from EditorInvert (packages/engine/layer3/Editor.cpp:634-638): “Must pick atom to invert as pk1”, “Must pick immobile atom in pk2”, “Must pick immobile atom in pk3”.

6.11 BondWizard (builder.py:646-697)

staticaction(cmd) (builder.py:648-669) is shared with the Create button:
Prompt: "Pick first atom for bond..." / "Pick second atom for bond...". Panel: Creating Bond / Creating Multiple Bonds, Create Multiple Bonds, Done.

6.12 UnbondWizard (builder.py:700-740)

  • toggle() forces cmd.button('single_left','none','PkBd'); cleanup() restores PkAt.
  • do_pick (@undoablemethod("(?pk1 ?pk2) extend 1")): cmd.select("_builder_active","bymol pk1"); if bondFlagcmd.unbond("pk1","pk2") + cmd.h_fill() + cmd.unpick(); else re-arm PkBd + cmd.unpick().
  • Prompt: "Pick bond(s) to delete...". Panel: Deleting a Bond / Deleting Multiple Bonds, Delete Multiple Bonds, Done.

6.13 HydrogenWizard (builder.py:743-805)

  • toggle(mode) with mode ∈ {'fix','add'}, hash (mode,). 'add' uses activateOrDismiss + activeSeleValid + immediate run_add(); 'fix' uses activateRepeatOrDismiss.
  • run_add(): cmd.h_add("_builder_active") then cmd.delete("_builder_active").
  • do_pick: cmd.select("_builder_active","bymol pk1"); 'fix'cmd.h_fill() + cmd.unpick(); 'add'cmd.unpick() + run_add().
  • Prompts: fix repeating → "Pick atom upon which to fix hydrogens..."; fix single → "Pick atoms upon which to fix hydrogens..." (the singular/plural strings are swapped in the source, builder.py:774-777); add → "Pick molecule upon which to add hydrogens...".
  • Panels: Fixing Hydrogens / Adding Hydrogens titles, plus Fix Multiple Atoms / Add To Multiple... when not repeating, plus Done.

6.14 AtomFlagWizard + FixAtomWizard + RestAtomWizard (builder.py:844-987)

Two subclasses (FixAtomWizard, RestAtomWizard) exist only to give distinct actionHash values; both bodies are pass. Flag numbers from editing.py:2848-2861 flag_dict: restrain=2, fix=3 (also focus=0, free=1, exclude=4, study=5, exfoliate=24, ignore=25, no_smooth=26). Panel (builder.py:950-975), title "Restrained Atoms" (flag 2) or "Fixed Atoms" (flag 3), in order: All, All C-alphas, More (byres), More, Byresidue, Less, Less (by residue), Only C-alphas, None, [flag 2 only: Store Reference Coords., Recall Reference Coords., Swap Reference Coords.], Done. cmd.reference actions (editing.py:77-84): store=1, recall=2, validate=3, swap=4. Note: the local verb dict (builder.py:953, {2:"Restrain", 3:"Fix"}) is computed and never used — dead code.

7. The editor state machine (pk1 / pk2 / pk3 / pk4)

7a. Reserved selection names (packages/engine/layer3/Editor.h:30-48)

7b. Pick assignment order (EditorGetNextMultiatom, packages/engine/layer3/Editor.cpp:498-536)

Next pick goes to the first free slot in order pk1 → pk2 → pk3 → pk4. Once all four are taken, further picks overwrite pk4 (the round-robin variant is commented out). Clicking an already-picked atom in atom-pick mode unpicks it (EditorDeselectIfSelected, Editor.cpp:356-392; message “You unpicked <atom>.“).

7c. Scene click → editor (packages/engine/layer1/SceneMouse.cpp:404-470)

  • cButModePickAtom1 (PkAt1 / “Pk1”): resets the editor and puts the atom in pk1 only: EditorInactivateSelectorCreate(pk1, "<obj><index+1>“)EditorActivate(state, enable_bond=false)EditorDefineExtraPks()WizardDoPick(0, state). Logs cmd.edit(“<sele>“,pkresi=1)`.
  • cButModePickAtom (PkAt): multi-pick; leaves bond mode if active, unpicks if already picked, otherwise EditorGetNextMultiatomSelectorCreate(name, ...)EditorActivate(state, false)EditorDefineExtraPks()EditorLogState(false)WizardDoPick(0, state). Feedback: “You clicked <atom> -> (pkN)”.
  • Bond picking (PkBd) goes through SceneClickPickBond (SceneMouse.cpp:487+) and sets pk1+pk2 with BondMode=true → wizards receive do_pick(bondFlag=1).
  • ObjectMolecule.cpp:3428 calls EditorSelect(sele1, sele2, "", "", false, true, true) for bond picks.

7d. EditorActivate (packages/engine/layer3/Editor.cpp:1786-1830)

Deletes pkmol pkresi pkchain pkobject pkbond _pkdihe _pkdihe1 _pkdihe2 _auto_measure, sets BondMode, calls SelectorSubdivide to compute NFrag and the _pkfragN selections, hides selections if auto_hide_selections, and:
  • if BondMode and editor_auto_dihedral → schedule EditorDrawDihedral
  • if !BondMode and editor_auto_measureEditorAutoMeasure (Editor.cpp:1764-1783):
    • 2 picks → ExecutiveDistance("_auto_measure", pk1, pk2, ...)
    • 3 picks → ExecutiveAngle("_auto_measure", pk1, pk2, pk3, ...)
    • 4 picks → ExecutiveDihedral("_auto_measure", pk1..pk4, ...)
    • then ExecutiveColor("_auto_measure", "gray", 0x1, true)
The Builder disables this on show (cmd.set("editor_auto_measure", 0), builder.py:1338).

7e. cmd.edit (editing.py:1080-1120)

One selection → picks an atom; two → picks the bond between them (when pkbond). cmd.unpick() (editing.py:991) deletes all pkN. Both are the web client’s handles for programmatic picking. Backend errors surfaced from EditorSelect / EditorCycleValence / EditorAttach (Editor.cpp:851, 878-914, 930-970): “Invalid input selection(s)”, “Only two picked selections allowed.”, “Both pk selections must belong to the same molecule.”, “Invalid bond.”, “No valid pk2 selection.”, “No valid pk1 selection.”, “Only 1 or 2 picked selections allowed.”, “Can’t attach atoms onto discrete objects.”, “Picked atoms must belong to the same object.”, “Could not attach atom.”, “Editor not active”, “Invalid pk selection”.

7f. Mouse modes touched by the Builder (packages/engine/modules/pymol/controlling.py:57-125)

cmd.button(button, modifier, action) action codes used by the Builder: PkAt=13 (controlling.py:71), PkBd=14 (:72), MovA=28 (:87). Buttons touched: single_left, double_left. cmd.edit_mode(1) (controlling.py:688-717) switches the current mouse ring entry from *_viewing to *_editing.

8. Complete cmd surface the Builder needs from the bridge

Grouped, all verified in this tree:
  • Picking / editor: edit, unpick, deselect, get_editor_scheme, torsion, drag
  • Bonds: bond, unbond, valence, cycle_valence, add_bond, rebond
  • Atoms: attach, replace, remove, remove_picked, fuse, set_geometry, fix_chemistry, invert, sort, rename
  • Hydrogens: h_fill, h_fix, h_add, protonate
  • Flags / restraints: flag, protect, deprotect, reference
  • Undo: undo, redo, push_undo (partially implemented in open source, editing.py:531)
  • Sculpting: sculpt_activate, sculpt_deactivate, sculpt_iterate, sculpt_purge
  • Minimization: clean (IncentiveOnly — raises)
  • Attribute editing: alter, alter_state, iterate, label, set_dihedral, translate, transform_object, get_object_matrix
  • Objects/selections: fragment, create, select, delete, get_names, get_names("selections"), get_object_list, get_chains, get_unused_name, count_atoms, index, get_coords, get_extent, get_position, get_state, enable, show, hide, center, zoom, pair_fit
  • Wizards: set_wizard, get_wizard, refresh_wizard, wizard, get_wizard_stack, set_wizard_stack
  • Settings: set, unset, get, get_setting_int, get_setting_boolean
  • Mouse: button, edit_mode
  • Command channel: cmd.do(...) — used by CleanWizard.run_job and doAutoPick, and by every wizard panel button (panel commands are strings).
  • Sequence builders (not in the panel, worth exposing): fab, fnab

9. Known defects in the source

  1. builder.py:417 calls editor.combine_monomer()this function does not exist in packages/engine/modules/pymol/editor.py (grep-verified; only wizard methods named combine_monomer exist at builder.py:402/491/511). BioPolymerWizard.do_pick mode 1 therefore raises AttributeError.
  2. builder.py:512 calls editor.combine_nucleotide(...)also does not exist in editor.py.
  3. BioPolymerWizard.get_panel (builder.py:458-471) never exposes a Combine w/ Existing Object entry, so combine()/mode 1 is unreachable from the panel — defects 1&2 are latent.
  4. BioPolymerWizard.get_panel hard-codes the title "Attaching Amino Acid" even for NucleicAcidWizard (builder.py:467).
  5. HydrogenWizard.get_prompt swaps singular/plural (builder.py:774-777).
  6. setUndoEnabled (builder.py:1310-1321) can pass the literal string "[N more]" to cmd.unset('suspend_undo', oname) when >20 objects have undo suspended.
  7. _BuilderPanel.attach uses a bare except: fin = -1 (builder.py:1369-1370) — silent failure, dead variable.
  8. _BuilderPanel.reset() (builder.py:1524-1525) is never wired to any widget — dead code.
  9. self.btn_icons (inverted ring icons) is populated but never used (builder.py:1114, 1125).
  10. AtomFlagWizard.get_panel’s verb dict is unused (builder.py:953).
  11. SculptWizard.sculpt_activate calls cmd.sculpt_activate(obj_name) twice (builder.py:146, 149).
  12. AtomFlagWizard.do_pick uses the double negative if not(active_sele not in ...) (builder.py:856).
  13. Tooltip typo "Chlorrine" (builder.py:1082); "Napthylene"/napthylene fragment misspelling throughout (builder.py:1109, and the on-disk napthylene.pkl).
  14. editor.attach_nuc_acid has an in-source FIXME about using selection as a name (editor.py:838-840), and extend_nuc_acid has a FIXME about the undocumented tmp_connect precondition (editor.py:867-868).
  15. packages/engine/data/chempy/fragments/utpA.pkl does not exist (all other {a,c,g,t,u}tp{A,B} and duplex fragments do). Only reachable if RNA form B is ever allowed; attach_nuc_acid currently forces RNA→form A (editor.py:803-805), so it is latent.

10. Where each surface lives now

  • <BuilderPanel/> — a dockable/floating panel. Tabs = Chemical | Protein | Nucleic Acid; the last contains nested Tabs DNA | RNA. Below the tabs, three fixed toolbars.
  • Button data lives in one declarative table mirroring builder.py:1074-1112, 1132-1135, 1176-1210, 1226-1265. Each entry: {label, tooltip, icon?, action: {kind, args}}. The React layer never contains chemistry logic.
  • useBuilderStore holds only UI state: active tab, SS combo index, DNA form (‘A’|‘B’), helix (single|double), and mirrored settings (clean_electro_mode, sculpt_vdw_vis_mode, suspend_undo). Settings mirror through the bridge’s setting-change event so external set commands stay in sync.
  • usePickedAtoms() — subscribes to an editor-state event carrying {pk1?, pk2?, pk3?, pk4?, bondMode, nFrag} derived from EditorAsPyList/get_names("selections"). Every action button branches on this exactly like collectPicked.
  • <WizardOverlay/> — renders get_prompt() lines + get_panel() rows over the WebGL canvas, re-fetched on every refresh_wizard event. type==1 rows are non-interactive headers; type==2 rows dispatch cmd.do(command).
  • Two confirm dialogs must be modeled as React modals (they are currently QMessageBox.question): “Really delete everything?” (builder.py:1529) and “Enable Undo for these objects?” (builder.py:1315).
  • PopupOnException (pymol/Qt/utils.py, used at builder.py:609 and :1497) becomes an error-toast wrapper around the two invert paths.
  • Feedback pane — the builder relies heavily on print() diagnostics from editor.py; the bridge must forward PyMOL’s feedback stream and the panel must show it.

11. Decisions this map fed

  1. cmd.clean stays incentive-only. It raises IncentiveOnlyException in this tree (computing.py:20), so the bridge lists it in packages/bridge/tenmol_bridge/incentive_only.py and the client shows the button as unavailable rather than failing at click time. No open-source minimiser was substituted behind the same signature.
  2. undocontext is a no-op in this tree (editor.py:38-49), so most undoablemethod-decorated actions are not actually undoable; only cmd.undo/cmd.redo against the C ring buffer work. The port keeps parity with open-source rather than inventing a bridge-side undo context.
  3. Sculpting is not streamed on a client timer. PyMOL_Idle calls ExecutiveSculptIterateAll(G) whenever ControlIdling(G) is true (packages/engine/layer5/PyMOL.cpp:2424, packages/engine/layer1/Control.cpp:397-403), and the bridge’s pump calls idle() every tick — so the engine sculpts with no client attached (measured: 0.68 A of drift in 2.0 s with no tick, no subscriber and no draw request; 0.0000 A after set sculpting, 0). Ticking sculpt_iterate with cycles on the client ran a second minimiser beside the engine’s own, so apps/web/src/features/builder/sculptTicker.ts defaults to cycles: 0 — a call that returns total strain and provably moves nothing. Moved atoms reach the user the same way every other engine-side change does.
  4. The ss combo exposes what the Qt combo exposes. ss=4 (flat) and raw secondary_structure values stay out of the panel; they remain reachable from the command line.
  5. combine_monomer / combine_nucleotide stay dead. Both would need new backend functions, so the panel entries remain absent, as they are in Qt.