Structure Editor Design
How structure editing (Megane Builder, the standalone editor at
/builder.html that the VS Code and JupyterLab extensions and the
megane.Builder Jupyter widget also embed, and the edit engine it shares
with the viewer's load_structure node) is built, why it lives where it does, and what is
deliberately left for later. Read this before extending the editor or wiring
it to a simulation backend.
Where it lives
Editing is implemented inside Megane — a second app entry in the same
repository (builder.html → src/builder/) on top of a shared edit engine —
not as a separate repository and not inside a simulation engine:
- It is tightly coupled to Megane internals that are not public API — the
Snapshotlayout, screen-space picking (src/renderer/Picking.ts), the renderer facade, and the zustand store bundle. A separate repository would first have to expose all of those. - A live-MD service (such as
livemd) treats topology as fixed for the life of a session: itsTopologyis sent once per connection and the OpenMM context cannot grow after creation. Editing there would mean rebuilding the session, which is the right integration (export the edited structure, start a new session) but the wrong place for the editor itself.
The division of labour is: Megane builds and repairs the structure before it is simulated; a simulation backend only needs a way to accept a new structure.
The edit list lives on the loader (rule #11 in AGENTS.md)
Parsers read files as-is, so edits are never applied inside a parser. They are
also not a node of the view pipeline: the graph describes how a structure is
shown, while an edit changes what it is. The two are kept apart by storing
the ordered operation list (EditOp in src/pipeline/types.ts) on the primary
load_structure node (LoadStructureParams.edits, helpers in
src/pipeline/editHistory.ts). The loader executor
(src/pipeline/executors/loadStructure.ts) replays the list on the loaded
Snapshot through applyEditOps (src/pipeline/executors/edit.ts) and emits
the edited structure as a brand-new immutable Snapshot, so every downstream
node only ever sees the edited atoms and the graph gains no node. This gives
undo (drop the last op), preview (editsBypassed on the
PipelineExecutionContext in src/pipeline/execute.ts, which shows the file
as loaded; no viewer UI sets it today, and the Builder's Show original is its
own store flag), sharing (the list rides in the .megane.json with the
loader) and LLM authoring (edits is documented on load_structure in
NODE_CATALOG) without any new persistence path.
An earlier iteration made the history a standalone edit node spliced after
the loader. It worked, but it put "what the molecule is" and "how it looks" in
the same graph, and the node had to be placed and re-wired by the panel; the
loader-level list removes both problems.
Atom identity
Ops never refer to atoms by their position in the current array, which would
shift on every deletion. An EditAtomRef is either
- a number — an index into the structure as the file declares it, or
- a string — the id assigned by an earlier
add_atom(id) oradd_fragment(<fragmentId>:<k>) op in the same list.
The executor keeps a working copy keyed by ref, rebuilds the ref → index map
after deletions, and materialises indices only when it builds the output
Snapshot. The working copy is typed arrays plus an implicit ref table (the
atoms of the file, or of the last whole-structure op, are addressed by their
index in it; only atoms an add_atom / add_fragment created carry an
explicit ref), so a supercell or a slab of a million atoms costs bytes per
atom rather than a string and a map entry each. It also reports, per output
atom, which ref it came from (EditResult.refAt(i), or the materialised
EditResult.outputRefs list); the Builder uses that to translate a click on
rendered atom i into the ref an op must name.
Because refs address the file's atoms, the list is replayed before anything
else runs. Nodes that change the atom count (replicate, symmetry) come
after, so atom indices rendered downstream of them are not refs. The viewer
has no editing UI that could write ops against them (the pipeline store's
pushEditOp / undoEditOp actions have no caller in the viewer); editing
happens in the Builder, whose view has no pipeline and maps every rendered
atom back through refAt. The
history belongs to the file it was authored against: updateNodeParams clears
a loader's edits when its fileName changes, and an op that no longer
applies is skipped with a warning on the loader.
What the executor guarantees
- Pure: the input
Snapshotis never mutated. - Tolerant: an unknown ref or malformed op is skipped and reported as a node warning; one bad op never blanks the structure.
- Complete: chain ids, B-factors and the Cα backbone arrays are carried and
remapped;
nFileBondsis set to the full bond count because every surviving bond is now asserted by the edit. Nothing upstream carries per-atom overrides or selections (the loader is the source), so none need remapping. - Cell-aware:
set_cellreplaces or removes the box (and, withscaleAtoms, keeps the atoms' fractional coordinates) and the loader emits the editedcellstream. Thetrajectoryoutput follows the file: its frames index the atoms as loaded.
Whole-structure ops (crystal tools)
supercell, slab and expand_symmetry do not edit atoms one by one: they
replace the working structure. The executor materialises the working copy
as a Snapshot, runs the transform (src/crystal/transform.ts —
makeSupercell, buildSlab — or the Symmetry node's own expandSymmetry),
and rebuilds the working copy from the result with every atom re-keyed as
<id>:<k>. Refs written before such an op therefore stop resolving after
it (a later delete_atoms: [0] is skipped with a warning), which is
intended: the Builder always addresses the structure it currently shows, and
the alternative — tracking each source atom through a supercell so that
"atom 3" means eight atoms — would make later ops ambiguous. wrap and
center only move atoms (and, with a vacuum, resize the cell) and keep
every ref. All of them mark the file's space-group operations as consumed
(symmetryOps is dropped from the output), so the viewer's Symmetry node
does not expand an already expanded or re-celled structure a second time.
The transforms live in src/crystal/ because they are pure Snapshot →
Snapshot functions with no Builder or pipeline dependency: cell.ts
(parameters ⇄ vectors, fractional ⇄ Cartesian, the integer helpers),
bulk.ts (the prototype structures behind New bulk crystal) and
transform.ts. They are TypeScript ports of ASE — bulk, make_supercell,
ase.build.surface (the general_surface construction, extended Euclid
included, so slabs come out identical), Atoms.wrap, Atoms.center — and
ASE is the test oracle, never a runtime dependency:
scripts/gen-crystal-fixtures.py records what ASE produces for a fixed set
of inputs into tests/fixtures/crystal/ase-oracle.json, and
tests/ts/crystal/ compares atom for atom (modulo lattice translations for
atoms that sit exactly on a cell face, where float32 and float64 wrap to
different sides). Bonds survive a transform by geometry rather than by index
bookkeeping: every source bond is a minimum-image vector, and it is re-drawn
wherever an image of its first atom has an image of its second atom at that
vector modulo the periodic axes of the result — so a supercell keeps every
bond (boundary bonds reach into the neighbouring image, as the Replicate
node draws them) and a slab drops the ones that would cross the vacuum.
The Builder app and the 3D view
The Builder is its own application, not a panel or a mode of the viewer.
Building changes what a structure is; the viewer's pipeline describes how a
structure is shown, and every attempt to host editing inside the viewer
(a tab in the Pipeline panel, a stacked panel, an exclusive "edit mode" that
swapped the view) ended up with the two competing for the same column and
users unsure which of them the picture reflected. So src/builder/ mounts
its own page (builder.html, a Vite entry beside index.html and the
multi-instance harness). It is laid out in the viewer's design language — the
3D view fills the window and every control floats over it on the viewer's
frosted-glass surfaces (src/components/toolbarStyles.ts, shared with the
viewer's Pipeline panel) — so that each control has exactly one home, with
every action on the left, the options of what was picked on the right, and
what is on screen along the top. On the left: the viewer's Reset View and
ViewAxisControls in the top-left corner with a floating tool rail under
them (ToolRail.tsx, one icon per tool with its key) and, under that, the
operations rail (OperationsRail.tsx: the File menu for the document
(Open, New, Save; topbarMenus.ts); the Structure and Tools
menus for what acts on the structure (crystal/structureMenu.ts,
tools/ToolServer.tsx); Undo / Redo; the viewer's ThemeCycleButton — its
lists opening to the right). Along the top, beside Reset View where the
viewer has its HUD, the info line (InfoHud.tsx: the document's name and
edit count, then formula, atoms and bonds, molar mass and cell). On the
right, two of the viewer's CollapsiblePanels: Details shows the
options of whatever was picked on the left — the current tool's settings
(ContextBar.tsx, only the settings that tool uses — none for Move /
Delete; select-by-element chips for Select; the molecule gallery for
Place), the selection (Inspector.tsx: the selected atoms with their
positions, distance / angle / dihedral and actions) and the symmetry offer
(crystal/SymmetryOffer.tsx), or instead, while one is open, the form a
menu item opened (CrystalDialog, NewStructureDialog, ToolServerDialog,
ToolDialog, all plain cards styled by detailCardStyle; one at a time,
and opening one reveals a folded panel) — and History (HistoryPanel.tsx)
under it the edit list. A status line at the bottom left, right of the
rails, says what the tool does (toolHint, the one place the hint is
written), with one notice line above it for every message the app has to
show (BuilderStore.notice, written through reportError / reportInfo so
no panel keeps an error line of its own). The menus (Menu.tsx) portal
their lists to <body> so no panel or scrolling rail clips them.
useSectionOpen (src/builder/panelState.ts) keeps whether each panel is
open and remembers each one in localStorage; shortcuts.ts holds the key table
(resolveShortcut) and applies it to the store (runShortcut), ignoring keys
aimed at a text field or while the (modal) Ketcher sketcher is open, and —
on a host that asks for it — keys pressed while the focus is outside the
Builder (inScope). Starting
a document — an empty cell or a bulk crystal — is one form
(NewStructureDialog), because both replace
what is open. The viewer imports no Builder app code — only the
BuildHandlers type its Viewport accepts and the rail primitives
(Menu.tsx, rail.tsx) its Pipeline rail shares; the Builder reuses
the viewer's renderer, parsers, writers and edit engine. Bringing a Builder
document into the viewer is the planned integration, and the shared history
format (below) is its seam.
Hosts. Every host puts the app on its page through mountBuilder
(src/builder/mount.tsx): builder.html (src/builder/index.tsx), the VS
Code webview (vscode-megane/webview/builder.tsx, an editor tab opened from
the Megane activity-bar view), the JupyterLab main-area tab
(jupyterlab-megane/src/builder/, opened from the Megane left-sidebar panel)
and the anywidget bundle behind megane.Builder (src/builderWidget.ts,
python/megane/builder.py). The host passes a BuilderHost
(src/builder/host.ts) that says how Save delivers a file (save: VS
Code's save dialog, the Jupyter server's contents, the kernel's working
directory; the web app downloads), whether the Tools menu is shown
(tools) and, for a page that cannot reach an MCP server itself, a
toolBridge through which the host makes the connections (VS Code's
extension host, JupyterLab's megane.builder_tools server extension; only
the notebook widget leaves the menu out with tools: false), whether the
keyboard shortcuts stay inside the Builder (scopeShortcuts), and whether
the page is the Builder's own (standalone: the web app alone warns before
the tab closes with unsaved edits and keeps an autosave). A Builder's
document lives in a bundle of stores (src/builder/BuilderStores.tsx: the
document, the library's dialogs, the Python tools, the Flow graph). Without a BuilderProvider it is the module-level bundle,
one Builder document per page, which is how builder.html, VS Code (which
gives each tab a webview of its own) and the widget bundle (loaded once per
Builder object, whose newest output takes the document) mount it.
JupyterLab's tabs share one page, so each mounts with createBuilderStores()
and edits a document of its own; the molecule library stays page-wide.
Flow mode. A mode switch at the top of the left rails
(src/builder/flow/ModeSwitch.tsx) picks Manual — everything above — or
Flow, where a model is a graph of blocks (src/builder/flow/): a molecule
from SMILES or the library, the Manual structure, the built-in crystal and
polymer blocks, and every tool of a connected tool server. In Flow mode the
tool and operations rails are hidden and FlowPanel, with its own rail
(FlowRail.tsx, as the viewer's Pipeline panel has), replaces Details and
History; the panel is built from the
viewer's shared graph kit (src/components/graph/), the same canvas, node
cards, wires and properties pane the Pipeline panel uses. FlowPanel is
loaded with React.lazy the first time Flow mode opens, so the Builder page
does not load React Flow until then. Run builds the blocks in order
(engine.ts) and caches each result under its settings and inputs; Open in
Manual makes the picked block's result the Manual document. Flows are saved
and opened as .megane-flow.json (serialized by the flow store,
flow/store.ts), delivered through the host's save like a structure;
persist.ts also keeps the last flow in localStorage for the next visit.
Document. useBuilderStore (src/builder/store.ts) holds a source
Snapshot (the opened file, or emptyCellSnapshot(edge) for New), its
per-atom labels and file name, the ordered edits: EditOp[], a redoStack,
the Show original flag, and result = applyEditOps(source, edits)
recomputed on every change. The source is never mutated; what the view shows
(shownSnapshot) is the result, or the source alone under the preview. Undo
pops the last op onto the redo stack, Redo pushes it back, any new op clears
the stack; setDragOps(base, ops) rewrites everything a drag in progress
has written since base (as one Undo step). A
revision counter bumps on every change that reshapes the rendered
structure without replacing the document, and the app hands it to
Viewport.preserveCameraKey so edits never move the camera while a new file
or cell still re-fits. Manual mode has no pipeline graph; Flow mode (below)
is a separate block graph whose output becomes the Manual document.
View. builderViewportState(shown) (src/builder/view.ts) turns the
shown snapshot into a ViewportState: one particle stream with every atom,
the structure's own bonds drawn straight from the snapshot regardless of
nFileBonds (bondDataFromSnapshotBonds, so parser-inferred bonds and the
ones the user drew are shown alike, PBC half-bonds included), the cell, no
trajectory, no overlays, ball-and-stick. BuilderApp drives the renderer
through the same applyViewportState the viewer uses, so the Builder does not
fork the renderer.
Clicks. The camera takes the mouse exactly as in the viewer (OVITO's
left-rotate, right / Shift + left pan, middle / wheel zoom, double-click to
set the rotation center); the Builder only claims the left drags it can use.
useBuilderHandlers installs pick, pressAction, dragStart, dragMove,
dragEnd and boxSelect as BuildHandlers (src/builder/types.ts), and
the app passes them to the Viewport with buildActive permanently on:
- a left press asks
pressAction(in the capture phase on the container, so before the camera controls see it) whether a drag from there moves atoms (Select on a selected atom, Move on any atom), draws a rubber band (anywhere else with Select while its Box is on) or belongs to the camera (everything else, or Alt held). For the first two the controls are suspended until the release; - once the pointer moves past the click slop, a move calls
dragStartwith the modifiers and thendragMovewith aBuildDragGesture— the pointer in viewport pixels, the camera's axes, the world size of a pixel at the atom's depth and a projector (MoleculeRenderer.dragGesture); a rubber band ends inboxSelect(indices, "replace" | "add" | "subtract"); - a press that barely moves is a click, reported to
pickwith the atom index or, on empty space, the world point at the pivot's depth; - a double-click is the viewer's re-centre, and a right-click opens the atom menu on the release, so a right drag stays a pan.
src/builder/manipulate.ts turns a gesture into displacements as a pure
function of the positions at the press: dragModeFor picks translate (no
modifier), depth (Shift), trackball rotate (Ctrl/⌘) or spin about the line of
sight (Ctrl/⌘ + Shift); an axis key pressed mid-drag (axisVector: x / y / z,
or the cell's a / b / c) locks moves along it and turns about it; Snap
rounds to SNAP_DISTANCE / SNAP_ANGLE; closeContacts lists non-bonded
pairs nearer than CLOSE_CONTACT_FACTOR of their van der Waals radii, which
the app draws in red through the renderer's warning layer. The renderer's
SelectionController keeps three highlight layers by atom index — the
selection (green), the hover mark (builder/hover.ts picks the colour of what
a click would do) and the warnings — and redraws them after every snapshot
and frame, so they follow atoms as they move.
Crystal. The operations rail's Structure menu (crystal/structureMenu.ts)
lists every cell and crystal op: Wrap, Remove cell and Expand
symmetry push their op at once; Set cell, Center with vacuum,
Supercell and Cut slab open crystal/CrystalDialog.tsx in the Details
panel, one form per op. While a form describes a valid op
the dialog hands it to BuilderStore.setPreview, which applies it to the
edited structure without committing it; the view draws viewSnapshot (the
preview when there is one), canEdit is false so clicks are paused, and any
change to the document drops the preview. Apply pushes the op with a fresh
fragment id. Results above PREVIEW_MAX_ATOMS are announced but not
previewed, since building the preview is the cost of every keystroke. The
current cell is on the info line; the Details panel keeps only the one-time
symmetry offer (crystal/SymmetryOffer.tsx). The bulk-crystal form (crystal/BulkForm.tsx) is not in the
menu, because BuilderStore.newBulk starts a new document rather than
editing the open one; it lives in the New structure form beside the empty
cell. The slab form runs slabPreview on the shown structure to state the
atom count and thickness before the op is written. The Place
tool's Place on atoms option (adsorbHeight on the store,
adsorptionSite in placement.ts) stamps a library molecule a given
height above a clicked atom along the cell's c axis, which with a slab is
the surface normal — an adsorbate as one add_fragment op.
Hydrogens. src/builder/hydrogens.ts plans the hydrogen bookkeeping of
the Add and Element tools (valence-rule hydrogen counts and their 3D
placement, addOnAtomOps / setElementOps) as ordinary ops pushed as one
Undo step; a structure without any hydrogen is left bare.
Geometry. src/builder/geometry.ts holds what moves together
(moleculeOf, and movingSide — the far side of a bond, or only the atom
when a ring joins the sides), the numeric edits (setDistance, setAngle,
setDihedral, which return a displacement per atom) and superpose, a
Kabsch fit by Horn's quaternion method. BuilderStore.moveAtoms writes
displacements as move_atoms ops — one per distinct displacement, pushed as
one Undo step — so numeric edits and the clean-up need no new op in the
load_structure edit vocabulary. src/builder/cleanup.ts is Clean up
geometry: megane-rdkit only exposes embedding, so each molecule goes to
the existing worker (library/embed.ts) as a V2000 block with addHs off
(RDKit then keeps our atom order), and the conformer is superposed back on
the old positions. On the way in, unwrappedPositions makes a molecule
split across the cell whole (minimum image along its bonds) and
inferredCharges writes M CHG for over-bonded N / P / O / S / B, and a
molecule RDKit rejects is counted and left alone rather than failing the
rest. editSteps groups the history into Undo steps (an op and
its continuations) for the History list and the edit counts; the op list
itself is unchanged. The right-click menu is AtomMenu.tsx (clampMenuPosition keeps it on
screen); box selection is the Builder's own rubber band (above); the viewer's
Inspector keeps boxSelectActive / onBoxSelect.
The element picker's periodic table is PeriodicTable.tsx (tablePosition),
a popover portalled beside the Details panel, tinted by element family
(elements.ts: ELEMENT_NAMES, elementFamily), with the pointed element
described in the table's empty top bay.
addFragment with no document opens an emptySnapshot() (no cell) first, so
the Place tool and its gallery work before anything is open. The 3D view's
background follows the theme store (themeToHex), as in the viewer. As in
the viewer, the frustum is inset by the panel's width while it is open
(setViewInsets), so the structure centres in the part of the view left of
the panel, and a
Structure preview starting or ending refits the camera (resetView, which
keeps the orientation).
Library. src/builder/library/ holds the molecule library the Place
tool's gallery offers (and the Inspector's Save as
fragment): presets.ts (small molecules with 3D geometries), a persisted
useLibraryStore (store.ts, the user's molecules in localStorage,
sanitized on read), fragment.ts (centring, Hill formulas, the flat-sketch
rescale, autoPlacement, and fragmentOp — the add_fragment op a
placement writes), sketch.ts (a molfile, file or selection → library
molecule, through the shared parsers), the actions and the shared UI state
(ui.ts: useLibraryActions, and useLibraryUi for the one sketch dialog,
the one file input and the gallery's open state, mounted once by
LibraryHost), and the UI (LibraryPanel, the gallery inside the Place
tool's settings, and SketchModal). Ketcher (ketcher-react + the standalone Indigo engine) is
mounted only by KetcherEditor.tsx, which SketchModal loads lazily, so the
sketcher's bundle and WASM are fetched on first use and the Builder itself
stays small. A molecule enters the document through
BuilderStore.addFragment, either at autoPlacement (the Add button) or
where the Place tool's click landed (BuildHandlers.pick with
placeSource set); the new atoms are selected so a Move drag carries them
together.
Python tools. Liquid boxes, polymer chains, solvation and other
generators that already exist in Python reach Builder as buttons backed by
MCP servers rather than as TypeScript ports: src/builder/tools/ is the
client of the Builder Tool Contract (MCP) behind the
Tools menu (web app, VS Code and JupyterLab; not the notebook widget —
see Hosts). A tool that adds to the open
structure writes its atoms as one add_fragment op (after a set_cell when
the result's cell differs from the document's), pushed as one Undo step; a
tool that builds a new document replaces the open one.
Rendered atom indices are translated to op refs through result.refAt
before an op is written, so an op always names the atom the user saw. A drag
recomputes its ops from the positions at the press on every move and writes
them with setDragOps — one move_atoms op per distinct displacement
(moveOps: one for a translation, one per atom for a turn) — so the history
gains a single step; a drag that moves nothing, or is cancelled with Esc,
leaves the history and the redo stack as they were. The Builder's keyboard
shortcuts pause while BuilderStore.drag is set, so the axis keys reach the
drag.
Sketch embedding (RDKit)
A Ketcher sketch is a flat molfile; the library turns it into a 3D molecule
with RDKit and with nothing else — no conformer, and no hydrogen placement,
is ever invented in TypeScript. RDKit runs in the browser as
megane-rdkit, an Emscripten
build of an unmodified RDKit release that exposes exactly one call today —
embed(molfile, options) (a mol block or a SMILES string): ETKDG embedding with the implicit hydrogens
added, then MMFF94s (UFF fallback) minimisation, returning a V2000 mol
block. The build lives in its own repository because it needs emsdk, Boost
and a C++ toolchain nothing else in Megane touches; Megane depends on the
published npm package and Vite ships its .wasm (~3.5 MB, ~1 MB gzipped) as
an asset that is fetched only when the sketch dialog or Clean up geometry
first needs it. The Jupyter widget bundle leaves the .wasm out and asks the
kernel for the copy shipped in the wheel (setRDKitWasmSource), and the
JupyterLab labextension builds the worker with webpack
(embedWorker.webpack.ts).
src/builder/library/embed.ts is the main-thread client: one lazily created
Web Worker (embed.worker.ts) holds the module, requests carry the .wasm
URL resolved on the main thread (the worker cannot know the page's base), and
a crashed or silent worker is torn down so the next request starts a fresh
one. draftFromMolfile in sketch.ts sends the molfile to RDKit, runs the
mol block it returns through the shared MOL parser and centres it; the
Ketcher molfile is kept on the library entry so Edit reopens the drawing,
not the conformer. Where RDKit cannot run (no Web Workers, a drawing it
cannot sanitise) the dialog reports the error and adds nothing. The flat
mark on a library entry is reserved for imported 2D files, which are only
rescaled to Å.
Writers
Megane had no structure writer before this feature. crates/megane-core/src/writer.rs
adds XYZ (extended XYZ when a cell is present), PDB (CRYST1, ATOM,
CONECT), MOL V2000, and two cell-only formats: CIF (P 1, _atom_site
fractional coordinates measured from the cell origin) and VASP 5 POSCAR
(Cartesian, atoms grouped by element in first-appearance order). Both refuse
a structure without a cell ("CIF needs a unit cell; this structure has none"); STRUCTURE_EXPORT_FORMATS marks them needsCell and
hasExportCell(box) tells a UI when to disable them. The writers are exposed
as write_structure through both WASM
(src/parsers/parseCore.ts → src/export/structureExport.ts) and PyO3
(megane.write_structure / megane.save_structure). In the web app the
Builder delivers the file through the host-neutral downloadBlob; an
embedding host writes it itself through BuilderHost.save (see Hosts
above).
Deliberately not in this iteration
- Space-group generation and termination enumeration — building a bulk
from a space-group number plus Wyckoff positions (
ase.spacegroup.crystal) needs the 230 groups' operation tables embedded; listing the distinct terminations of a slab (pymatgen'sSlabGenerator.get_slabs) and primitive / conventional cell detection need spglib, which would go into themegane-rdkitEmscripten build as a second module. - Bake to file — collapsing a long history into a re-loaded file. The writer makes this possible; it needs a host-specific "replace the loaded file" flow.
- Trajectory editing — the loader's trajectory output follows the file; an edited atom count no longer matches the frames.
- Save in place — the Builder never writes back to the file it opened: VS Code's Builder tab is a plain webview panel (not a custom editor of the file) and saves through the save dialog, JupyterLab's Builder tab is not a document widget and asks for a path (confirming before it replaces a file), and the notebook widget writes a new name in the kernel's working directory.
- Live-MD integration — a "reload structure" command on the simulation side (stop → rebuild system → re-send topology), driven by Megane's exporter.