The molecular builder
The Builder dock widget and the molecular editor state machine behind it, read out ofpackages/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 issuescmd.* 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:
- 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_selectcallbacks 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 awizard_refreshevent whenevercmd.refresh_wizard()is called. - Picking. The editor’s
pk1..pk4selections 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 sameEditorSelectpath (exposed ascmd.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)
cmd.set(name) with no value sets 1.
2. Tab structure
QTabWidget with three top-level tabs (builder.py:1068-1070):
- “Chemical” —
QGridLayout, 3 rows of fragment/element buttons. - “Protein” —
QGridLayout, 2 rows of residue buttons + a secondary-structureQComboBoxon row 2. - “Nucleic Acid” —
QVBoxLayoutcontaining a nestedQTabWidgetwith sub-tabs “DNA” and “RNA” (builder.py:1164-1172).
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):
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
selectionis not an existing named selection →cmd.fragment(fragment)creates a new object (errors if an object of that name exists); thencmd.remove("(hydro and <fragment>)")ifauto_remove_hydrogens. - Else →
cmd.fragment(fragment, tmp_name, origin=0),cmd.fuse(f"{tmp} and id {hydrogen}", f"({selection})", 1), thencmd.remove("(hydro and pkmol)")ifauto_remove_hydrogenselsecmd.h_fill()whencmd.count_atoms('hydro and (neighbor pk2)'), finallycmd.delete(tmp). anchoris 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):
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 < 0→hydro = not auto_remove_hydrogens.- New-object branch (selection empty / 0 atoms):
cmd.fragment(amino_acid, object);cmd.remove("(hydro and <obj>)")ifnot hydro; thencmd.edit("((obj) and name C)")if a C exists elsecmd.edit("((obj) and name N)"). Errors if an object with that name already exists. - Validation errors (each prints and raises
QuietException, aftercmd.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 notelem C→ “invalid connection point: must be C for residue ‘<x>’”amino_acid == "ace"and the picked atom is notelem 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 residueresi = 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)")ifnot hydro;cmd.set_dihedral(CA/CH3', pk2, pk1, CA/CH3, 180.0)(omega);cmd.h_fix(tmp2)if hydro; PHI/PSIcmd.set_dihedral(skipped forpro*); re-cmd.editon the new terminal N; optionalcmd.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 fornhh(set_dihedral(O, C, N, H1, 180)), PHI/PSI, thencmd.editnew terminal C orcmd.unpick(). - Always finishes with
cmd.delete("_tmp_editor*").
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 isnuc_acid + "_" + _base_pair["DNA"][nuc_acid] + form(e.g.atp_ttpB). Base-pair tables ateditor.py:412-416. - New object (
count_atoms(selection)==0):cmd.fragment(frag_string, object)for the duplex, orcmd.fragment(fragment_name, object, origin=0)+cmd.alter(object, "segi='A';chain='A';resv=1")+rename_three_to_onefor single strand; RNA additionally callsadd2pO; thencmd.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): readschain,name,modelviaiterate_to_list. If the picked atom isO5'→attach_O5_phosphate()first. Accepts onlyPorO3', else raises"invalid connection point: must be one atom, name O3' or P". - Always ends with
cmd.show("cartoon", f"byobject {selection}")andcmd.delete("_tmp_editor*"). extend_nuc_acid(editor.py:856-1060) does: chain→segi normalization viacmd.alter, fragment load, resv ±1,check_valid_attachment(raises"P already bonded!"/"O3' already bonded!"),move_new_res(cylindrical twist/rise: B-formtwist=-36.0, rise=-3.375; A-formtwist=-32.7, rise=-2.548,editor.py:541-548; raisesValueError("Form not recognized")for anything else),cmd.fuse(..., mode=3),cmd.bond(...)viabond_single_stranded/bond_double_stranded,cmd.pair_fitinfit_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,Z→ZA,z→za), bond-distance sanity check (within 3.0).attach_O5_phosphate(editor.py:612-646) prints “This building selection has an unphosphorylated O5’ end.”, callsattach_fragment("pk1","phosphite",4,0), then a fixed sequence ofcmd.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 forutp),cmd.edit(C2')+cmd.attach("O",4,4)+cmd.alter(... name O01 ..., "name=\"O2'\"").
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")):
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):
"[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)actionHashdefaults tostr(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); elsecmd.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; ifpk1exists →cmd.select("_builder_active","byobj pk1"); else if exactly one object is enabled → select it.
RepeatableActionWizard(builder.py:228-263): addsrepeatingflag,repeat(),getRepeating(),activateRepeatOrDismiss()(first click arms + always sets repeating=1 — seebuilder.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==1is a title row,type==2is a clickable button whosecommandis a PyMOL command string evaluated by the engine. Prompt fromget_prompt()is a list of strings drawn in the viewport. - Event mask default is
event_mask_pick + event_mask_select(wizard/__init__.py:56).
{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(); ifactiveSeleValid()→run_job().run_job(): if_builder_activenames 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_activetobyobj pk1,cmd.unpick(),cmd.deselect(), thenrun_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.cleanraisespymol.IncentiveOnlyExceptionin 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; thencmd.push_undo(obj),cmd.sculpt_activate(obj),cmd.set("sculpting", 1),cmd.sculpt_activate(obj)(called twice — redundant), and ifcmd.get("sculpt_vdw_vis_mode")is truthy →cmd.show("cgo", obj); thencmd.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; elsereturn 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; computesradius = 1.25 * cpv.length(cpv.sub(extent[0], extent[1]))fromcmd.get_extent, center fromcmd.get_position, thencmd.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);Undo→cmd.undo();Switch Object→cmd.get_wizard().sculpt_deactivate();Scramble Unrestrained Coords.→scramble(0);Scramble Unfixed Coords.→scramble(1);Done→finish_sculpting(). cleanup()→sculpt_deactivate().- Relevant settings:
sculpting(SettingInfo.h:246),sculpt_field_mask(:259, default0x1FF),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)setsactionHash = (symbol, geometry, valence, text)→activateRepeatOrDismiss().do_pick:cmd.select("_builder_active","bymol pk1"),cmd.replace(symbol, geometry, valence), thenactionWizardDone()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 Atoms→repeat(),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-96→cmd.fragment(f, "_tmp_editor0"), optionalcmd.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 whenself._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_pickmode 0:cmd.select("_builder_active","bymol ?pk1")thenwith undocontext(cmd,"bymol ?pk1"): self.attach_monomer(), catchingpymol.CmdExceptionand printing it.do_pickmode 1:cmd.select("_builder_active","bymol ?pk1")theneditor.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 beforetoggle().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')andcmd.button('single_left','none','PkBd').do_pick(bondFlag)(decorated@undoablemethod("(?pk1 ?pk2) extend 1")):cmd.select("_builder_active","bymol pk1"); ifbondFlag→cmd.valence(order,"pk1","pk2")+cmd.h_fill()whenint(order) >= 0, elsecmd.cycle_valence(); ifnot bondFlag→ re-armPkBdon double_left and single_left. Thencmd.unpick().cleanup()restorescmd.button('single_left','none','PkAt')andcmd.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(); thencmd.label("pk1","'''<text>'''")ifabs(float(charge)) > 0.0001elsecmd.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 whencollectPicked() == ["pk1","pk2","pk3"]→cmd.invert()+cmd.unpick(); alwayscmd.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:
"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()forcescmd.button('single_left','none','PkBd');cleanup()restoresPkAt.do_pick(@undoablemethod("(?pk1 ?pk2) extend 1")):cmd.select("_builder_active","bymol pk1"); ifbondFlag→cmd.unbond("pk1","pk2")+cmd.h_fill()+cmd.unpick(); else re-armPkBd+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)withmode ∈ {'fix','add'}, hash(mode,).'add'usesactivateOrDismiss+activeSeleValid+ immediaterun_add();'fix'usesactivateRepeatOrDismiss.run_add():cmd.h_add("_builder_active")thencmd.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 Hydrogenstitles, plusFix Multiple Atoms/Add To Multiple...when not repeating, plusDone.
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 inpk1only:EditorInactivate→SelectorCreate(pk1, "<obj><index+1>“)→EditorActivate(state, enable_bond=false)→EditorDefineExtraPks()→WizardDoPick(0, state). Logscmd.edit(“<sele>“,pkresi=1)`.cButModePickAtom(PkAt): multi-pick; leaves bond mode if active, unpicks if already picked, otherwiseEditorGetNextMultiatom→SelectorCreate(name, ...)→EditorActivate(state, false)→EditorDefineExtraPks()→EditorLogState(false)→WizardDoPick(0, state). Feedback: “You clicked <atom> -> (pkN)”.- Bond picking (
PkBd) goes throughSceneClickPickBond(SceneMouse.cpp:487+) and setspk1+pk2withBondMode=true→ wizards receivedo_pick(bondFlag=1). ObjectMolecule.cpp:3428callsEditorSelect(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
BondModeandeditor_auto_dihedral→ scheduleEditorDrawDihedral - if
!BondModeandeditor_auto_measure→EditorAutoMeasure(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)
- 2 picks →
cmd.set("editor_auto_measure", 0), builder.py:1338).
7e. cmd.edit (editing.py:1080-1120)
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.
7g. Editor-related settings
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 byCleanWizard.run_jobanddoAutoPick, 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
builder.py:417callseditor.combine_monomer()— this function does not exist inpackages/engine/modules/pymol/editor.py(grep-verified; only wizard methods namedcombine_monomerexist atbuilder.py:402/491/511).BioPolymerWizard.do_pickmode 1 therefore raisesAttributeError.builder.py:512callseditor.combine_nucleotide(...)— also does not exist ineditor.py.BioPolymerWizard.get_panel(builder.py:458-471) never exposes aCombine w/ Existing Objectentry, socombine()/mode 1 is unreachable from the panel — defects 1&2 are latent.BioPolymerWizard.get_panelhard-codes the title"Attaching Amino Acid"even forNucleicAcidWizard(builder.py:467).HydrogenWizard.get_promptswaps singular/plural (builder.py:774-777).setUndoEnabled(builder.py:1310-1321) can pass the literal string"[N more]"tocmd.unset('suspend_undo', oname)when >20 objects have undo suspended._BuilderPanel.attachuses a bareexcept: fin = -1(builder.py:1369-1370) — silent failure, dead variable._BuilderPanel.reset()(builder.py:1524-1525) is never wired to any widget — dead code.self.btn_icons(inverted ring icons) is populated but never used (builder.py:1114, 1125).AtomFlagWizard.get_panel’sverbdict is unused (builder.py:953).SculptWizard.sculpt_activatecallscmd.sculpt_activate(obj_name)twice (builder.py:146, 149).AtomFlagWizard.do_pickuses the double negativeif not(active_sele not in ...)(builder.py:856).- Tooltip typo
"Chlorrine"(builder.py:1082);"Napthylene"/napthylenefragment misspelling throughout (builder.py:1109, and the on-disknapthylene.pkl). editor.attach_nuc_acidhas an in-sourceFIXMEabout usingselectionas a name (editor.py:838-840), andextend_nuc_acidhas aFIXMEabout the undocumentedtmp_connectprecondition (editor.py:867-868).packages/engine/data/chempy/fragments/utpA.pkldoes 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_acidcurrently 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 nestedTabsDNA | 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. useBuilderStoreholds 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 externalsetcommands stay in sync.usePickedAtoms()— subscribes to an editor-state event carrying{pk1?, pk2?, pk3?, pk4?, bondMode, nFrag}derived fromEditorAsPyList/get_names("selections"). Every action button branches on this exactly likecollectPicked.<WizardOverlay/>— rendersget_prompt()lines +get_panel()rows over the WebGL canvas, re-fetched on everyrefresh_wizardevent.type==1rows are non-interactive headers;type==2rows dispatchcmd.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 atbuilder.py:609and:1497) becomes an error-toast wrapper around the two invert paths.- Feedback pane — the builder relies heavily on
print()diagnostics fromeditor.py; the bridge must forward PyMOL’s feedback stream and the panel must show it.
11. Decisions this map fed
cmd.cleanstays incentive-only. It raisesIncentiveOnlyExceptionin this tree (computing.py:20), so the bridge lists it inpackages/bridge/tenmol_bridge/incentive_only.pyand the client shows the button as unavailable rather than failing at click time. No open-source minimiser was substituted behind the same signature.undocontextis a no-op in this tree (editor.py:38-49), so mostundoablemethod-decorated actions are not actually undoable; onlycmd.undo/cmd.redoagainst the C ring buffer work. The port keeps parity with open-source rather than inventing a bridge-side undo context.- Sculpting is not streamed on a client timer.
PyMOL_IdlecallsExecutiveSculptIterateAll(G)wheneverControlIdling(G)is true (packages/engine/layer5/PyMOL.cpp:2424,packages/engine/layer1/Control.cpp:397-403), and the bridge’s pump callsidle()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 afterset sculpting, 0). Tickingsculpt_iteratewith cycles on the client ran a second minimiser beside the engine’s own, soapps/web/src/features/builder/sculptTicker.tsdefaults tocycles: 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. - The
sscombo exposes what the Qt combo exposes.ss=4(flat) and rawsecondary_structurevalues stay out of the panel; they remain reachable from the command line. combine_monomer/combine_nucleotidestay dead. Both would need new backend functions, so the panel entries remain absent, as they are in Qt.