feat: add variadic render graph logic nodes

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-29 10:03:06 +00:00
co-authored by heaust
parent 0e866596c0
commit 90406ae18f
34 changed files with 1031 additions and 273 deletions
+53
View File
@@ -130,6 +130,59 @@ try {
Complete sources: [definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts), [bootstrap](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/main.ts), and
[first-node tutorial](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/tutorials/first-node.md).
## Multi-input sockets and logic gates
An input socket becomes a vertical multi-input pill when `maxIncomingLinks` is greater than `1`. The worker enforces
the capacity, lays each link onto a stable point along the pill, and keeps the whole pill selectable for link gestures.
Outputs must continue to use `0`; ordinary single-link inputs use `1`.
```ts
const andNode = [
"example.logic.and",
{
version: 1,
title: "AND",
behavior: "standard",
style: "logic",
parameters: {},
sockets: {
inputs: {
title: "Inputs (up to 5)",
direction: "input",
type: "boolean",
maxIncomingLinks: 5,
visible: true,
value: null,
showValue: false,
},
result: {
title: "Result",
direction: "output",
type: "boolean",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "socket", socket: "inputs" },
{ kind: "socket", socket: "result" },
],
muteBypass: [["inputs", "result"]],
migrations: [],
},
] as const satisfies readonly [string, FxNodeDefinition];
await api.composeNode(...andNode);
```
![AND, OR, NOT, XOR, and XNOR nodes using multi-input socket pills](https://raw.githubusercontent.com/Heaust-ops/fxnode/main/examples/assets/logic-nodes.png)
fxnode presents and edits the graph; it does not prescribe graph execution semantics. The
[logic-node example](https://github.com/Heaust-ops/fxnode/tree/main/examples/logic-nodes) evaluates Boolean operations
in application code by subscribing to versioned snapshots.
## One graph, zero or many views
`createFxNode()` creates the shared graph authority and starts one worker, but creates no canvas. This is valid for
+5 -5
View File
@@ -1,9 +1,9 @@
# fxnode upstream provenance
- Source: https://github.com/Heaust-ops/fxnode
- Commit: `3f8745717bf4574577be72e9769373475cc300c9`
- Tree: `fb8bf407854b68dad8c0249c9ba63c7fd0bd9332`
- Imported: 2026-07-26
- Commit: `4e96585e99742959660d4107b0078c27ff13e708`
- Tree: `4fcafa60557bcbd760d7aa8d8898c0adbe0bb2c8`
- Imported: 2026-07-29
- License: MIT (see `LICENSE` and `NOTICE.md`)
- Local patches: none
@@ -14,8 +14,8 @@ This directory is a committed source snapshot. Application adaptations belong ou
```sh
git clone --filter=blob:none https://github.com/Heaust-ops/fxnode /tmp/fxnode
git -C /tmp/fxnode fetch --depth=1 origin 3f8745717bf4574577be72e9769373475cc300c9
git -C /tmp/fxnode checkout --detach 3f8745717bf4574577be72e9769373475cc300c9
git -C /tmp/fxnode fetch --depth=1 origin 4e96585e99742959660d4107b0078c27ff13e708
git -C /tmp/fxnode checkout --detach 4e96585e99742959660d4107b0078c27ff13e708
rm -rf vendor/fxnode
mkdir -p vendor/fxnode
git -C /tmp/fxnode archive HEAD | tar -x -C vendor/fxnode
+7 -1
View File
@@ -1,6 +1,6 @@
# Examples
The repository has five current experiences. Images below use the examples' existing captured assets—there are no documentation copies.
The repository has six current experiences. Images below use the examples' existing captured assets—there are no documentation copies.
## Minimal
@@ -26,6 +26,12 @@ _Replacing a node definition and migrating its instance. [Source](https://github
_One worker and graph with independent cameras and selections. The application-owned toolbar targets the active view, and the canvases forward pointer events only. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/multi-view/main.ts)._
## Logic nodes
![Boolean logic nodes connected through vertical multi-input socket pills](../../../examples/assets/logic-nodes.png)
_A Boolean socket, app-composed AND/OR/NOT/XOR/XNOR/NAND/NOR nodes, and five-link inputs. The library presents and edits the graph; the example evaluates it in application code. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/logic-nodes/main.ts)._
## Blender-shaped gallery
The [larger gallery source](https://github.com/Heaust-ops/fxnode/blob/main/examples/blender/main.ts) exercises many node and interaction shapes; it is repository application code, not package authority.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+5
View File
@@ -45,6 +45,11 @@
<span class="tag">Runtime API</span><strong>Live composition</strong>
<p>Version and migrate definitions while the editor is running.</p>
</a>
<a href="./logic-nodes/">
<img src="./assets/logic-nodes.png" alt="Boolean logic graph with multi-input socket pills" />
<span class="tag">Multi-input sockets</span><strong>Logic nodes</strong>
<p>AND, OR, NOT, XOR, XNOR, NAND, and NOR composed from a Boolean socket.</p>
</a>
<a href="./multi-view/">
<img src="./assets/multi-view.png" alt="Multi-view example" />
<span class="tag">Shared graph</span><strong>Multi-view</strong>
+87
View File
@@ -0,0 +1,87 @@
import type { FxNodeDefinition, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "@lib/index.js";
export const booleanSocket = [
"boolean",
{ title: "Boolean", color: "#d67cff", acceptsFrom: ["boolean"] },
] as const satisfies readonly [string, FxNodeSocketTypeDefinition];
export const logicStyles = {
source: { header: "#547aa5" },
logic: { header: "#7c4d9e" },
} as const satisfies Readonly<Record<string, FxNodeStyleDefinition>>;
export const booleanValueNode = [
"example.logic.boolean",
{
version: 1,
title: "Boolean",
behavior: "standard",
style: "source",
parameters: {
value: { type: "boolean", default: { kind: "boolean", value: true } },
},
sockets: {
value: {
title: "Value",
direction: "output",
type: "boolean",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "parameter", parameter: "value" },
{ kind: "socket", socket: "value" },
],
muteBypass: [],
migrations: [],
},
] as const satisfies readonly [string, FxNodeDefinition];
function gateNode(title: string, inputCapacity: number): FxNodeDefinition {
return {
version: 1,
title,
behavior: "standard",
style: "logic",
parameters: {},
sockets: {
inputs: {
title: inputCapacity === 1 ? "Input" : `Inputs (up to ${inputCapacity})`,
direction: "input",
type: "boolean",
maxIncomingLinks: inputCapacity,
visible: true,
value: null,
showValue: false,
},
result: {
title: "Result",
direction: "output",
type: "boolean",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "socket", socket: "inputs" },
{ kind: "socket", socket: "result" },
],
muteBypass: [["inputs", "result"]],
migrations: [],
};
}
export const logicNodes = [
["example.logic.and", gateNode("AND", 5)],
["example.logic.or", gateNode("OR", 5)],
["example.logic.not", gateNode("NOT", 1)],
["example.logic.xor", gateNode("XOR", 5)],
["example.logic.xnor", gateNode("XNOR", 5)],
["example.logic.nand", gateNode("NAND", 5)],
["example.logic.nor", gateNode("NOR", 5)],
] as const satisfies readonly (readonly [string, FxNodeDefinition])[];
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>fxnode logic nodes</title>
<link rel="stylesheet" href="../shared/example.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main>
<h1>Composable logic nodes</h1>
<p>
AND, OR, XOR, and XNOR accept several links through one multi-input pill. Toggle a Boolean value to evaluate the
graph in application code.
</p>
<output id="results" aria-live="polite"></output>
<canvas id="graph" width="1200" height="700" aria-label="Logic node graph"></canvas>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+161
View File
@@ -0,0 +1,161 @@
import { createFxNode, nodeId, socketId, type GraphSnapshot } from "@lib/index.js";
import { prepareFxNodeBrowserHost } from "../shared/browser-host.js";
import { exampleTheme } from "../shared/theme.js";
import { booleanSocket, booleanValueNode, logicNodes, logicStyles } from "./definition.js";
const canvas = document.querySelector<HTMLCanvasElement>("#graph")!;
const results = document.querySelector<HTMLOutputElement>("#results")!;
const host = prepareFxNodeBrowserHost({ canvas });
let cleanedUp = false;
let unsubscribeSnapshot: (() => void) | undefined;
const operator = new Map<string, (values: readonly boolean[]) => boolean>([
["example.logic.and", (values) => values.every(Boolean)],
["example.logic.or", (values) => values.some(Boolean)],
["example.logic.not", (values) => !values[0]],
["example.logic.xor", (values) => values.filter(Boolean).length % 2 === 1],
["example.logic.xnor", (values) => values.filter(Boolean).length % 2 === 0],
["example.logic.nand", (values) => !values.every(Boolean)],
["example.logic.nor", (values) => !values.some(Boolean)],
]);
function evaluate(snapshot: GraphSnapshot) {
const nodes = new Map(snapshot.nodes.map((node) => [node.id, node]));
const resolve = (id: string, visiting = new Set<string>()): boolean => {
const node = nodes.get(nodeId(id));
if (!node || visiting.has(id)) return false;
if (node.typeId === booleanValueNode[0]) {
const value = node.parameters.value;
return typeof value === "object" && value !== null && "kind" in value && value.kind === "boolean"
? value.value === true
: false;
}
const operation = operator.get(node.typeId);
if (!operation) return false;
const next = new Set(visiting).add(id);
const incoming = snapshot.links
.filter((link) => link.toNodeId === node.id && link.toSocketId === socketId(`${id}:inputs`) && !link.muted)
.sort((a, b) => a.id.localeCompare(b.id));
return operation(incoming.map((link) => resolve(link.fromNodeId, next)));
};
const gates = snapshot.nodes.filter((node) => operator.has(node.typeId));
results.replaceChildren(
...gates.map((node) => {
const item = document.createElement("span");
const value = resolve(node.id);
item.className = value ? "true" : "false";
item.textContent = `${node.label}: ${String(value)}`;
return item;
}),
);
}
function cleanup() {
window.removeEventListener("pagehide", cleanup);
cleanedUp = true;
unsubscribeSnapshot?.();
unsubscribeSnapshot = undefined;
const root = handle.root,
view = handle.view;
handle.root = null;
handle.view = null;
host.destroy();
const destroyRoot = () => root?.destroy();
if (view) void view.detach().then(destroyRoot, destroyRoot);
else destroyRoot();
}
const handle: StandaloneExampleHandle = {
root: null,
view: null,
host,
ready: Promise.resolve(),
cleanup,
};
window.fxnodeStandalone = handle;
window.addEventListener("pagehide", cleanup);
handle.ready = (async () => {
try {
const root = await createFxNode({
applicationId: "fxnode.example.logic-nodes",
applicationVersion: 1,
resources: {},
});
if (cleanedUp) {
root.destroy();
return;
}
handle.root = root;
await root.setTheme(exampleTheme);
await root.setHeaderStyles(logicStyles);
await root.composeSocket(...booleanSocket);
await root.composeNode(...booleanValueNode);
for (const [id, definition] of logicNodes) await root.composeNode(id, definition);
await root.setState({ graphId: "logic-nodes", catalogVersion: 1, nodes: [], links: [], metadata: {} });
const nodes = [
["a", booleanValueNode[0], { x: -460, y: 260 }],
["b", booleanValueNode[0], { x: -460, y: 130 }],
["c", booleanValueNode[0], { x: -460, y: 0 }],
["d", booleanValueNode[0], { x: -460, y: -130 }],
["e", booleanValueNode[0], { x: -460, y: -260 }],
["and", "example.logic.and", { x: -170, y: 220 }],
["or", "example.logic.or", { x: -170, y: -100 }],
["xor", "example.logic.xor", { x: 100, y: 180 }],
["not", "example.logic.not", { x: 100, y: -100 }],
["xnor", "example.logic.xnor", { x: 370, y: 100 }],
] as const;
for (const [id, type, position] of nodes)
await root.dispatch({ type: "node.add", nodeId: nodeId(id), nodeType: type, position });
await root.dispatch({
type: "node.parameter",
id: nodeId("c"),
key: "value",
value: { kind: "boolean", value: false },
});
const connections = [
["a", "and"],
["b", "and"],
["c", "and"],
["d", "and"],
["e", "and"],
["c", "or"],
["d", "or"],
["e", "or"],
["and", "xor"],
["or", "xor"],
["xor", "not"],
["and", "xnor"],
["or", "xnor"],
["not", "xnor"],
] as const;
for (const [from, to] of connections)
await root.dispatch({
type: "link.add",
link: {
fromNodeId: nodeId(from),
fromSocketId: socketId(`${from}:${from.length === 1 ? "value" : "result"}`),
toNodeId: nodeId(to),
toSocketId: socketId(`${to}:inputs`),
muted: false,
extensions: {},
},
});
const view = await root.attachView({
canvas,
viewport: host.initialViewport,
initialCamera: { center: { x: 0, y: 0 }, zoom: 0.8 },
});
handle.view = view;
host.attach(root, view);
unsubscribeSnapshot = root.onSnapshots(({ snapshot }) => evaluate(snapshot));
evaluate(await root.getState());
await view.whenRendered();
} catch (error) {
cleanup();
throw error;
}
})();
+31
View File
@@ -0,0 +1,31 @@
main {
width: 1200px;
}
#results {
display: flex;
min-height: 30px;
gap: 7px;
margin-bottom: 12px;
}
#results span {
padding: 5px 9px;
color: #d7d9de;
background: #292c32;
border: 1px solid #3a3e46;
border-radius: 999px;
font-size: 12px;
}
#results .true {
color: #dafbe1;
background: #183b25;
border-color: #2d6b40;
}
#results .false {
color: #ffd8dc;
background: #472126;
border-color: #76343c;
}
canvas {
width: 1200px;
height: 700px;
}
+1
View File
@@ -109,6 +109,7 @@ export interface FxNodeSocketDefinition<S extends string = string> {
readonly title: string;
readonly direction: "input" | "output";
readonly type: S;
/** Maximum incoming links. Inputs above 1 are presented as multi-input pills; outputs must use 0. */
readonly maxIncomingLinks: number;
readonly visible: boolean;
readonly value: FxNodeValueSchema | null;
+59 -19
View File
@@ -179,7 +179,7 @@ export function buildLayoutScene<C extends FxNodeCompositionData>(
? G.reroute * 2
: node.collapsed
? G.header
: G.header + visibleItems.reduce((sum, item) => sum + nodeRowUnits(item), 0) * G.row + G.gap;
: G.header + visibleItems.reduce((sum, item) => sum + nodeRowUnits(item, descriptor), 0) * G.row + G.gap;
const calculated = descriptor ? minimumNodeSize(descriptor, node) : { x: G.minWidth, y: contentHeight };
const minimumSize = { x: calculated.x, y: kind === "node" && node.collapsed ? G.header : calculated.y };
const width =
@@ -190,19 +190,60 @@ export function buildLayoutScene<C extends FxNodeCompositionData>(
: Math.min(G.maxWidth, Math.max(minimumSize.x, node.size.x));
const height = kind === "node" && !node.collapsed ? Math.max(contentHeight, node.size.y) : contentHeight;
const nodeBounds = { x: at.x, y: at.y, width, height };
const rowBySocket = new Map<string, number>();
const rowBySocket = new Map<string, { offset: number; units: number }>();
let socketRowOffset = 0;
for (const item of visibleItems) {
if (item.kind === "socket") rowBySocket.set(item.socket, socketRowOffset);
socketRowOffset += nodeRowUnits(item);
const units = nodeRowUnits(item, descriptor);
if (item.kind === "socket") rowBySocket.set(item.socket, { offset: socketRowOffset, units });
socketRowOffset += units;
}
const layoutSockets: LayoutSocket[] = visibleSockets.map((socket) => {
const linkIds = linksBySocket.get(socket.id) ?? [];
const linked = linkIds.length > 0;
const row = rowBySocket.get(socket.key) ?? 0;
const row = rowBySocket.get(socket.key) ?? { offset: 0, units: 1 };
const placement = descriptor?.ui.find((item) => item.kind === "socket" && item.socket === socket.key);
const socketType = descriptor ? compiled.socketTypes.get(socket.dataType as never) : undefined;
if (descriptor && !socketType) throw new Error(`Missing compiled socket type: ${socket.dataType}`);
const anchor =
kind === "reroute"
? { x: at.x + G.reroute, y: at.y - G.reroute }
: {
x: at.x + (socket.direction === "output" ? width : 0),
y: at.y - (node.collapsed ? G.half : G.header + (row.offset + row.units / 2) * G.row),
};
const shape =
kind === "node" && !node.collapsed && socket.direction === "input" && socket.maxIncomingLinks > 1
? "multi-input"
: "circle";
const pillHeight = row.units * G.row - 8;
const socketBounds =
shape === "multi-input"
? { x: anchor.x - G.socket, y: anchor.y + pillHeight / 2, width: G.socket * 2, height: pillHeight }
: undefined;
const orderedLinks = linkIds.slice().sort((a, b) => {
const left = document.links[a],
right = document.links[b];
return left && right
? `${left.fromNodeId}:${left.fromSocketId}:${left.id}`.localeCompare(
`${right.fromNodeId}:${right.fromSocketId}:${right.id}`,
)
: a.localeCompare(b);
});
const linkAnchors = new Map<LinkId, Vec2>(
orderedLinks.map((id, index) => [
id,
orderedLinks.length === 1 || shape === "circle"
? anchor
: {
x: anchor.x,
y:
anchor.y +
pillHeight / 2 -
G.socket -
(index * (pillHeight - G.socket * 2)) / (orderedLinks.length - 1),
},
]),
);
return {
id: socket.id,
nodeId: node.id,
@@ -216,20 +257,17 @@ export function buildLayoutScene<C extends FxNodeCompositionData>(
capacity: socket.maxIncomingLinks,
linkIds,
linked,
anchor:
kind === "reroute"
? { x: at.x + G.reroute, y: at.y - G.reroute }
: {
x: at.x + (socket.direction === "output" ? width : 0),
y: at.y - (node.collapsed ? G.half : G.header + G.half + row * G.row),
},
anchor,
shape,
...(socketBounds ? { bounds: socketBounds } : {}),
linkAnchors,
};
});
for (const socket of layoutSockets) sockets.set(socket.id, socket);
const rows: LayoutRow[] = [];
let rowOffset = 0;
for (const item of visibleItems) {
const units = nodeRowUnits(item);
const units = nodeRowUnits(item, descriptor);
const rowBounds: Rect = { x: at.x, y: at.y - G.header - rowOffset * G.row, width, height: units * G.row };
if (item.kind === "text") {
rows.push({ kind: item.variant, label: item.title, units, bounds: rowBounds });
@@ -412,7 +450,7 @@ export function buildLayoutScene<C extends FxNodeCompositionData>(
kind: "socket",
socketId: socket.id,
...(controlId ? { controlId } : {}),
units: 1,
units,
bounds: rowBounds,
});
}
@@ -491,13 +529,15 @@ export function buildLayoutScene<C extends FxNodeCompositionData>(
const from = sockets.get(link.fromSocketId) as LayoutSocket | undefined,
to = sockets.get(link.toSocketId) as LayoutSocket | undefined;
if (!from || !to) continue;
const points = cubic(from.anchor, to.anchor);
const dx = Math.max(40, Math.abs(to.anchor.x - from.anchor.x) * 0.5);
const fromAnchor = from.linkAnchors.get(link.id) ?? from.anchor,
toAnchor = to.linkAnchors.get(link.id) ?? to.anchor;
const points = cubic(fromAnchor, toAnchor);
const dx = Math.max(40, Math.abs(toAnchor.x - fromAnchor.x) * 0.5);
const cs = [
{ x: from.anchor.x + dx, y: from.anchor.y },
{ x: to.anchor.x - dx, y: to.anchor.y },
{ x: fromAnchor.x + dx, y: fromAnchor.y },
{ x: toAnchor.x - dx, y: toAnchor.y },
] as const,
linkBounds = cubicBounds(from.anchor, cs[0], cs[1], to.anchor);
linkBounds = cubicBounds(fromAnchor, cs[0], cs[1], toAnchor);
links.set(link.id, {
id: link.id,
fromNodeId: link.fromNodeId,
+7 -3
View File
@@ -4,7 +4,7 @@ import { GEOMETRY as G } from "./constants.js";
const title = (value: string) => value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
const textWidth = (value: string) => value.length * 6.5;
export const nodeRowUnits = (item: FxNodeUiRow): number =>
export const nodeRowUnits = (item: FxNodeUiRow, definition?: FxNodeDefinition): number =>
item.kind === "text" && item.variant === "header"
? 2
: item.kind === "widget"
@@ -13,7 +13,11 @@ export const nodeRowUnits = (item: FxNodeUiRow): number =>
: 8
: item.kind === "resource"
? 4
: 1;
: item.kind === "socket" &&
definition?.sockets[item.socket]?.direction === "input" &&
definition.sockets[item.socket]!.maxIncomingLinks > 1
? 2
: 1;
const controlWidth = (schema: FxNodeValueSchema | undefined, ramp = false) =>
!schema
? 80
@@ -82,7 +86,7 @@ export function minimumNodeSize(
}
return {
x: Math.min(G.maxWidth, Math.ceil(width)),
y: G.header + items.reduce((sum, item) => sum + nodeRowUnits(item), 0) * G.row + G.gap,
y: G.header + items.reduce((sum, item) => sum + nodeRowUnits(item, definition), 0) * G.row + G.gap,
};
}
export function initialNodeSize(
+6
View File
@@ -36,6 +36,12 @@ export interface LayoutSocket {
readonly capacity: number;
readonly linkIds: readonly LinkId[];
readonly anchor: Vec2;
/** Multi-capacity inputs use a vertical pill; ordinary and collapsed sockets remain circular. */
readonly shape: "circle" | "multi-input";
/** Authoritative world-space paint and hit bounds for a multi-input pill. */
readonly bounds?: Rect;
/** Stable world-space attachment point for each link occupying a multi-input pill. */
readonly linkAnchors: ReadonlyMap<LinkId, Vec2>;
readonly linked: boolean;
}
export type LayoutControlKind =
+4 -1
View File
@@ -479,7 +479,10 @@ function paintSocket(
const point = worldToView(socket.anchor, transform);
context.fillStyle = socket.color;
context.beginPath();
context.arc(point.x, point.y, G.socket * zoom, 0, Math.PI * 2);
if (socket.shape === "multi-input" && socket.bounds) {
const topLeft = worldToView({ x: socket.bounds.x, y: socket.bounds.y }, transform);
context.roundRect(topLeft.x, topLeft.y, socket.bounds.width * zoom, socket.bounds.height * zoom, G.socket * zoom);
} else context.arc(point.x, point.y, G.socket * zoom, 0, Math.PI * 2);
context.fill();
if (showLabel && zoom >= 0.35) {
context.fillStyle = theme.text;
+3 -1
View File
@@ -138,7 +138,9 @@ export function hitTest(layout: LayoutSnapshot, view: Vec2, preferredDirection?:
.filter(
(socket) =>
(!topNode || socket.nodeId === topNode.id) &&
Math.hypot(world.x - socket.anchor.x, world.y - socket.anchor.y) <= tolerance,
(socket.shape === "multi-input" && socket.bounds
? inRect(world, socket.bounds, tolerance)
: Math.hypot(world.x - socket.anchor.x, world.y - socket.anchor.y) <= tolerance),
)
.sort((a, b) => {
const an = layout.nodes.get(a.nodeId),
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
for (const example of ["minimal", "color-balance", "live-composition", "multi-view"])
for (const example of ["minimal", "color-balance", "live-composition", "logic-nodes", "multi-view"])
test(`${example} documentation image`, async ({ page }) => {
await page.goto(`/examples/${example}/`);
await page.evaluate(
+34 -3
View File
@@ -4,6 +4,7 @@ const examples = [
{ path: "minimal", nodeId: "value", typeId: "example.minimal.value" },
{ path: "color-balance", nodeId: "color-balance", typeId: "fxnode.compositor.color-balance" },
{ path: "live-composition", nodeId: "live-node", typeId: "example.live.parameter" },
{ path: "logic-nodes", nodeId: "and", typeId: "example.logic.and" },
] as const;
function capturePageErrors(page: Page): Error[] {
@@ -14,11 +15,11 @@ function capturePageErrors(page: Page): Error[] {
test("gallery links every standalone application and loads its images", async ({ page }) => {
await page.goto("/examples/");
await expect(page.locator(".gallery a")).toHaveCount(5);
await expect(page.locator(".gallery a")).toHaveCount(6);
expect(
await page.locator(".gallery a").evaluateAll((links) => links.map((link) => link.getAttribute("href"))),
).toEqual(["./minimal/", "./color-balance/", "./live-composition/", "./multi-view/", "./blender/"]);
await expect(page.locator(".gallery img")).toHaveCount(4);
).toEqual(["./minimal/", "./color-balance/", "./live-composition/", "./logic-nodes/", "./multi-view/", "./blender/"]);
await expect(page.locator(".gallery img")).toHaveCount(5);
expect(
await page
.locator(".gallery img")
@@ -144,6 +145,36 @@ test("multi-view keeps view input and selection local while graph changes fan ou
).toEqual({ root: null, views: 0 });
});
test("logic nodes accept five links through one input and application evaluation follows snapshots", async ({
page,
}) => {
const errors = capturePageErrors(page);
await page.goto("/examples/logic-nodes/");
await page.evaluate(() => window.fxnodeStandalone.ready);
const initial = await page.evaluate(async () => {
const state = await window.fxnodeStandalone.root!.getState();
return {
incoming: state.links.filter((link) => link.toSocketId === "and:inputs").length,
labels: [...document.querySelectorAll<HTMLElement>("#results span")].map((item) => item.textContent),
};
});
expect(initial.incoming).toBe(5);
expect(initial.labels).toContain("AND: false");
await page.evaluate(async () => {
const root = window.fxnodeStandalone.root!;
const source = (await root.getState()).nodes.find((node) => node.id === "c")!;
return root.dispatch({
type: "node.parameter",
id: source.id,
key: "value",
value: { kind: "boolean", value: true },
});
});
await expect(page.locator("#results span").filter({ hasText: "AND:" })).toHaveText("AND: true");
expect(errors).toEqual([]);
});
for (const example of examples) {
test(`${example.path} renders its known node and cleans up on pagehide`, async ({ page }) => {
const errors = capturePageErrors(page);
+1 -1
View File
@@ -18,7 +18,7 @@ test("examples server explicitly loads the repository Vite config", async () =>
test("standalone examples import library types and values only through the public entrypoint", async () => {
const root = new URL("../examples/", import.meta.url);
const directories = ["shared", "minimal", "color-balance", "live-composition"];
const directories = ["shared", "minimal", "color-balance", "live-composition", "logic-nodes"];
const files: string[] = [];
async function collect(directory: string): Promise<void> {
for (const entry of await readdir(new URL(directory, root), { withFileTypes: true })) {
+74 -5
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
import { commandId, nodeId } from "@lib/core/types.js";
import { commandId, linkId, nodeId } from "@lib/core/types.js";
import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js";
import { viewToWorld, worldToView } from "@lib/layout/geometry.js";
const layoutGraph = (document: any, transform: Parameters<typeof genericLayoutGraph>[2]) =>
@@ -163,8 +163,8 @@ test("all catalog types lay out deterministically", () => {
"fxnode.geometry.join-geometry",
"node",
"geometry",
["socket:input:geometry:1", "socket:output:result:1"],
[175, 76],
["socket:input:geometry:2", "socket:output:result:1"],
[175, 100],
[175, 100],
],
[
@@ -207,8 +207,8 @@ test("all catalog types lay out deterministically", () => {
"fxnode.common.group-output",
"node",
"output",
["control:string:interfaceName:1", "socket:input:input:1"],
[257, 76],
["control:string:interfaceName:1", "socket:input:input:2"],
[257, 100],
[257, 100],
],
[
@@ -448,6 +448,75 @@ test("expanded, collapsed, reroute and links have pinned geometry", () => {
assert.equal(snapshot.controls.get("math:socket:math:a")?.linked, true, "linked inputs hide controls");
});
test("multi-input sockets use pill hit bounds and stable per-link anchors", () => {
const join = materializeNode("join", "fxnode.geometry.join-geometry", { x: 160, y: 80 });
const sources = [0, 1, 2].map((index) =>
materializeNode(`cube-${index}`, "fxnode.geometry.mesh-cube", { x: -180, y: 180 - index * 160 }),
);
const linkIds = ["z-random", "a-random", "m-random"] as const;
const links = Object.fromEntries(
sources.map((source, index) => {
const id = linkIds[index]!;
return [
id,
{
id,
fromNodeId: source.id,
fromSocketId: source.sockets.find((socket) => socket.key === "mesh")!.id,
toNodeId: join.id,
toSocketId: join.sockets.find((socket) => socket.key === "geometry")!.id,
muted: false,
extensions: {},
},
];
}),
);
const snapshot = layoutGraph(
{
schemaVersion: 2,
graphId: "multi-input",
catalogVersion: 1,
nodes: Object.fromEntries([join, ...sources].map((node) => [node.id, node])),
links,
metadata: {},
},
transform,
);
const socket = snapshot.sockets.get(join.sockets.find((candidate) => candidate.key === "geometry")!.id)!;
assert.equal(socket.shape, "multi-input");
assert.deepEqual(socket.bounds, {
x: socket.anchor.x - 5,
y: socket.anchor.y + 20,
width: 10,
height: 40,
});
assert.equal(socket.linkAnchors.size, 3);
assert.equal(new Set([...socket.linkAnchors.values()].map((anchor) => anchor.y)).size, 3);
assert.ok(socket.linkAnchors.get(linkId("z-random"))!.y > socket.linkAnchors.get(linkId("a-random"))!.y);
assert.ok(socket.linkAnchors.get(linkId("a-random"))!.y > socket.linkAnchors.get(linkId("m-random"))!.y);
for (const [id, anchor] of socket.linkAnchors) {
assert.deepEqual(snapshot.links.get(id)!.points.at(-1), anchor);
}
assert.deepEqual(hitTest(snapshot, worldToView({ x: socket.anchor.x, y: socket.bounds!.y - 2 }, transform)), {
kind: "socket",
id: socket.id,
});
const collapsed = layoutGraph(
{
schemaVersion: 2,
graphId: "multi-input-collapsed",
catalogVersion: 1,
nodes: { join: { ...join, collapsed: true } },
links: {},
metadata: {},
},
transform,
).sockets.get(socket.id)!;
assert.equal(collapsed.shape, "circle");
assert.equal(collapsed.bounds, undefined);
});
test("frames have labelled fitted bounds around parent-local children", () => {
const frame = {
...materializeNode("frame", "fxnode.common.frame", { x: -200, y: 200 }),