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:
+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),
|
||||
|
||||
Reference in New Issue
Block a user