- name
- mn-nodes
- description
- How Molecular Nodes geometry-node assets are written as nodebpy Python, built into nodes.blend, documented, and tested numerically and with golden renders. Load before adding, editing, or testing any node group under molecularnodes/nodes/.
# Writing, building and testing Molecular Nodes node groups
## 1. Mental model
- **Source of truth is Python.** Every node group ships as a module under
`molecularnodes/nodes/geometry/` (assets users see) or
`molecularnodes/nodes/geometry/_shared/` (internal groups other assets import).
Shader and material groups live in `nodes/shader/` and `nodes/materials/`.
- **`nodes.blend` is a build product.** `molecularnodes/assets/nodes.blend` is untracked
and rebuilt from the sources. Only `assets/resources.blend` (meshes, collections) is
committed.
- **The tree is what gets dumped back.** After `build`, `dump` regenerates each module
from the built tree: docstring, `_Inputs`/`_Outputs`, `__init__`, and the body of
`_build_group()`. Anything the tree cannot represent, such as Python comments inside
`_build_group`, is dropped on dump. Put explanations in socket `description=` strings,
in the node's `description`, or in in-tree comments (section 3a). Never in `#` comments.
- Config lives in `[tool.nodebpy.assets]` in `pyproject.toml`; `CONTRIBUTING.md`
"Node assets" is the human version of this section.
## 2. The pipeline commands
Always via `uv run`, never bare `python`.
```bash
uv run -m nodebpy.assets build # nodes/*.py -> assets/nodes.blend (also validates your tree)
uv run -m nodebpy.assets dump # assets/nodes.blend -> nodes/*.py (normalises code, regenerates __init__.py)
uv run -m nodebpy.assets ensure # build only if missing or stale (the test suite runs this)
uv run -m nodebpy.assets check # CI: build -> dump must reproduce the sources exactly
uv run docs/generate.py # regenerates the node pages block in docs/_quarto.yml from the blend
```
`build` takes about a minute for the ~270 assets; run it in the background when you
have other work. `dump` may leave the new files staged in git; `git reset -q` if you
want a clean index.
## 3. Anatomy of a node module
Copy an existing neighbour rather than starting blank. Good templates on `main`:
`assembly_instance.py` (asset with a panel, capture attributes, instancing, realize),
`animate_trails.py` (panels plus a menu input and `MenuSwitch`),
`_shared/ca_value_float.py` (internal group with field inputs),
`_shared/unit_convert.py` (menu input and `MenuSwitch`),
`animate_wiggle.py` (repeat zone),
`atoms_to_ca_curves.py` and `dna_from_curve.py` (in-tree comments via Frame plus String).
The symmetry nodes (#1210) and Select String (#1212) are further worked examples of
the same patterns; read them on their branches until they merge.
```python
class SymmetryCyclic(AssetGeometryGroup): # CustomGeometryGroup for _shared/ helpers
"""docstring regenerated by dump"""
_name = "Symmetry Cyclic"
_asset_name = "Symmetry Cyclic" # assets only
_library = PackageLibrary(__file__, "../../assets/nodes.blend") # assets only
_color_tag = "GEOMETRY"
_tree_properties = {"description": "..."} # tooltip on the group
class _Inputs(SocketAccessor): ... # regenerated by dump
class _Outputs(SocketAccessor): ...
def __init__(self, geometry: InputGeometry = None, order: InputInteger = 3, ...):
super().__init__(**{"Geometry": geometry, "Order": order, ...})
def _build_group(self, tree: TreeBuilder[GeometryNodeTree]) -> None:
geometry = tree.inputs.geometry("Geometry", description="...")
order = tree.inputs.integer("Order", 3, min_value=1, max_value=1000)
axis = tree.inputs.vector("Axis", (0.0, 0.0, 1.0), subtype="XYZ")
with tree.inputs.panel("Animate", default_closed=True):
factor = tree.inputs.float("Factor", 1.0, min_value=0.0, max_value=1.0, subtype="FACTOR")
instances = tree.outputs.geometry("Instances")
... # nodes
something >> instances
ASSET = SymmetryCyclic
ASSET_METADATA = {"catalog_id": "a484cee9-...", "description": "..."}
```
- **Catalog** decides the Add menu and the docs page. IDs are in
`molecularnodes/nodes/blender_assets.cats.txt` (Ensemble, Style, Select, Utilities/...).
- **Field inputs** (per-point values) use `hide_value=True`; they are evaluated in
whatever geometry context they are used in inside the group.
- **Units.** Positions are world units with `Molecule.world_scale = 0.1`, so 1 Å is
0.1 units. Convert user-facing Å inputs with `AngstromToWorld(angstrom=x)` or the
shared `UnitConvert`. Angles use `subtype="ANGLE"` (stored radians, shown degrees).
- **Shared helpers** in `_shared/` are `CustomGeometryGroup`, have no `_library` or
`ASSET`, and are imported by the assets that use them. Use one whenever two or
more assets would repeat the same sub-tree.
## 3a. Commenting a tree: Frame plus an orphan String node
`#` comments inside `_build_group` do not survive `dump`. What does survive is a
Frame wrapping the nodes being explained, with an unlinked `g.String(...)` inside
it holding the prose. Blender shows the text on the String node in the frame, the
builder lays it out with the frame, and dump writes it back. (A real Comment node
is coming to Blender; until then this is the convention. `dna_from_curve.py` and the
symmetry nodes use it.)
```python
with g.Frame("Dn operators"):
g.String(
string="Points 0..n-1 are the first ring, n..2n-1 the same ring flipped by a "
"two-fold perpendicular to the axis. Offset rotates the flipped ring about the axis."
)
ring = g.AxisAngleToRotation(axis=axis, angle=...)
...
```
What to expect after `build` + `dump`, verified on the four symmetry trees:
- The text round-trips intact. Dump joins an implicitly concatenated multi-line
literal into one long `string="..."` line; ruff leaves long strings alone.
- The orphan is renamed `_string`, `_string_1`, ... (leading underscore marks an
unused node) and is re-emitted wherever the layout orders it inside the frame,
not necessarily first. Do not rely on its position.
- **Only nodes created inside the `with g.Frame(...)` block land in the frame.**
Nodes built inline as arguments of a call outside the block (for example inside a
`SymmetryInstance(rotation=g.AxisAngleToRotation(...))` call) are not in the frame;
a frame that ends up holding only the String node still works but explains nothing
visually. Hoist those nodes into named variables inside the block first.
- `check` passes with comments present, so they are safe for CI.
- Keep one comment per frame and keep it to a few sentences; the String node is
drawn at fixed width and long text is fine but reads as a paragraph in the editor.
## 4. nodebpy idioms inside `_build_group`
- `from nodebpy import geometry as g`. Nodes are classes: `g.InstanceOnPoints(points=..., instance=...)`.
Outputs via `.o.<socket>`; a node used where a socket is expected means its first output.
- `>>` chains geometry: `points >> g.SetPosition(position=p) >> g.InstanceOnPoints(instance=geo) >> instances`.
- Sockets support arithmetic: `a * b`, `a - b`, `vec.normalize()`, `(x >= n).switch.rotation(f, t)`,
`rot.rotate(by, rotation_space="LOCAL")`, `rotation.to_axis_angle()`. When mixing an
integer socket with floats be explicit (`g.Math.multiply(g.Index(), x)`) so you do not
get integer math by accident; dump will rewrite it into the idiomatic form anyway.
- Typed variants: `g.Compare.integer.equal(a, b)`, `g.Switch.geometry(cond, f, t)`,
`g.StoreNamedAttribute.instance.integer(name=..., value=...)`,
`g.CaptureAttribute.point(geometry=...)` then `capture.items.vector("Name", field)`,
`g.MenuSwitch.float(menu, {"A": a, "B": b})`, `g.DomainSize(geometry=..., component="POINTCLOUD").o.point_count`.
- Nodes must be created inside a tree context. In tests that means inside
`with mol.tree.reset() as (atoms, join):`; constructing a node outside raises
`RuntimeError: ... must be created within a TreeBuilder context manager`.
### Finding the API
- Signatures and enum options: `uv run python -c "import inspect; from nodebpy import geometry as g; print(inspect.signature(g.RotateRotation.__init__))"`.
- Output socket names: `g.RotationToAxisAngle._Outputs.__annotations__`.
- Input-interface builders: `from nodebpy.builder import InputInterfaceContext as I; inspect.signature(I.float)`
(float, integer, vector, rotation, boolean, menu, geometry, object, material, panel, ...).
- Grep the repo for a node you want to use: `grep -rn "AlignRotationToVector" molecularnodes/nodes/geometry/`.
270 worked examples beat any doc.
- **nodebpy docs**: https://bradyajohnston.github.io/nodebpy (source at
https://github.com/BradyAJohnston/nodebpy). Read before guessing:
- `introduction` adding, linking and organising nodes; `operators` arithmetic,
comparison, `.switch`, boolean and matrix operators with an operator reference
table; `node-api` sockets, accessors, enum options, class methods;
`custom-node-groups` anatomy of a group, panels, composing groups, class options;
`assets` the build/dump pipeline and how `PackageLibrary` resolution works;
`nodes-to-code` turning trees back into code.
- In the nodebpy repo, `tests/test_usecases.py`, `tests/test_operators.py`,
`tests/test_custom_groups.py` and `tests/test_asset_pipeline.py` are runnable
examples of every idiom; `src/nodebpy/builder/` is the implementation (`tree.py`
inputs/outputs/panels, `socket.py` operators, `asset.py` group classes) and
`src/nodebpy/nodes/` is generated per Blender node, one class each.
- The installed copy is in the venv: `uv run python -c "import nodebpy; print(nodebpy.__path__)"`.
## 5. Editing an existing node
Edit `_build_group()` (and `__init__` if you add an input, or just let dump fix it),
then `build`, `dump`, and read the `git diff`. Expect dump to reformat and to drop
comments. If you prefer editing in Blender: `build`, open `nodes.blend`, edit, save,
`dump`, commit the `.py` diff. Run `check` before pushing; CI runs it.
## 6. Docs
- `docs/nodes.yml` holds long-form prose per node, keyed by group name. Inline chips
like `` `Order::Integer` `` and links like `` `Symmetry ID::Node` `` are rendered by `docs/filters.lua`.
- `docs/_quarto.yml` node listing is generated by `docs/generate.py` from the built blend.
It needs `nodes.blend` present. It also rewrites unrelated drift; keep only your hunk
if the diff includes nodes you did not touch (CI regenerates before the docs build).
## 7. Testing numerically (`tests/test_nodes.py`)
Pattern used by the symmetry tests:
```python
mol = mn.Molecule.fetch("4ozs", cache=data_dir) # small, cached in tests/data
positions = mol.named_attribute("position") # raw mesh, world units
with mol.tree.reset() as (atoms, join):
(
atoms
>> SymmetryCyclic(order=5, axis=axis, centre=centre)
>> RealizeInstances()
>> join
)
realized = mol.named_attribute("position", evaluate=True)
sym_id = mol.named_attribute(
"sym_id", evaluate=True
) # instance attrs propagate on realize
assert np.allclose(realized, expected_from_numpy, atol=1e-4)
```
- Compute the expected result independently in numpy (port the reference maths, e.g.
Rodrigues rotation) rather than asserting a snapshot when the maths is known.
- Instances are invisible to `named_attribute` until realized.
- Structured-output snapshots: `snapshot == GeometrySet(obj).summary()` from
`tests/utils.py`; update with `pytest --snapshot-update`.
- Pass node factories (lambdas) into helpers so the node is built inside the context.
- To check a node against a deposited assembly, apply `mol.assemblies()["1"]`
operators in numpy (matrix translation is Å, multiply by `mol.world_scale`) and
compare with a KD-tree since copy order differs.
- Run only what you changed: `uv run pytest tests/test_nodes.py -k symmetry -q`.
## 8. Testing visually (`tests/test_render_images.py`)
Golden-image tests, compared with Blender's own render tolerances.
```python
@pytest.mark.parametrize("code", list(SYMMETRY_EXAMPLES))
def test_render_symmetry(golden_canvas, tmp_path, assembly_image_snapshot, code):
mol = mn.Molecule.fetch(code)
with mol.tree.reset() as (atoms, join):
(
atoms
>> mg.StyleRibbon(material=mn.material.Flat().material)
>> SYMMETRY_EXAMPLES[code]()
>> join
)
golden_canvas.look_at(mol, viewpoint="top")
assert assembly_image_snapshot == _render(golden_canvas, tmp_path)
```
- `golden_canvas` is 128x128 Cycles CPU, fixed seed, no denoise. Use `image_snapshot`
for a single molecule and `assembly_image_snapshot` (looser pixel tolerance) for
full-frame assemblies.
- Generate goldens with `uv run pytest tests/test_render_images.py -k <name> --snapshot-update`,
then open the PNGs in `tests/__snapshots__/test_render_images/` and look at them
before committing. Failures write received and diff images to `tests/image_failures/`.
- Named viewpoints: front, back, top, bottom, left, right. Style before the instancing
node so each copy is cheap; realize after only if you need per-copy attributes
(e.g. `SetColor(color=ColorAttributeRandom(name="sym_id"))`).
## 9. Ad-hoc scripts outside pytest
The `mn-render` skill (`skills/mn-render/`) covers Canvas, framing, materials and
animation in full; the points below are the minimum for a node-testing script.
- Set `os.environ.setdefault("BLENDER_USER_EXTENSIONS", tempfile.mkdtemp())` before
importing `bpy`, or the installed MN extension's bundled wheels shadow the venv.
- Create `mn.Canvas()` before loading molecules so the scene preset loads.
- `canvas.look_at(target, viewpoint=(x, y, z))` with a custom Euler needs
`bpy.context.view_layer.update()` between `canvas.camera.set_viewpoint(...)` and
`look_at`, otherwise the framing uses the stale camera basis and renders empty.
- `mathutils` is only importable after `import bpy`.
GitHub에서 보기