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
+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),