Skip to main content

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​

#SurfaceFilePurpose
1PipelineNodeType unionsrc/pipeline/types.tsRegister the type identifier
2NODE_TYPE_LABELSsrc/pipeline/types.tsDisplay name in the header/palette
3NODE_CATEGORYsrc/pipeline/types.tsColor group (data_load/bond/filter/modify/overlay/viewport)
4NODE_PORTSsrc/pipeline/types.tsTyped input/output ports
5XxxParams + PipelineNodeParams + defaultParams()src/pipeline/types.tsThe node's config schema and initial values
6GENERIC_NODE_ACCEPTS (optional)src/pipeline/types.tsOnly for nodes whose input type is resolved from the connected edge
7NODE_CATALOG entrysrc/pipeline/catalog.tsProse + per-param docs (feeds the LLM prompt and the Node Reference page)
8summarizeNodeParams() casesrc/pipeline/nodeSummary.tsOne-line parameter summary shown on the node's canvas card
9Executor functionsrc/pipeline/executors/<name>.tsPure compute: (params, inputs) => outputs
10NODE_EXECUTORS entrysrc/pipeline/executors/registry.tsWire the executor into executePipeline()
11React componentsrc/components/nodes/<Name>Node.tsxThe node's properties form, wrapping NodeShell
12nodeTypes map + ADD_NODE_GROUPSsrc/components/PipelineEditor.tsx, src/components/PipelineRail.tsxRender the component + add it to the "Add Node" menu
13Builder subclass + BUILDER_NODE_CLASSES entrysrc/pipeline/builder.ts (+ export from src/lib.ts)JS graph-construction API
14Python subclasspython/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's dataType equals the target input's dataType — enforced by canConnect() in types.ts. Generic nodes (in GENERIC_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, and filter / color / label_generator the 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 plain ViewportState, and a separate applyViewportState() 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.tsx is 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:

FieldWhat it is
idThe node's id
paramsThe node's params, narrowed to the node's own XxxParams
inputsUpstream data per input port (collectInputs())
nodesEvery node in the graph (rarely needed; Load Trajectory uses it)
ctxThe caller's PipelineExecutionContext (snapshots, frames, providers, labels)
addErrorRecords 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 ParticleData and assert the output overrides;
  • defaultParams("fade") — assert the returned shape (the catalog tests in tests/ts/pipeline/catalog.test.ts also check every default key is documented);
  • summarizeNodeParams — add a case to tests/ts/pipeline/nodeSummary.test.ts;
  • the React component — render it and assert updateNodeParams fires on change;
  • if you added builder/Python subclasses — assert to_dict() / _toSerializedParams() round-trips through deserialize.

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.