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 eight locations. 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 | Executor function | src/pipeline/executors/<name>.ts | Pure compute: (params, inputs) => outputs |
| 9 | Dispatch case | src/pipeline/execute.ts | Wire the executor into executePipeline() |
| 10 | React component | src/components/nodes/<Name>Node.tsx | The node UI, wrapping NodeShell |
| 11 | nodeTypes map + ADD_NODE_GROUPS | src/components/PipelineEditor.tsx | Render the component + add it to the "Add Node" menu |
| 12 | Builder subclass (optional) | src/pipeline/builder.ts | JS graph-construction API |
| 13 | Python subclass (optional) | python/megane/pipeline.py | Python graph-construction API |
Surfaces 1–5 and 7 are compile-enforced (see above). Surfaces 8–11 are what make
the node actually do something and appear on the canvas. 12–13 are only needed
if you want the node reachable from the programmatic Pipeline builders.
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,cell,label,mesh,trajectory,vector,volumetric). A connection is legal only when the source output'sdataTypeequals the target input'sdataType— enforced bycanConnect()intypes.ts. Generic nodes (inGENERIC_NODE_ACCEPTS) resolve their input type from the connected edge instead. - Executors are pure and renderer-free. An executor has the exact signature
(params, inputs: Map<string, PipelineData[]>) => Map<string, PipelineData>(some load nodes take extra context args). 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/FilterNode.tsx).
1–6. Declare the type in src/pipeline/types.ts
// 1. Add to the PipelineNodeType union
export type PipelineNodeType =
| "load_structure"
// …existing…
| "isosurface"
| "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", // or null if you skip the Python subclass in step 12/13
},
};
8. 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;
}
9. Dispatch it in src/pipeline/execute.ts
Import the executor near the other executor imports, then add a case to the
switch (data.params.type) inside executePipeline():
import { executeFade } from "./executors/fade";
// … inside the switch …
case "fade": {
const outputs = executeFade(data.params as FadeParams, inputs);
edgeOutputs.set(id, outputs);
if (!inputs.get("in")?.length) {
addError(id, { message: "No input data (check upstream nodes)", severity: "warning" });
}
break;
}
10. Build the UI — src/components/nodes/FadeNode.tsx
Every node wraps the shared NodeShell (src/components/nodes/NodeShell.tsx),
which draws the header, enable toggle, delete button, error badge, and the typed
handles derived from NODE_PORTS. Your component only renders the body (the
param controls) and commits changes via the store's updateNodeParams.
import { useState, useEffect, useCallback } from "react";
import type { NodeProps, Node } from "@xyflow/react";
import type { PipelineNodeData } from "../../pipeline/execute";
import type { FadeParams } from "../../pipeline/types";
import { usePipelineStore } from "../../pipeline/store";
import { NodeShell } from "./NodeShell";
export function FadeNode({ id, data }: NodeProps<Node<PipelineNodeData>>) {
const updateNodeParams = usePipelineStore((s) => s.updateNodeParams);
const params = data.params as FadeParams;
const [local, setLocal] = useState(params.opacity);
useEffect(() => {
setLocal(params.opacity);
}, [params.opacity]);
const commit = useCallback(
(value: number) => updateNodeParams(id, { opacity: value }),
[id, updateNodeParams],
);
return (
<NodeShell id={id} nodeType="fade" enabled={data.enabled}>
<label style={{ fontSize: 16, color: "#475569" }}>Opacity: {local.toFixed(2)}</label>
<input
type="range"
min={0}
max={1}
step={0.05}
value={local}
onChange={(e) => setLocal(Number(e.target.value))}
onMouseUp={() => commit(local)}
style={{ width: "100%" }}
/>
</NodeShell>
);
}
Read/write params only through updateNodeParams(id, partial) — never mutate
data.params directly. Keep a local state for smooth typing/dragging and commit
on blur/onMouseUp/Enter, as FilterNode.tsx does.
11. Register the component in src/components/PipelineEditor.tsx
Two edits: the React Flow nodeTypes map, and the "Add Node" palette groups.
import { FadeNode } from "./nodes/FadeNode";
const nodeTypes = {
// …existing…
fade: FadeNode,
};
// Add "fade" to the appropriate ADD_NODE_GROUPS entry (the "Modify" group):
const ADD_NODE_GROUPS: { category: NodeCategory; label: string; types: PipelineNodeType[] }[] = [
// …
{ category: "modify", label: "Modify", types: ["modify", "color", "representation", "replicate", "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 (optional, for scripting)
Everything above wires the node into the visual editor and engine. If you also want the node constructable from the JS or Python builder APIs (TypeScript / Python), subclass the one real base class that megane does have.
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 12):
export class Fade extends PipelineNode {
readonly nodeType = "fade";
protected readonly _outPorts = { particle: "out" };
protected readonly _inpPorts = { particle: "in" };
constructor(public opacity: number = 0.3) {
super();
}
_toSerializedParams() {
return { type: this.nodeType, opacity: this.opacity };
}
}
And the Python mirror (step 13) 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:
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 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
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.