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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user