Let a Sequence grow an output as you connect the last one
The Outputs setting is gone. A Sequence now keeps one free output below the last connected one: nothing plugged in is one output, connecting the spare grows a new one, unplugging the bottom edge shrinks it back. It counts the last *connected* port rather than the number of edges, so unplugging a middle one leaves a gap instead of silently dropping an edge. Removing the setting also removes a bug it caused. Typing in the Outputs field did nothing and the arrow keys did nothing, while dragging the value worked: onInput fires per keystroke, the preview writes into node.params, that emits 'project', and Inspector.refreshValues() sees the node's signature change -- the port list is part of it -- so it rebuilds and destroys the row being typed in. A drag survives because it keeps receiving globalpointermove on the old widget. refreshValues() has no focus guard where refreshLive() does; the trap is still there for the next typed field that changes a node's shape. Ports that depend on the edges around a node mean derivePorts needs the graph, not just the params, so resolveDef and portSignature take it too. And a node whose ports are derived cannot know how many it has from its type, so run is handed the definition resolved for it.
This commit is contained in:
1 parent
b6a609ae24
commit
3c76106fd2
15 files changed
+159
-111
No files matched your search
@@ -136,7 +136,7 @@ export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
|
||||
super.execute(project);
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const def = resolveDef(node, behaviorLookup(project));
|
||||
const def = resolveDef(node, graph, behaviorLookup(project));
|
||||
if (!def) return;
|
||||
const outputs = new Set(def.outputs.map((p) => p.key));
|
||||
const inputs = new Set(def.inputs.map((p) => p.key));
|
||||
@@ -189,8 +189,8 @@ export class ConnectCommand implements Command {
|
||||
if (!fromNode || !toNode) throw new CommandError('Node not found');
|
||||
if (fromNode === toNode) throw new CommandError('A node cannot connect to itself');
|
||||
const lookup = behaviorLookup(project);
|
||||
const out = resolveDef(fromNode, lookup)?.outputs.find((p) => p.key === this.edge.from.port);
|
||||
const inp = resolveDef(toNode, lookup)?.inputs.find((p) => p.key === this.edge.to.port);
|
||||
const out = resolveDef(fromNode, graph, lookup)?.outputs.find((p) => p.key === this.edge.from.port);
|
||||
const inp = resolveDef(toNode, graph, lookup)?.inputs.find((p) => p.key === this.edge.to.port);
|
||||
if (!out || !inp) throw new CommandError('Port not found');
|
||||
if (!portsCompatible(out.type, inp.type))
|
||||
throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
|
||||
|
||||
@@ -95,7 +95,7 @@ export class GraphInterpreter {
|
||||
const def = this.def(node);
|
||||
if (!def?.run) return;
|
||||
const inputs = this.collectInputs(node, ctx);
|
||||
const result = def.run(ctx, inputs, node.params);
|
||||
const result = def.run(ctx, inputs, node.params, def);
|
||||
const returned = result.call ? this.call(result.call, ctx, depth) : {};
|
||||
this.frame.outputCache.set(node.id, { ...result.outputs, ...returned });
|
||||
this.trace?.onNode(node.id);
|
||||
@@ -174,14 +174,14 @@ export class GraphInterpreter {
|
||||
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
|
||||
// Mark before evaluating so a data cycle reads null instead of recursing forever.
|
||||
this.frame.dataCache.set(nodeId, {});
|
||||
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
|
||||
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params, def).outputs ?? {};
|
||||
this.frame.dataCache.set(nodeId, outputs);
|
||||
this.trace?.onNode(nodeId);
|
||||
return outputs[port] ?? null;
|
||||
}
|
||||
|
||||
private def(node: GraphNode) {
|
||||
return resolveDef(node, this.behaviors);
|
||||
return resolveDef(node, this.frame.graph, this.behaviors);
|
||||
}
|
||||
|
||||
private node(id: string): GraphNode | undefined {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { PortType } from './Graph';
|
||||
import type { Graph, GraphNode, PortType } from './Graph';
|
||||
import type { BehaviorLookup } from './lookup';
|
||||
import type { NodeContext } from './NodeContext';
|
||||
import { asBoolean, asNumber, asString, type Value } from './values';
|
||||
@@ -42,9 +42,10 @@ export interface RunResult {
|
||||
call?: CallResult;
|
||||
}
|
||||
|
||||
/** What a node may compute its ports from: its own settings, and the behaviors around it. */
|
||||
/** What a node may compute its ports from: itself, the graph it sits in, and the behaviors around it. */
|
||||
export interface PortSource {
|
||||
params: PropertyBag;
|
||||
node: GraphNode;
|
||||
graph: Graph;
|
||||
behaviors: BehaviorLookup;
|
||||
}
|
||||
|
||||
@@ -83,8 +84,10 @@ export interface NodeDef {
|
||||
/**
|
||||
* Pure data nodes are evaluated on demand when a downstream input needs their value; exec nodes
|
||||
* run when the flow reaches them. Event nodes have no `run` – the interpreter seeds their outputs.
|
||||
* `def` is this definition as resolved for the node at hand, which is the only way a node with
|
||||
* derived ports can know how many it has.
|
||||
*/
|
||||
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag): RunResult;
|
||||
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag, def: NodeDef): RunResult;
|
||||
}
|
||||
|
||||
const defs = new Map<string, NodeDef>();
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface PortSide {
|
||||
|
||||
export function portTypeOf(graph: Graph, side: PortSide, lookup: BehaviorLookup): PortType | undefined {
|
||||
const node = findNode(graph, side.nodeId);
|
||||
const def = node && resolveDef(node, lookup);
|
||||
const def = node && resolveDef(node, graph, lookup);
|
||||
const list = side.isOutput ? def?.outputs : def?.inputs;
|
||||
return list?.find((p) => p.key === side.port)?.type;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function dropTargetPort(
|
||||
): PortSide | null {
|
||||
if (nodeId === from.nodeId) return null;
|
||||
const node = findNode(graph, nodeId);
|
||||
const def = node && resolveDef(node, lookup);
|
||||
const def = node && resolveDef(node, graph, lookup);
|
||||
const fromType = portTypeOf(graph, from, lookup);
|
||||
if (!def || fromType === undefined) return null;
|
||||
const candidates = compatiblePorts(def, fromType, from.isOutput)
|
||||
|
||||
@@ -41,12 +41,12 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[]
|
||||
* The node definition as it applies to *this* node: identical to the registered one, unless the
|
||||
* type declares ports per node (`declaresPorts`) or computes them (`derivePorts`).
|
||||
*/
|
||||
export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | undefined {
|
||||
export function resolveDef(node: GraphNode, graph: Graph, lookup: BehaviorLookup): NodeDef | undefined {
|
||||
const def = nodeDef(node.type);
|
||||
if (!def || (!def.declaresPorts && !def.derivePorts)) return def;
|
||||
const own = def.declaresPorts ? (node.ports ?? []).map(portDefOf) : [];
|
||||
const side = (which: 'inputs' | 'outputs') => (def.declaresPorts === which ? own : []);
|
||||
const grown = def.derivePorts?.({ params: node.params, behaviors: lookup }) ?? {};
|
||||
const grown = def.derivePorts?.({ node, graph, behaviors: lookup }) ?? {};
|
||||
return {
|
||||
...def,
|
||||
label: grown.label ?? def.label,
|
||||
@@ -74,8 +74,8 @@ export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = '
|
||||
* Everything that changes how a node looks, as one string. Views and the inspector rebuild when it
|
||||
* changes – a port list that is derived rather than fixed has no other way of being noticed.
|
||||
*/
|
||||
export function portSignature(node: GraphNode, lookup: BehaviorLookup, ownerId = ''): string {
|
||||
const def = resolveDef(node, lookup);
|
||||
export function portSignature(node: GraphNode, graph: Graph, lookup: BehaviorLookup, ownerId = ''): string {
|
||||
const def = resolveDef(node, graph, lookup);
|
||||
if (!def) return 'missing';
|
||||
const list = (ports: PortDef[]) => ports.map((p) => `${p.key}:${p.type}`).join(',');
|
||||
return `${node.type}|${def.label}|${list(def.inputs)}|${list(def.outputs)}|${
|
||||
|
||||
@@ -18,8 +18,8 @@ export const RunBehavior: NodeDef = {
|
||||
inputs: [exec()],
|
||||
outputs: [exec()],
|
||||
params: [{ key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] }],
|
||||
derivePorts: (source) => {
|
||||
const program = source.behaviors(textOf(source.params[BEHAVIOR_PARAM] ?? ''));
|
||||
derivePorts: ({ node, behaviors }) => {
|
||||
const program = behaviors(textOf(node.params[BEHAVIOR_PARAM] ?? ''));
|
||||
return {
|
||||
// The graph reads as what it does, not as a row of identical "Run behavior" boxes.
|
||||
label: program?.name,
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import type { PropertyBag } from '../../../model/Properties';
|
||||
import type { Graph, GraphNode } from '../../Graph';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
|
||||
const COUNT = 'count';
|
||||
/** Below two outputs a Sequence is just a link, so two are fixed and the rest are derived. */
|
||||
const FIXED = 2;
|
||||
/** One letter per output, so `a` stays `a` – the alphabet is what sets the ceiling. */
|
||||
const MAX = 26;
|
||||
|
||||
export function sequenceKey(index: number): string {
|
||||
function sequenceKey(index: number): string {
|
||||
return String.fromCharCode(97 + index);
|
||||
}
|
||||
|
||||
export function sequenceCount(params: PropertyBag): number {
|
||||
const count = Math.round(asNumber(params[COUNT] ?? 3));
|
||||
return Math.max(FIXED, Math.min(MAX, count));
|
||||
function sequenceIndex(key: string): number {
|
||||
return key.length === 1 ? key.charCodeAt(0) - 97 : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* One free output below the last connected one: plugging into the spare grows the node, unplugging
|
||||
* the bottom edge shrinks it again. No output can vanish while an edge hangs on it, which is why
|
||||
* this counts the last *connected* port rather than the connected ones.
|
||||
*/
|
||||
function outputCount(node: GraphNode, graph: Graph): number {
|
||||
let last = -1;
|
||||
for (const edge of graph.edges)
|
||||
if (edge.from.node === node.id) last = Math.max(last, sequenceIndex(edge.from.port));
|
||||
return Math.min(MAX, last + 2);
|
||||
}
|
||||
|
||||
function outputAt(index: number) {
|
||||
@@ -23,23 +30,19 @@ function outputAt(index: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys are `a`, `b`, `c`, … rather than `out1`…`outN`: those three are what every Sequence
|
||||
* saved so far names its outputs, and renaming them would drop the edges hanging on them.
|
||||
* The keys are `a`, `b`, `c`, … rather than `out1`…`outN`: that is what every Sequence saved so far
|
||||
* names its outputs, and renaming them would drop the edges hanging on them.
|
||||
*/
|
||||
export const Sequence: NodeDef = {
|
||||
type: 'Sequence',
|
||||
label: 'Sequence',
|
||||
category: 'flow',
|
||||
description: 'Runs its outputs one after another.',
|
||||
description: 'Runs its outputs one after another. A new output appears as you connect the last one.',
|
||||
inputs: [exec()],
|
||||
outputs: [outputAt(0), outputAt(1)],
|
||||
params: [
|
||||
{ key: COUNT, label: 'Outputs', kind: 'number', default: 3, min: FIXED, max: MAX, step: 1, decimals: 0 },
|
||||
],
|
||||
derivePorts: ({ params }) => ({
|
||||
outputs: Array.from({ length: sequenceCount(params) - FIXED }, (_, i) => outputAt(FIXED + i)),
|
||||
}),
|
||||
run: (_ctx, _inputs, params) => ({
|
||||
next: Array.from({ length: sequenceCount(params) }, (_, i) => sequenceKey(i)),
|
||||
outputs: [outputAt(0)],
|
||||
params: [],
|
||||
derivePorts: ({ node, graph }) => ({
|
||||
outputs: Array.from({ length: outputCount(node, graph) - 1 }, (_, i) => outputAt(i + 1)),
|
||||
}),
|
||||
run: (_ctx, _inputs, _params, def) => ({ next: def.outputs.map((p) => p.key) }),
|
||||
};
|
||||
@@ -235,7 +235,7 @@ export class GraphCanvas extends Widget {
|
||||
const seen = new Set<string>();
|
||||
const lookup = behaviorLookup(this.state.project);
|
||||
for (const node of asset.graph.nodes) {
|
||||
const def = resolveDef(node, lookup);
|
||||
const def = resolveDef(node, asset.graph, lookup);
|
||||
if (!def) continue;
|
||||
seen.add(node.id);
|
||||
let view = this.views.get(node.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
allNodeDefs,
|
||||
BEHAVIOR_PARAM,
|
||||
createEmptyGraph,
|
||||
isTerminalType,
|
||||
type NodeDef,
|
||||
resolveDef,
|
||||
@@ -39,7 +40,8 @@ export function paletteEntries(project: Project, asset: BehaviorAsset): PaletteE
|
||||
// A behavior running itself is an endless loop, so it is not on offer.
|
||||
if (other.type !== 'behavior' || other.id === asset.id) continue;
|
||||
const params = { [BEHAVIOR_PARAM]: other.id };
|
||||
const def = resolveDef({ id: '', type: RUN_BEHAVIOR, x: 0, y: 0, params }, lookup);
|
||||
// The entry is a preview of a node that sits in no graph yet, hence the empty one.
|
||||
const def = resolveDef({ id: '', type: RUN_BEHAVIOR, x: 0, y: 0, params }, createEmptyGraph(), lookup);
|
||||
if (def) entries.push({ id: `${RUN_BEHAVIOR}:${other.id}`, hint: 'behavior', def, params });
|
||||
}
|
||||
return entries;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import {
|
||||
callProblem,
|
||||
type Graph,
|
||||
edgesInto,
|
||||
type GraphNode,
|
||||
inlineInputDef,
|
||||
@@ -25,14 +26,14 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
|
||||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return 'missing';
|
||||
const lookup = behaviorLookup(project);
|
||||
const def = resolveDef(node, lookup);
|
||||
const def = resolveDef(node, asset.graph, lookup);
|
||||
const connected = def
|
||||
? inlineInputs(def)
|
||||
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
|
||||
.map((p) => p.key)
|
||||
: [];
|
||||
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}`).join(',');
|
||||
return `${portSignature(node, lookup, assetId)}|${declared}|${connected.join(',')}|${asset.variables
|
||||
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${asset.variables
|
||||
.map((v) => v.key)
|
||||
.join(',')}`;
|
||||
}
|
||||
@@ -42,7 +43,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
const asset = findAsset(host.state.project, assetId);
|
||||
if (!asset || asset.type !== 'behavior') return;
|
||||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||
const def = node && resolveDef(node, behaviorLookup(host.state.project));
|
||||
const def = node && resolveDef(node, asset.graph, behaviorLookup(host.state.project));
|
||||
if (!node || !def) return;
|
||||
|
||||
addTitle(host, `${def.category.toUpperCase()} NODE`);
|
||||
@@ -90,9 +91,9 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
|
||||
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
|
||||
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
|
||||
const node = currentNode(host.state.project, assetId, nodeId);
|
||||
const def = node && resolveDef(node, behaviorLookup(host.state.project));
|
||||
if (!node || !def) return false;
|
||||
const found = currentNode(host.state.project, assetId, nodeId);
|
||||
const def = found && resolveDef(found.node, found.graph, behaviorLookup(host.state.project));
|
||||
if (!found || !def) return false;
|
||||
for (const [key, row] of host.fields) {
|
||||
const [scope, name] = key.split(':') as ['param' | 'input', string];
|
||||
const port = def.inputs.find((p) => p.key === name);
|
||||
@@ -100,7 +101,7 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId:
|
||||
scope === 'param'
|
||||
? def.params.find((p) => p.key === name)?.default
|
||||
: port && inlineInputDef(port).default;
|
||||
row.setValue(node.params[name] ?? fallback ?? '');
|
||||
row.setValue(found.node.params[name] ?? fallback ?? '');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -117,7 +118,7 @@ function resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef {
|
||||
/** Previews straight into the node's params; the command records the value from before the preview. */
|
||||
function paramHandlers(host: InspectorHost, assetId: string, nodeId: string, key: string): FieldHandlers {
|
||||
return previewingHandlers<GraphNode>(host, {
|
||||
target: () => currentNode(host.state.project, assetId, nodeId),
|
||||
target: () => currentNode(host.state.project, assetId, nodeId)?.node,
|
||||
read: (node) => node.params[key],
|
||||
write: (node, value) => (node.params[key] = value),
|
||||
remove: (node) => delete node.params[key],
|
||||
@@ -125,7 +126,14 @@ function paramHandlers(host: InspectorHost, assetId: string, nodeId: string, key
|
||||
});
|
||||
}
|
||||
|
||||
function currentNode(project: Project, assetId: string, nodeId: string): GraphNode | undefined {
|
||||
/** The node together with the graph it sits in, because its ports are derived from both. */
|
||||
function currentNode(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
nodeId: string,
|
||||
): { node: GraphNode; graph: Graph } | undefined {
|
||||
const asset = findAsset(project, assetId);
|
||||
return asset?.type === 'behavior' ? asset.graph.nodes.find((n) => n.id === nodeId) : undefined;
|
||||
if (asset?.type !== 'behavior') return undefined;
|
||||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||
return node && { node, graph: asset.graph };
|
||||
}
|
||||
Reference in new issue
Block a user