Files
feuerwerk/src/editor/inspector/portSection.ts
T
adminandClaude Opus 5.5 a526e1b40f
game-maker / gate (push) Successful in 3m16s
game-maker / suite (push) Failing after 14m37s
Use a shader graph as a node in other shaders (#303)
A calculation needed in several shaders (a greyscale, a noise, an
outline) had to be built again in every one of them. A shader
function keeps it as an asset of its own, called like a behavior is
since #16.

- New asset type `shaderfunction` ("Shader function"), edited in the
  Shader tab. The panel beside the graph shows no picture, the
  readout counts its inputs and outputs.
- Two new nodes: `Function input` and `Function output`. Their
  declared ports, numbers or colours only, are the ports of every
  call. One of each per function, like Start and End.
- The call is node `ShaderCall`, named after the function. The
  palette lists every function, it can be dragged in from the Assets
  panel, and a double-click opens it.
- The compiler sets the function in where it is called
  (`inlineFunctions`). It renames the copied nodes `<call>/<node>`
  and turns an unconnected port into a constant. Passes, Time, the
  Edge cut and the corner walk then see a plain graph.
- A problem inside a function is reported at the call, in the
  function's words. The function's own tab checks it on its own
  (`checkShaderFunction`).
- `ShaderLibrary` keys a shader on its graph and on every function it
  calls, however deep, so a change reaches every caller.
- The port commands mirror a new, renamed or removed port into every
  call, as for a behavior's Start and End. `NodeDef.portKinds` holds
  the two ends to Number and Color.
- As agreed in the issue: no `Property`, `Color property`, Output or
  Vertex output in a function, and `Time` is the caller's clock.
- Own decision: a `Pass` may stand in a function. It becomes a pass
  of the calling shader, so a wide blur can be a function.
- A function calling itself, also through another, says so at the
  node and is not set in. The palette does not offer such a call.
- The Shader tab showed only the compiler's problems. A call nothing
  reaches was never compiled and looked healthy, so a call now also
  shows its own problem there, as in a behavior.

Tests:
- Gate: 228 files, 3510 tests, CHECK EXIT 0 (+13: 11 in
  tests/core/shader-functions.test.ts, 2 for the new icon)
- e2e 28: 100 checks, all passed (+3 in 28l-viii). Called and in
  place both #606060/#a2a2a2, halved in the function #303030/#515151.
- Counter-check: a fingerprint without the called functions fails 1
  unit test, and in the browser the change check (#606060 stays).
- The first e2e runs found two faults, both fixed: a double-click
  only selected the function, and an unreached call of itself
  showed no problem.
- e2e total 1728 is computed (1725 + 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:09:53 +02:00

329 lines
13 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 {
AddPortCommand,
AddPortsCommand,
nextPortName,
RemovePortCommand,
RenamePortCommand,
RepointPortCommand,
SetPortWriteCommand,
} from '../../core/commands/portCommands';
import {
type DeclaredPort,
type GraphNode,
isEntryTerminal,
isPortOf,
isTerminalNode,
missingFieldPorts,
nodeDef,
OBJECT_BEHAVIOR_PARAM,
OBJECT_BEHAVIOR_SETTING,
OBJECT_TYPE_SETTING,
portKindOf,
SHADER_FUNCTION_INPUT,
SHADER_FUNCTION_OUTPUT,
VARIABLES_SETTING,
variablesSourceOf,
propertyTargetOf,
structShapeOf,
targetLabel,
targetProperties,
} from '../../core/graph';
import { isPrivateVariable } from '../../core/model/Asset';
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties';
import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds';
import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import {
Checkbox,
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
type SectionHeader,
Spacer,
TextField,
Theme,
type Widget,
} from '../../ui';
import { NAME_WIDTH, removeButton } from './fieldFactory';
import { addNote, type InspectorHost, sectionHeader } from './host';
import { kindMenu, markLabel } from './variableRows';
/** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */
export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] {
const source = variablesSourceOf(node.params);
if (source) return findAssetOf(project, source, 'variables')?.variables ?? [];
const asset = findGraphAsset(project, assetId);
// A state machine declares no variables; its one node type has none to name.
return asset?.type === 'behavior' ? asset.variables : [];
}
/**
* The ports of a Variables node: each one *is* a declared variable, so it is picked from a list
* rather than named, and its kind follows the declaration. Set makes it written too.
*/
export function buildVariablePortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
addSetting: (def: PropertyDef) => void,
): void {
addSetting(VARIABLES_SETTING);
const source = variablesSourceOf(node.params);
const shared = source ? findAssetOf(host.state.project, source, 'variables') : undefined;
buildPickedPortSection(host, assetId, node, {
noun: 'Variable',
choices: variableChoices(host.state.project, assetId, node),
owner: shared ? `"${shared.name}"` : 'this behavior',
empty: source
? shared
? `"${shared.name}" declares no variables yet.`
: 'That Variables asset is gone.'
: 'This behavior has no variables yet.',
});
}
/** The type decides what the ports are picked from; Any names them by hand, with a kind picked. */
export function buildPropertyPortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
addSetting: (def: PropertyDef) => void,
): void {
const target = propertyTargetOf(node.params);
addSetting(OBJECT_TYPE_SETTING);
if (target === 'any') {
buildPickedPortSection(host, assetId, node, { noun: 'Property', choices: [], owner: '', custom: true });
return;
}
if (target !== 'behavior') {
const owner = `a ${targetLabel(target)}`;
// A sprite's, text's or camera's rows go on with the parameters of the shader it wears – any shader,
// the node cannot know which, so each entry says whose it is. Keyed by the def itself: a shader
// parameter and a property of the type may share a name, and only the shader's entry earns the note.
const wears = target === 'sprite' || target === 'text' || target === 'camera';
const shaderRows = wears ? allShaderParamRows(host.state.project) : [];
const from = new Map(shaderRows.map((row) => [row.def, row.shaders.join(', ')]));
buildPickedPortSection(host, assetId, node, {
noun: 'Property',
choices: [...targetProperties(target), ...shaderRows.map((row) => row.def)],
owner,
note: (def) => from.get(def),
});
return;
}
addSetting(OBJECT_BEHAVIOR_SETTING);
const id = textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '');
const behavior = id ? findAssetOf(host.state.project, id, 'behavior') : undefined;
buildPickedPortSection(host, assetId, node, {
noun: 'Property',
choices: behavior ? schemaForAsset(host.state.project, behavior) : [],
owner: behavior ? `"${behavior.name}"` : 'a behavior',
empty: id ? 'That behavior is gone.' : 'Pick a behavior first.',
// Not offered, yet a port naming one by hand is one of its properties all the same (#257).
hidden: (def) => behavior !== undefined && isPrivateVariable(behavior, def.key),
});
}
interface PickedPorts {
noun: 'Variable' | 'Property';
choices: readonly PropertyDef[];
/** Whose ports these are, for the warning on one it does not have. */
owner: string;
empty?: string;
/** Named by hand instead of picked. */
custom?: boolean;
/** Where a choice comes from, shown after its key in the menu; nothing for one that speaks for itself. */
note?: (def: PropertyDef) => string | undefined;
/** A choice left out of the menus that is still valid on a port. */
hidden?: (def: PropertyDef) => boolean;
}
function buildPickedPortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
{ noun, choices, owner, empty, custom = false, note, hidden }: PickedPorts,
): void {
const offered = hidden ? choices.filter((def) => !hidden(def)) : choices;
const plural = noun === 'Property' ? 'properties' : 'variables';
const ports = node.ports ?? [];
const taken = new Set(ports.map((p) => p.key));
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
const choiceLabel = (def: PropertyDef): string => {
const where = note?.(def);
return where ? `${def.key} (${where})` : def.key;
};
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
offered.map((def) => ({
label: choiceLabel(def),
disabled: taken.has(def.key) && def.key !== own,
onSelect: () => onPick(portOf(def)),
}));
const typed = (): MenuItem[] =>
kindMenu(VARIABLE_KINDS, (kind) =>
host.state.execute(
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
),
);
const searchable = noun === 'Property';
const header = sectionHeader(host, plural.toUpperCase());
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
add.onClick = () => {
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
new Menu(custom ? typed() : items, { searchable }).openBelow(add);
};
add.setEnabled(custom || offered.some((def) => !taken.has(def.key)));
header.add(add);
host.body.add(header);
if (!custom && choices.length === 0 && empty) addNote(host, empty);
else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`);
for (const port of ports) {
const toggle = writeToggle(host, assetId, node, port);
if (custom) {
portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle);
continue;
}
const def = choices.find((d) => d.key === port.key);
const known = def !== undefined && isPortOf(port, def);
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
choose.onClick = () => {
const items = pick(port.key, (next) =>
host.state.execute(new RepointPortCommand(assetId, node.id, port.key, next)),
);
new Menu(items, { searchable }).openBelow(choose);
};
portRow(host, assetId, node, def && known ? { ...port, kind: portKindOf(def) } : port, choose, toggle);
if (!known) addNote(host, `"${port.key}" is not a ${noun.toLowerCase()} of ${owner}.`, 'warning');
}
}
/**
* The ports a node declares for itself: the signature of a behavior seen as a node, or the fields
* of a struct. A start node's are named from the caller's side – what it hands into the graph is
* an *input* of the behavior – which is why the side comes from the node type, not from here.
*/
export function buildPortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
variables: readonly PropertyDef[] = [],
typeName: string | null = null,
): void {
const onStart = isEntryTerminal(node.type);
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
const ofFunction = node.type === SHADER_FUNCTION_INPUT || node.type === SHADER_FUNCTION_OUTPUT;
const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
const header = sectionHeader(host, `${noun.toUpperCase()}S`);
// With a Struct asset as its type the fields are that asset's (#253): changed there, not here.
if (typeName !== null) {
host.body.add(header);
addNote(host, `The fields of ${typeName}. Change them there.`, 'small');
for (const port of node.ports ?? []) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(markLabel(port.kind));
row.add(new Label(port.key, { style: 'muted', width: NAME_WIDTH, padding: Theme.fieldPadding }), 1);
host.body.add(row);
}
return;
}
if (noun === 'Field') addShapePicker(host, assetId, node, variables, header);
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
add.onClick = () => {
// A further way in or out is a start or end node's to declare; a struct has fields only.
const kinds =
nodeDef(node.type)?.portKinds ??
DECLARED_PORT_KINDS.filter((kind) => kind !== 'flow' || noun !== 'Field');
const items = kindMenu(kinds, (kind) =>
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
);
new Menu(items).openBelow(add);
};
header.add(add);
host.body.add(header);
const ports = node.ports ?? [];
if (ports.length === 0)
addNote(
host,
noun === 'Field'
? 'No fields yet. Every field added here becomes a port of the struct.'
: ofFunction
? `No ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.`
: onStart
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.'
: 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.',
);
for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port));
}
const plural = (noun: string) => `${noun.toLowerCase()}s`;
function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
return new TextField(port.key, {
width: NAME_WIDTH,
onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
});
}
function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] {
const box = new Checkbox(port.write === true);
box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on));
return [new Label('Set', { style: 'muted' }), box];
}
function portRow(
host: InspectorHost,
assetId: string,
node: GraphNode,
port: DeclaredPort,
name: Widget,
extras: readonly Widget[] = [],
): void {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(markLabel(port.kind));
row.add(name, 1);
for (const extra of extras) row.add(extra);
row.add(new Spacer());
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
host.body.add(row);
}
/**
* Takes the fields of a Pack/Expand from a variable that already declares the shape, instead of
* typing them again. Only offered where there is a shape to copy; a field the node already has
* stays as it is, so this adds and never rewrites.
*/
function addShapePicker(
host: InspectorHost,
assetId: string,
node: GraphNode,
variables: readonly PropertyDef[],
header: SectionHeader,
): void {
const shaped = variables.filter((v) => structShapeOf(v) !== undefined);
if (shaped.length === 0) return;
const pick = new Dropdown('From variable', { variant: 'ghost' });
pick.onClick = () => {
const items: MenuItem[] = shaped.map((v) => {
const ports = missingFieldPorts(node, structShapeOf(v) ?? []);
return {
label: v.kind === 'list' ? `${v.key} (item)` : v.key,
disabled: ports.length === 0,
onSelect: () => host.state.execute(new AddPortsCommand(assetId, node.id, ports)),
};
});
new Menu(items).openBelow(pick);
};
header.add(pick);
}