Files
feuerwerk/src/core/graph/dropTarget.ts
T
rootandClaude Fable 5.1 e7d82bd1a9 State machines: asset, tab, State branch and Set state nodes (#51)
A state machine is an asset whose graph holds one `State` node per state
(no ports, name as title), edited in a new "State machine" tab that reuses
the graph canvas: right-click places "State n", snap toggle shared with the
Behavior tab via SnapToggle. `statemachine` is a value kind like texture
(port, literal node, variable, struct field, tagged list item).

Behaviors use it through `State branch` (one exec output per state) and
`Set state` (one exec input per state; the interpreter now hands `run` the
entry port). States live per behavior instance in NodeContext.states, with
no initial state. Ports derive from the inline picker or a wired
`State machine` literal; BehaviorLookup became GraphLookup with
behavior(id) and machine(id).

The Graph tab is labelled "Behavior" (key unchanged). Unit tests
(state-machine, kinds, catalog, ports), e2e step 26 and README added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 18:00:23 +02:00

92 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edgesInto, findNode, type Graph, type PortType, portsCompatible } from './Graph';
import type { GraphLookup } from './lookup';
import { resolveDef } from './nested';
import type { NodeDef } from './NodeRegistry';
/** One end of an edge being dragged: a port on a node, seen from its own side. */
export interface PortSide {
nodeId: string;
port: string;
isOutput: boolean;
}
export function portTypeOf(graph: Graph, side: PortSide, lookup: GraphLookup): PortType | undefined {
const node = findNode(graph, side.nodeId);
const def = node && resolveDef(node, graph, lookup);
const list = side.isOutput ? def?.outputs : def?.inputs;
return list?.find((p) => p.key === side.port)?.type;
}
/** Whether a drag from `from` may land on `to`, i.e. whether ConnectCommand would accept it. */
export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: GraphLookup): boolean {
if (from.isOutput === to.isOutput || from.nodeId === to.nodeId) return false;
const fromType = portTypeOf(graph, from, lookup);
const toType = portTypeOf(graph, to, lookup);
if (fromType === undefined || toType === undefined) return false;
const [out, inp] = from.isOutput ? [fromType, toType] : [toType, fromType];
return portsCompatible(out, inp) && !linked(graph, from, to);
}
/**
* Which port a drag from `from` lands on when it is dropped on node `nodeId` rather than on a port:
* the first free compatible one, or – when none is free – the last compatible one, whose edge is
* then replaced. Returns null when the node offers nothing to connect to.
*/
export function dropTargetPort(
graph: Graph,
from: PortSide,
nodeId: string,
lookup: GraphLookup,
): PortSide | null {
if (nodeId === from.nodeId) return null;
const node = findNode(graph, nodeId);
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)
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, type: p.type }))
.filter((c) => !linked(graph, from, c.side));
const free = candidates.find((c) => isFree(graph, c.side, c.type));
return (free ?? candidates.at(-1))?.side ?? null;
}
/**
* Whether a node of this type has any port a drag from `fromType` could land on – the filter for
* the palette that opens when an edge is dropped into empty space.
*/
export function defAcceptsFrom(def: NodeDef, fromType: PortType, fromIsOutput: boolean): boolean {
return compatiblePorts(def, fromType, fromIsOutput).length > 0;
}
/**
* The port a *fresh* node of this type offers such a drag. Every port of a node that is not in the
* graph yet is free, so the first compatible one is what `dropTargetPort` would pick for it.
*/
export function firstPortFor(def: NodeDef, fromType: PortType, fromIsOutput: boolean): string | null {
return compatiblePorts(def, fromType, fromIsOutput)[0]?.key ?? null;
}
function compatiblePorts(def: NodeDef, fromType: PortType, fromIsOutput: boolean) {
const ports = fromIsOutput ? def.inputs : def.outputs;
return ports.filter((p) =>
fromIsOutput ? portsCompatible(fromType, p.type) : portsCompatible(p.type, fromType),
);
}
/** Only a data input is exclusive – exec inputs and every output take any number of edges. */
function isFree(graph: Graph, side: PortSide, type: PortType): boolean {
if (side.isOutput || type === 'exec') return true;
return edgesInto(graph, side.nodeId, side.port).length === 0;
}
function linked(graph: Graph, a: PortSide, b: PortSide): boolean {
const [out, inp] = a.isOutput ? [a, b] : [b, a];
return graph.edges.some(
(e) =>
e.from.node === out.nodeId &&
e.from.port === out.port &&
e.to.node === inp.nodeId &&
e.to.port === inp.port,
);
}