Files
feuerwerk/src/core/graph/NodeRegistry.ts
T
adminandClaude Opus 5.5 79a04e6764 Edit a Parametric EQ in its node, let the wheel only zoom graphs (#381)
The small curve below the EQ node's rows (#375) only showed the bands.
Like the Compressor since #380, the node now shows the editor of its
tab between its inputs and its output, and is worked right there. A
double-click on the node still opens the same editor in a tab.

Two wishes for the nodes with a panel came on top and share the same
files, so they are in this commit as well: the numbers below the
Compressor's knobs read as plain labels (#382), and the wheel over a
node zooms the graph instead of turning a value (#383).

#381, the EQ in its node:
- EqPanel holds the curve, the band's numbers and the help line, taken
  out of EqEditor. The tab puts its one panel under a header, each EQ
  node has a panel of its own at 720 x 360 (EqFace).
- A press on the curve neither picks nor moves the node. A
  double-click on the curve adds a band, one on the title or the edge
  opens the tab.
- In the node a press on the Type or Slope button stays with the
  button, so the node is not picked or moved.
- The spectrum runs in the node while it is in the picture. The tab
  lets go of the tap once, like the Compressor's, and Editor.tick lets
  every unseen tab and graph go before any seen one listens.
- The count of bands moved from the tab's header into the band row,
  so the node shows it as well.
- The node picture of #375 is gone with its only user: NodePicture,
  EQ_PICTURE and NodeView's drawing of it.

#382, the numbers below the Compressor's knobs in its node:
- A Knob with typed: false shows its number as a centred Label without
  a box, in the panel's ink. The ratio reads 4:1 (ratioText).
- The label is as high as its text and sits up in the dial's open
  bottom, right under the ends of the scale.
- Label takes a colour of its own.

#383, the wheel in a graph:
- GraphCanvas takes the wheel on the way down and stops it there. It
  zooms over a node too, and no knob or curve in a node sees it.
- The tabs keep their wheel: a knob's step, a band's Q and the zoom
  of the EQ's dB.

Found on the way:
- A Menu opened below a button in the zoomed graph would have been
  placed by the button's own height. openBelow now takes the bottom
  edge as drawn.
- The band row did not lay out its labels after a text change when
  its own size stayed. In the tab a later resize hid it, in the node
  "Band 1" and the count were missing. The row now lays out itself.
- Handle and point positions for the checks were unscaled. They now
  go through toGlobal and hold at the graph's zoom.

Tests:
- Unit parametric-eq: 16 tests. The #375 tests now check
  eqCurvePoints and eqRangeOf directly. compressor-view: ratioText.
- e2e 34 (atx): ALL PASSED, 125 checks (3 new: the EQ panel and its
  ports, editing in the node, the spectrum in the node with the tab
  open). The Compressor node's check reads its numbers as labels and
  8:1. Both node checks now expect the wheel to zoom the graph and
  leave the ratio, the Q and the dB zoom alone.
- The unseen-tab check now leaves for the Scene tab, since the node
  in the graph listens to the same tap.
- Counter-check: the tab letting go of the tap every unseen frame
  stalls the node's spectrum with the tab open (1 frame against 22,
  probe on stein2).
- Gate (atx): CHECK EXIT 0, 283 files, 4357 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:40:11 +02:00

291 lines
12 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 { type DeclaredPortKind, isKindWith, plainKindOf } from '../properties/kinds';
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
import { type DeclaredPort, type Graph, type GraphNode, type PortType, portsCompatible } from './Graph';
import type { GraphLookup, ValueTyping } 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;
/** On a port writing a declared variable: what the written value is held to (#271). */
typing?: ValueTyping;
/** An input taking any number of edges, like an exec input: the layers of an instrument (#331). */
many?: true;
/** A number input an Envelope's curve can move while a note plays (#335). */
followsEnvelope?: true;
}
/** Whether an output may feed an input: by their types, and an envelope only where the number follows one. */
export function portsMeet(
out: Pick<PortDef, 'type'>,
inp: Pick<PortDef, 'type' | 'followsEnvelope'>,
): boolean {
if (out.type === 'envelope' && inp.type === 'number') return inp.followsEnvelope === true;
return portsCompatible(out.type, inp.type);
}
/**
* `state`, `shader`, `particles` and `sound` are the categories no behavior graph offers: their nodes
* are the states of a state machine, the GLSL of a shader asset, the motion of one particle and the
* effects of a sound FX asset.
*/
export type NodeCategory =
'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader' | 'particles' | 'sound';
/**
* 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>;
/** The flow input the call came in by, which is the output its start node leaves through (#294). */
entry: string;
}
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[];
/** The ports stand in the order given instead of exec pins first – a node laying out a whole side. */
inOrder?: true;
}
/** 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[];
/**
* Settings that do nothing at this value set, so neither the node body nor the inspector shows
* them – like the Direction of a linear key. Unlike `hiddenParams`, nothing is left to pick there.
* An unconnected input named here stays in the inspector, greyed out. `fed` says whether an
* input has an edge, which a value typed into it then does not count against.
*/
idleParams?(params: PropertyBag, fed?: (key: string) => boolean): string[];
/** Whether the node does nothing at this value set and is drawn faded, like a bypassed effect (#354). */
dimmed?(params: PropertyBag): boolean;
/**
* 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';
/** The kinds a declared port may have here; without it, every declarable kind. */
portKinds?: readonly DeclaredPortKind[];
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
declaredPort?(port: DeclaredPort, node: GraphNode, graph: Graph, lookup: GraphLookup): 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;
/** Comes and goes with something outside the graph, never added or deleted by hand: a song's tracks (#350). */
fixed?: true;
/** Links between such nodes may never lead round to where they started: a song's routing (#350). */
acyclic?: 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';
}
/** Whether an input keeps the edges it has when another one is connected to it. */
export function takesManyEdges(port: PortDef): boolean {
return port.type === 'exec' || port.many === true;
}
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' &&
port.type !== 'picture' &&
port.type !== 'audio' &&
port.type !== 'fxparams' &&
port.type !== 'trigger' &&
port.type !== 'envelope'
);
}
/** 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;
}