Writing a Custom Pipeline Node
A hands-on guide to adding your own node type to Megane's visual pipeline. It expands the short checklist in Architecture into a complete, copy-paste walkthrough with every touch-point, the rules your node must obey, and how to test it.
If you only want to use the existing nodes, read the Visual Pipeline Editor guide and the autogenerated Node Reference instead. This page is for developers extending Megane itself.
What a node actually is
The pipeline is a typed, directed-acyclic data-flow graph (built on
React Flow). A "node" is not a single class — it is
a set of coordinated declarations spread across roughly ten files. There is
deliberately no runtime Node base class: instead, each node type is a string
literal in the PipelineNodeType union, and everything about it is supplied by
matching entries in a family of Record<PipelineNodeType, …> tables.
Those Record annotations are the enforced interface. Because they are declared
as Record<PipelineNodeType, …> (not Partial<…>), TypeScript refuses to
compile until every table has an entry for your new node type. Miss one and
tsc tells you exactly which. This compile-time guardrail is what a base class
would normally give you — the project trades the abstraction for exhaustiveness
checks.
Nodes exist only in the TypeScript frontend (src/pipeline/ + src/components/).
The Rust core (crates/megane-core/) knows nothing about nodes — it only parses
files into Snapshots. Pipeline execution is 100% TypeScript.
The contract: every surface a node must satisfy
| # | Surface | File | Purpose |
|---|---|---|---|
| 1 | PipelineNodeType union | src/pipeline/types.ts | Register the type identifier |
| 2 | NODE_TYPE_LABELS | src/pipeline/types.ts | Display name in the header/palette |
| 3 | NODE_CATEGORY | src/pipeline/types.ts | Color group (data_load/bond/filter/modify/overlay/viewport) |
| 4 | NODE_PORTS | src/pipeline/types.ts | Typed input/output ports |
| 5 | XxxParams + PipelineNodeParams + defaultParams() | src/pipeline/types.ts | The node's config schema and initial values |
| 6 | GENERIC_NODE_ACCEPTS (optional) | src/pipeline/types.ts | Only for nodes whose input type is resolved from the connected edge |
| 7 | NODE_CATALOG entry | src/pipeline/catalog.ts | Prose + per-param docs (feeds the LLM prompt and the Node Reference page) |
| 8 | summarizeNodeParams() case | src/pipeline/nodeSummary.ts | One-line parameter summary shown on the node's canvas card |
| 9 | Executor function | src/pipeline/executors/<name>.ts | Pure compute: (params, inputs) => outputs |
| 10 | NODE_EXECUTORS entry | src/pipeline/executors/registry.ts | Wire the executor into executePipeline() |
| 11 | React component | src/components/nodes/<Name>Node.tsx | The node's properties form, wrapping NodeShell |
| 12 | nodeTypes map + ADD_NODE_GROUPS | src/components/PipelineEditor.tsx, src/components/PipelineRail.tsx | Render the component + add it to the "Add Node" menu |
| 13 | Builder subclass + BUILDER_NODE_CLASSES entry | src/pipeline/builder.ts (+ export from src/lib.ts) | JS graph-construction API |
| 14 | Python subclass | python/megane/pipeline.py (+ export from python/megane/__init__.py) | Python graph-construction API |
Surfaces 1–5, 7, 8, 10 and 13 are compile-enforced (see above; defaultParams()
and summarizeNodeParams() are exhaustive switches with no default, so a
missing case is a "lacks ending return statement" error; NODE_EXECUTORS is a
mapped type over PipelineNodeType, so a missing entry is a "property is
missing" error; and BUILDER_NODE_CLASSES satisfies a mapped type over
PipelineNodeType, so a node type without a JS builder class does not compile).
tests/ts/pipeline/builderCoverage.test.ts then checks every builder class's
ports against NODE_PORTS, its serialized keys against defaultParams and the
catalog, and the serialize round-trip, and
tests/ts/pipeline/builderLibExports.test.ts checks it is exported from
src/lib.ts. Surfaces 9–12 are what make the node actually do something and
appear on the canvas. 14 is only needed for the Python Pipeline builder — but note that the catalog's
pythonClass names the class users import from megane, and
tests/ts/pipeline/catalog.test.ts requires one for every node, so ship the
Python subclass too. tests/python/test_pipeline_node_drift.py then checks the
class against the TS contract (tests/fixtures/pipeline_node_contract.json,
regenerated with MEGANE_UPDATE_NODE_CONTRACT=1 npx vitest run tests/ts/pipeline/nodeContractFixture.test.ts): same type, same port names,
and every TS default param surviving Pipeline.from_dict() → to_dict().
Rules
These are non-negotiable — they keep the engine testable and consistent, and some are hard CI gates.
- Ports are typed and connections are checked. Every port declares a
PipelineDataType(particle,bond,coordination,cell,label,mesh,trajectory,vector,volumetric,spectrum). A connection is legal only when the source output'sdataTypeequals the target input'sdataType— enforced bycanConnect()intypes.ts. Generic nodes (inGENERIC_NODE_ACCEPTS:filter,modify,color,representation) resolve their input type from the connected edge instead, and their output is checked against the types they can carry. - Executors are pure and renderer-free. An executor has the exact signature
(params, inputs: Map<string, PipelineData[]>) => Map<string, PipelineData>(some nodes take extra context args — the loaders their file data, andfilter/color/label_generatorthe atom labels). It must be a pure function of its inputs — no Three.js, no DOM, no side effects. Computation and rendering are strictly separated: the pipeline produces a plainViewportState, and a separateapplyViewportState()layer maps it to renderer calls. This is why the pipeline can be unit-tested without a WebGL context. - Never mutate input data in place. Inputs may be shared with other branches
of the graph. Spread into a new object (
{ ...particle, opacityOverrides: … }) and copy typed arrays (new Float32Array(existing)) before writing. - Honor the disabled-node passthrough. When a node is disabled the engine
forwards each input to the first output port of the same
dataType, so a toggled-off node is transparent. Keep your input/output port types aligned so passthrough works (this is automatic for same-type modifier nodes). - Write unit tests for every new line. Codecov is a hard merge gate — patch
coverage must be ≥ 70 % on the diff (CRITICAL RULE #8). A new executor,
params default, and React component all need tests in the same PR. Reproduce the
gate locally with
npm test -- --coverage. - UI-touching nodes need E2E before a PR. Adding a node component or editing
PipelineEditor.tsxis UI-affecting under CRITICAL RULE #9 — run the relevant Playwright projects locally and commit any intended baseline updates. See Testing your node. - Keep host parity in mind. The webapp, JupyterLab, and VSCode hosts all share
src/, so a node registered here appears everywhere automatically — but if your node loads a file, follow the Adding a File Format checklist too.
Walkthrough: adding a fade node
We'll add a Fade node in the modify category that applies a uniform opacity
to an incoming particle stream. The compute is intentionally trivial — the point
is the wiring. Substitute your own logic once the plumbing is in place. The
pattern mirrors the real modify node (src/pipeline/executors/modify.ts,
src/components/nodes/ModifyNode.tsx).
1–6. Declare the type in src/pipeline/types.ts
// 1. Add to the PipelineNodeType union
export type PipelineNodeType =
| "load_structure"
// …existing…
| "spectrum_plot"
| "fade";
// 2. Display label
export const NODE_TYPE_LABELS: Record<PipelineNodeType, string> = {
// …existing…
fade: "Fade",
};
// 3. Category (drives the left-border color)
export const NODE_CATEGORY: Record<PipelineNodeType, NodeCategory> = {
// …existing…
fade: "modify",
};
// 4. Ports — one particle in, one particle out
export const NODE_PORTS: Record<PipelineNodeType, NodePortConfig> = {
// …existing…
fade: {
inputs: [{ name: "in", dataType: "particle", label: "In" }],
outputs: [{ name: "out", dataType: "particle", label: "Out" }],
},
};
// 5. Params interface …
export interface FadeParams {
type: "fade";
/** Uniform opacity applied to every atom, 0–1. */
opacity: number;
}
// … add it to the discriminated union …
export type PipelineNodeParams =
| LoadStructureParams
// …existing…
| FadeParams;
// … and give it defaults in defaultParams()
export function defaultParams(type: PipelineNodeType): PipelineNodeParams {
switch (type) {
// …existing cases…
case "fade":
return { type, opacity: 0.3 };
}
}
Because the in/out ports are declared as particle, fade is not generic
and does not need a GENERIC_NODE_ACCEPTS entry. (Add one only if the node
should accept whatever type is wired in, like filter/modify do.)
7. Document it in src/pipeline/catalog.ts
This entry is compile-enforced and feeds both the AI system prompt and the autogenerated Node Reference page.
export const NODE_CATALOG: Record<PipelineNodeType, NodeCatalogEntry> = {
// …existing…
fade: {
description: "Applies a uniform opacity to every atom in the input stream.",
params: [
{ jsonKey: "opacity", tsType: "number", default: "0.3", doc: "Uniform opacity, 0–1." },
],
promptInputs: "`in` (particle data type)",
promptOutputs: "`out` (particle data type)",
inPrompt: true,
pythonClass: "Fade", // the class exported from `megane` (step 14)
},
};
8. Summarize it in src/pipeline/nodeSummary.ts
The canvas shows each node as a compact card whose second line is
summarizeNodeParams(params). Add a case to its switch:
case "fade":
return `Opacity ${pct(params.opacity)}`;
9. Write the executor — src/pipeline/executors/fade.ts
import type { PipelineData, ParticleData, FadeParams } from "../types";
/**
* Fade node — sets a uniform per-atom opacity override on the particle stream.
* Pure function: no renderer, no mutation of the input.
*/
export function executeFade(
params: FadeParams,
inputs: Map<string, PipelineData[]>,
): Map<string, PipelineData> {
const outputs = new Map<string, PipelineData>();
const inData = inputs.get("in")?.[0];
if (!inData || inData.type !== "particle") return outputs;
const particle = inData as ParticleData;
const opacityOverrides = new Float32Array(particle.source.nAtoms).fill(params.opacity);
outputs.set("out", { ...particle, opacityOverrides });
return outputs;
}
10. Register it in src/pipeline/executors/registry.ts
executePipeline() (src/pipeline/execute.ts) walks the graph in topological
order and hands every enabled node to its entry in the NODE_EXECUTORS
registry. You do not edit execute.ts. Import the executor next to the other
executor imports in registry.ts and add an entry:
import { executeFade } from "./fade";
export const NODE_EXECUTORS: NodeExecutorRegistry = {
// … the other node types …
fade: ({ params, inputs, addError }) => {
const outputs = executeFade(params, inputs); // params is already FadeParams
if (!inputs.get("in")?.length) addError(noInput());
return { outputs };
},
};
Each entry receives a NodeExecutorContext:
| Field | What it is |
|---|---|
id | The node's id |
params | The node's params, narrowed to the node's own XxxParams |
inputs | Upstream data per input port (collectInputs()) |
nodes | Every node in the graph (rarely needed; Load Trajectory uses it) |
ctx | The caller's PipelineExecutionContext (snapshots, frames, providers, labels) |
addError | Records a NodeError on this node (shown as the card's warning badge) |
It returns { outputs }, which the engine stores as the node's edge outputs
(only the Viewport node returns { viewportState } instead). The registry is
typed NodeExecutorRegistry, a mapped type over PipelineNodeType, so tsc
fails until the new type has an entry; tests/ts/pipeline/executors/registry.test.ts
also checks at runtime that the registry matches NODE_CATALOG, so add a case
there for your node's warnings.
11. Build the UI — src/components/nodes/FadeNode.tsx
Every node wraps the shared NodeShell (src/components/nodes/NodeShell.tsx).
The editor renders each node component twice: on the canvas, NodeShell ignores
its children and draws the compact NodeCard (category icon, title, the
summarizeNodeParams line, an "Off" badge, the error/warning status dot, and the
typed handles derived from NODE_PORTS); in the properties pane under the graph,
it renders only your children, below a header (enable toggle, delete button,
node messages) that PipelineEditor.tsx supplies. So your component only renders
the form (the param controls) and commits changes via the store's
updateNodeParams.
import type { NodeProps, Node } from "@xyflow/react";
import type { PipelineNodeData } from "../../pipeline/execute";
import type { FadeParams } from "../../pipeline/types";
import { useScopedPipelineStore } from "../../stores/MeganeProvider";
import { NodeShell } from "./NodeShell";
import { FormStack, SliderField } from "./form";
export function FadeNode({ id, data }: NodeProps<Node<PipelineNodeData>>) {
const updateNodeParams = useScopedPipelineStore((s) => s.updateNodeParams);
const params = data.params as FadeParams;
return (
<NodeShell id={id} nodeType="fade" enabled={data.enabled}>
<FormStack>
<SliderField
label="Opacity"
min={0}
max={1}
step={0.05}
value={params.opacity}
format={(v) => v.toFixed(2)}
onChange={(v) => updateNodeParams(id, { opacity: v })}
/>
</FormStack>
</NodeShell>
);
}
Lay the form out with the shared kit in src/components/nodes/form.tsx —
FormStack for the column, Field for a label-and-control row, SliderField,
Section for a titled group, FileDrop for a loader — and take control styles
(inputStyle, selectStyle, checkboxStyle, …) from formStyles.ts, so the
new node's properties pane looks like every other one.
Read/write params only through updateNodeParams(id, partial) — never mutate
data.params directly. Use the scoped hook (useScopedPipelineStore from
src/stores/MeganeProvider.tsx), not the module-global usePipelineStore, so the
node talks to the store of the viewer it lives in when several viewers share a
page. For a text field, keep a local state for smooth typing
and commit on blur/Enter, as FilterNode.tsx does.
12. Register the component
Two edits: the React Flow nodeTypes map in src/components/PipelineEditor.tsx,
and the "Add Node" menu groups in src/components/PipelineRail.tsx (the icon
rail down the Pipeline panel's left edge).
// src/components/PipelineEditor.tsx
import { FadeNode } from "./nodes/FadeNode";
const nodeTypes = {
// …existing…
fade: FadeNode,
};
// src/components/PipelineRail.tsx — add "fade" to the "Modify" group:
export const ADD_NODE_GROUPS: { category: NodeCategory; label: string; types: PipelineNodeType[] }[] = [
// …
{
category: "modify",
label: "Modify",
types: [
"modify",
"color",
"representation",
"symmetry",
"wrap",
"replicate",
"drawing_boundary",
"boundary_completion",
"fade",
],
},
// …
];
That's the full loop — build, drop a Fade node from the Modify group, wire it between a structure and the viewport, and your atoms go translucent.
The programmatic base class (for scripting)
Everything above wires the node into the visual editor and engine. To make the
node constructable from the JS and Python builder APIs
(TypeScript / Python),
subclass the one real base class that Megane does have. The JS builder covers
every node type (the compile-time BUILDER_NODE_CLASSES registry enforces it).
Unlike the execution side, the builder has a genuine
abstract class PipelineNode (src/pipeline/builder.ts), mirrored one-to-one by
class PipelineNode in python/megane/pipeline.py. It exists solely to
construct and serialize graphs into the SerializedPipeline v3 JSON that the
engine loads — it has no role in execution or rendering.
The TypeScript contract:
export abstract class PipelineNode {
abstract readonly nodeType: string;
protected abstract readonly _outPorts: Record<string, string>; // alias -> port name
protected abstract readonly _inpPorts: Record<string, string>;
_id: string | null = null;
get out(): PortAccessor { /* node.out.particle */ }
get inp(): PortAccessor { /* node.inp.particle */ }
abstract _toSerializedParams(): Record<string, unknown>;
}
A Fade subclass (step 13), following the builder's options-object constructor convention:
export class Fade extends PipelineNode {
readonly nodeType = "fade";
protected readonly _outPorts = { particle: "out" };
protected readonly _inpPorts = { particle: "in" };
public opacity: number;
constructor({ opacity = defaultsOf("fade").opacity }: { opacity?: number } = {}) {
super();
this.opacity = opacity;
}
_toSerializedParams() {
return { type: this.nodeType, opacity: this.opacity };
}
}
And the Python mirror (step 14) in python/megane/pipeline.py — declare the class
with _node_type/_out_ports/_inp_ports and store params on self, then add
an isinstance(node, Fade) branch to Pipeline._serialize_node() that copies
those attributes into the node dict. Export the class from python/megane/__init__.py
(import + __all__) so megane.Fade resolves; likewise add the TypeScript class
to the builder exports in src/lib.ts and register it in BUILDER_NODE_CLASSES
(fade: Fade). In TypeScript, read defaults with defaultsOf("fade") (a helper
local to src/pipeline/builder.ts that returns defaultParams("fade")) rather
than repeating them in the constructor, so the builder cannot drift from the
editor. Python has no such helper: keep its keyword defaults equal to
defaultParams by hand.
class Fade(PipelineNode):
"""Apply a uniform opacity to the particle stream.
Ports:
inp.particle — atom data in
out.particle — modified atom data
"""
_node_type = "fade"
_out_ports = {"particle": "out"}
_inp_ports = {"particle": "in"}
def __init__(self, *, opacity: float = 0.3) -> None:
super().__init__()
self.opacity = opacity
# in Pipeline._serialize_node():
# elif isinstance(node, Fade):
# base["opacity"] = node.opacity
The port aliases and the serialized type/param keys must match the
TypeScript side exactly, or the JSON won't round-trip. Keeping the JS builder,
the Python builder, and the engine's defaultParams/executor in agreement is the
whole reason to write serialization tests (below).
Testing your node
Nothing is done until it's tested — Codecov gates the diff and E2E catches UI regressions.
Unit tests (required, npm test -- --coverage). Cover:
- the executor — feed it a small
ParticleDataand assert the output overrides; defaultParams("fade")— assert the returned shape (the catalog tests intests/ts/pipeline/catalog.test.tsalso check every default key is documented);summarizeNodeParams— add a case totests/ts/pipeline/nodeSummary.test.ts;- the React component — render it and assert
updateNodeParamsfires on change; - if you added builder/Python subclasses — assert
to_dict()/_toSerializedParams()round-trips throughdeserialize.
Follow the existing specs under tests/ts/pipeline/executors/ (e.g.
modify.test.ts) and tests/ts/components/nodes/ (e.g. FilterNode.test.tsx)
for patterns. Patch coverage must be ≥ 70 %.
E2E (required for UI changes, local-only). Adding a node component is UI-affecting (CRITICAL RULE #9). Build, then run the pipeline-editor and modifier projects and sweep the neighborhood for side effects:
npm run build:wasm && npm run build:app
PATH="$(pwd)/.venv/bin:$PATH" npx playwright test --project=pipeline-editor
PATH="$(pwd)/.venv/bin:$PATH" npx playwright test --project=modify-node__webapp
(modify-node is a feature × host matrix; its projects are named
modify-node__<host>. npm run test:e2e:modify-node runs all five hosts, which
needs the JupyterLab and code-server setup from the e2e-coverage skill.)
If your node changes rendered pixels intentionally, re-baseline with
MEGANE_E2E_UPDATE=1, visually inspect the new PNGs, and commit them under
tests/e2e/baselines/<project>/. Treat unexpected diffs, timeouts, or runtime
errors elsewhere in the matrix as regressions to fix at the root — not baselines
to bless. The full runbook (all five hosts, per-host setup) lives in the
e2e-coverage skill.
Quick reference
For the agent-facing checklist version of this walkthrough, see the add-node
skill (.agents/skills/add-node/SKILL.md). For the internals it plugs into, see
Architecture. For the per-node parameter tables generated
from catalog.ts, see the Node Reference.