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>
329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
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);
|
||
}
|