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 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

#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)
8Executor functionsrc/pipeline/executors/<name>.tsPure compute: (params, inputs) => outputs
9Dispatch casesrc/pipeline/execute.tsWire the executor into executePipeline()
10React componentsrc/components/nodes/<Name>Node.tsxThe node UI, wrapping NodeShell
11nodeTypes map + ADD_NODE_GROUPSsrc/components/PipelineEditor.tsxRender the component + add it to the "Add Node" menu
12Builder subclass (optional)src/pipeline/builder.tsJS graph-construction API
13Python subclass (optional)python/megane/pipeline.pyPython 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's dataType equals the target input's dataType — enforced by canConnect() in types.ts. Generic nodes (in GENERIC_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 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/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 ParticleData and assert the output overrides;
  • defaultParams("fade") — assert the returned shape;
  • 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

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.