Let a behavior be a node in another graph

A behavior that has a Start and an End node can now be dropped into any
other graph as a node of its own. What is declared on the start node
becomes the inputs of that node, what hangs on the end node its outputs;
the port list is derived from the sub graph, not copied, so declaring a
port in one graph grows the node in the other without touching it. The
node carries the behavior's name and the palette lists behaviors by
name, so a call is one grab instead of a node plus a dropdown.

An embedded behavior has no object and therefore no place for
variables: every call starts on the asset defaults and communicates
only through its ports. That is the conservative direction - state can
still be added later, and shared state already has a home in a behavior
object in the scene. Node ids are unique per graph only, so each call
gets its own pulse cache; without that, two graphs would collide.

Renaming a port used to silently drop the value typed on the calling
node. It is carried along now, project wide, like the edges.
This commit is contained in:
root committed 2026-09-09 20:01:49 +02:00
1 parent 64e034ffb9
commit 6702ff8abe
31 files changed
+1402 -107

No files matched your search

+23 -7
View File
@@ -1,26 +1,40 @@
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import { edgesInto, type GraphNode, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
import {
edgesInto,
type GraphNode,
inlineInputDef,
inlineInputs,
isTerminalNode,
portSignature,
resolveDef,
} from '../../core/graph';
import type { Asset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection } from './portSection';
import { previewingHandlers } from './previewHandlers';
/** Structure-defining facts of a node selection: type, connected inputs, declared variables. */
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
const asset = findAsset(project, assetId);
if (!asset || asset.type !== 'behavior') return 'missing';
const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing';
const def = nodeDef(node.type);
const lookup = behaviorLookup(project);
const def = resolveDef(node, lookup);
const connected = def
? inlineInputs(def)
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
.map((p) => p.key)
: [];
return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`;
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}`).join(',');
return `${portSignature(node, lookup)}|${declared}|${connected.join(',')}|${asset.variables
.map((v) => v.key)
.join(',')}`;
}
/** Description, settings (params) and the inline values of unconnected data inputs. */
@@ -28,12 +42,13 @@ 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 && nodeDef(node.type);
const def = node && resolveDef(node, behaviorLookup(host.state.project));
if (!node || !def) return;
addTitle(host, `${def.category.toUpperCase()} NODE`);
host.body.add(new Label(def.label, { style: 'bodyBold' }));
addNote(host, def.description);
if (isTerminalNode(node)) buildPortSection(host, assetId, node);
if (def.params.length > 0) addTitle(host, 'SETTINGS');
for (const param of def.params) {
@@ -74,14 +89,15 @@ 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 && nodeDef(node.type);
const def = node && resolveDef(node, behaviorLookup(host.state.project));
if (!node || !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);
const fallback =
scope === 'param'
? def.params.find((p) => p.key === name)?.default
: inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
: port && inlineInputDef(port).default;
row.setValue(node.params[name] ?? fallback ?? '');
}
return true;