Files
feuerwerk/src/core/graph/NodeRegistry.ts
T
adminandClaude Opus 5.5 2aaca01929 Find the first matching item with a Find … Match block (#255)
Searching a list with For each needs the test result wired back into
break on the left of the loop node. The wires then run across the node
and the graph reads badly. Find … Match keeps the whole search flowing
left to right, while the test in between stays a free graph.

- Find takes exec and list and offers item and index. It sets them to
  each element in turn and has no exec output of its own.
- Match reads its bool input match once per element. The first true
  value continues at found with item and index of the hit. No hit or an
  empty list continues at not found with an empty item and index -1.
- The pair is derived from the wiring and stored nowhere. A Match
  belongs to the Find whose item or index its test is built from. Copy,
  paste and undo therefore need no extra code (findBlock.ts).
- An unpaired Find or Match, two Matches on one Find and a Match fed
  by two Finds show a warning on the node. At run time the flow ends at
  an unpaired Find and the console says so.
- Adding Find from the palette places its Match 480 px to the right in
  the same undo step.
- The graph draws a quiet frame behind Find, Match and the nodes of the
  test. Literals feeding the test from outside stay outside the frame.
- The test consists of data nodes only, like the break of For each.

Tests:
- ./check.sh: 184 files, 2694 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6: ALL PASSED, 272 checks
- New find-match.test.ts with 12 tests, 2 catalog cases, e2e 6z-v
  with 3 checks using a real mouse.
- Counter-checks: ignoring the test fails 4 unit tests, no ambiguity
  check fails 2, framing everything fails 1. In the browser, no frame
  and no Match from the palette each fail their check.
- Check total in README: 1517.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:33:25 +02:00

242 lines
10 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 { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
import { isKindWith, plainKindOf } from '../properties/kinds';
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
import type { GraphLookup } from './lookup';
import type { NodeContext } from './NodeContext';
import type { SharedPortGroup } from './portTypes';
import { asList, namedValue, type Value } from './values';
export interface PortDef {
key: string;
label: string;
type: PortType;
/** Inline value used while the input is unconnected and the node has no own value (else 0/''/false). */
default?: PropertyValue;
/** The property this port carries; its typed-in value is edited as that property (color, enum, …). */
field?: PropertyDef;
/** `false`: nothing to type in even for a number – unconnected, the node's own rule applies. */
inline?: boolean;
/** Not a port of the definition: a key only an edge still names, drawn so that edge can be seen. */
ghost?: true;
/**
* Drawing only: the colour an `any` input is painted in, taken from the edge that feeds it (#207).
* Its `type` stays `any`, so the port keeps taking everything and can be swapped at any time –
* only the pin says what currently flows in. Set by `drawnDef`, read by `NodeView`, nowhere else.
*/
paintType?: PortType;
}
/**
* `state`, `shader` and `particles` are the categories no behavior graph offers: their nodes are the
* states of a state machine, the GLSL of a shader asset and the motion of one particle.
*/
export type NodeCategory =
'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader' | 'particles';
/**
* How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body,
* `index` is the number output seeded with the round number.
*/
export interface RepeatResult {
port: string;
index: string;
times: number;
/** Round i also sets output `port` to `values[i]` – the element a `For each` is at. */
items?: { port: string; values: readonly Value[] };
/**
* Boolean input read anew before every round, after the round's outputs are set. True ends the
* loop there: that round's body does not run, and its outputs stay for what follows.
*/
stopWhen?: string;
/** Boolean output telling whether `stopWhen` ended the loop. */
stopped?: string;
/** Outputs standing after a loop that ran through all rounds without being stopped. */
exhausted?: Record<string, Value>;
}
/** How a node asks for another behavior's graph to be run in place, `inputs` seeding its start node. */
export interface CallResult {
behaviorId: string;
inputs: Record<string, Value>;
}
export interface RunResult {
/** Cached for the rest of the pulse. */
outputs?: Record<string, Value>;
/** Exec output(s) to continue along; omitted = the node ends the flow. */
next?: string | string[];
/** Runs `port` that many times before continuing along `next`. */
repeat?: RepeatResult;
/** Runs another behavior before continuing along `next`; its results join `outputs`. */
call?: CallResult;
/**
* The elements a `Find` tests one by one against the `Match` closing its block. The flow goes on
* from that Match, not from the Find.
*/
find?: readonly Value[];
}
/** What a node may compute its ports from: itself, the graph it sits in, and the project around it. */
export interface PortSource {
node: GraphNode;
graph: Graph;
lookup: GraphLookup;
}
/** Ports a single node has on top of the fixed ones of its type, and a label naming what it does. */
export interface DerivedShape {
label?: string;
inputs?: PortDef[];
outputs?: PortDef[];
}
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
export interface NodeDef {
type: string;
label: string;
category: NodeCategory;
description: string;
inputs: PortDef[];
outputs: PortDef[];
/** Node settings that are not ports (key name, operator, variable). */
params: PropertyDef[];
/**
* Settings that say nothing at this value set, so the node body leaves them off; the inspector
* still offers them. Without it a switched-off repeat is two rows of "0" on every key node.
*/
hiddenParams?(params: PropertyBag): string[];
/**
* Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it
* is what gives a node the port editor in the inspector. `both`: every port is an output, and an
* input as well where it is marked `write`.
*/
declaresPorts?: 'inputs' | 'outputs' | 'both';
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
declaredPort?(port: DeclaredPort, node: GraphNode): PortDef;
/**
* Edges between two such nodes are transitions: drawn as a straight arrow instead of the usual
* curve, and hung on ports that are never shown. A transition graph is read as directed steps
* between whole states, not as signal flowing left to right into a particular socket.
*/
transitionEdges?: true;
/** The node that comes along when this one is added, `dx` to the right: the end of its block. */
opens?: { type: string; dx: number };
/**
* Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence
* offers, which values the behavior a call node names takes. `resolveDef` applies it.
*/
derivePorts?(source: PortSource): DerivedShape;
/**
* Ports carrying one and the same value: the outputs take the type the inputs agree on, so an
* `any` output is drawn, validated and edited as what actually flows through it (#207).
* `resolveDef` applies it; only the outputs change, the inputs stay `any` and take everything.
*/
sharedType?: SharedPortGroup;
/**
* Outputs readable at any moment, even while the node's exec input has not fired in this pulse.
* Never cached, so a read after a write sees the new value. `input` pulls one input on demand:
* pulling all of them would loop through an edge that feeds the node's own output back in.
* `def` and `lookup` are as in `run`: the definition resolved for this very node, and the project
* around it – what a reader with one output per state needs to answer for all of them.
*/
read?(
ctx: NodeContext,
params: PropertyBag,
input: (key: string) => Value,
def: NodeDef,
lookup: GraphLookup,
): Record<string, Value>;
/**
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
* hand, the only way a node with derived ports knows how many it has. `entry` is the exec input
* the pulse arrived through – what a node with one exec input per meaning reads. `lookup` is the
* project around the graph, for a node whose step is written in another asset.
*/
run?(
ctx: NodeContext,
inputs: Record<string, Value>,
params: PropertyBag,
def: NodeDef,
entry: string,
lookup: GraphLookup,
): RunResult;
}
const defs = new Map<string, NodeDef>();
export function registerNode(def: NodeDef): NodeDef {
if (defs.has(def.type)) throw new Error(`Node type "${def.type}" registered twice`);
defs.set(def.type, def);
return def;
}
export function nodeDef(type: string): NodeDef | undefined {
return defs.get(type);
}
export function allNodeDefs(): NodeDef[] {
return [...defs.values()];
}
export function isEventNode(def: NodeDef): boolean {
return def.category === 'event';
}
export function hasExecInput(def: NodeDef): boolean {
return def.inputs.some((p) => p.type === 'exec');
}
/**
* Data inputs that may carry an inline value while unconnected. An object is a live handle and a
* struct is assembled from its parts, so neither has a value that could be typed on one line; a
* shader colour has one only where the node gives it a colour field.
*/
export function inlineInputs(def: NodeDef): PortDef[] {
return def.inputs.filter(isInlinePort);
}
function isInlinePort(port: PortDef): boolean {
if (port.inline === false) return false;
if (port.type === 'vec3') return port.field !== undefined;
return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct';
}
/** Untyped, an input writes the zero of its kind; as its default the node body shows it, not just the name. */
export function withInlineDefault(port: PortDef): PortDef {
return isInlinePort(port) ? { ...port, default: inlineInputDef(port).default } : port;
}
export function inlineInputDef(port: PortDef): PropertyDef {
if (port.field) return { ...port.field, key: port.key, label: port.label };
const kind = port.type === 'any' ? 'text' : plainKindOf(port.type);
// A declared default is a stored value, never a live handle.
if (kind === 'list')
return { key: port.key, label: port.label, kind, default: namedValue(asList(port.default)) as ListValue };
const flat = kind !== undefined && isKindWith('field', kind) ? kind : 'number';
const def = flatDef(port.key, flat, { label: port.label, value: port.default });
return def.kind === 'number' ? { ...def, step: 1 } : def;
}
/**
* The value an unconnected input carries: the node's own inline value, else the default the
* inspector shows for it – `false`, `0`, `""`, an empty list –, so what the node reads is what the
* editor says it reads. Only an input with nothing to type in (object, struct, exec) is `null`.
*/
export function inlineValue(node: { params: PropertyBag }, port: PortDef): Value {
return node.params[port.key] ?? port.default ?? shownDefault(port);
}
/** Cached per port: the interpreter asks for it on every read of an unconnected input. */
const shownDefaults = new WeakMap<PortDef, PropertyValue | null>();
function shownDefault(port: PortDef): Value {
let value = shownDefaults.get(port);
if (value === undefined) {
value = isInlinePort(port) ? inlineInputDef(port).default : null;
shownDefaults.set(port, value);
}
// A list is handed out fresh: a node that edits its input in place must not edit every other one.
return isList(value) ? [...value] : value;
}