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:
Vendored
+53
@@ -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);
|
||||
```
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
Vendored
+5
-5
@@ -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
@@ -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
|
||||
|
||||

|
||||
|
||||
_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.
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Vendored
+5
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Vendored
+6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 })) {
|
||||
|
||||
Vendored
+74
-5
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user