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>
385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
import {
|
||
BEHAVIOR_PARAM,
|
||
declaresPropertyPorts,
|
||
declaresVariablePorts,
|
||
type DeclaredPort,
|
||
findNode,
|
||
fixedPortKeys,
|
||
FUNCTION_PARAM,
|
||
type Graph,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
isEntryTerminal,
|
||
isFlowPort,
|
||
isTerminalNode,
|
||
isValidPortName,
|
||
namesBehavior,
|
||
OBJECT_BEHAVIOR_PARAM,
|
||
propertyTargetOf,
|
||
SHADER_CALL,
|
||
variablesSourceOf,
|
||
} from '../graph';
|
||
import { isGraphAsset } from '../model/Asset';
|
||
import type { PropertyValue } from '../model/Properties';
|
||
import { kindLabel } from '../properties/kinds';
|
||
import type { Project } from '../model/Project';
|
||
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||
import { type Command, CommandError, RenameCommand } from './Command';
|
||
import { nextFieldName } from './naming';
|
||
import { requireGraph, requireNode, requireNodeDef } from './require';
|
||
|
||
interface PortSite {
|
||
graph: Graph;
|
||
nodeId: string;
|
||
/** Whether the port is an input there – the terminal and the call node face opposite ways. */
|
||
asInput: boolean;
|
||
}
|
||
|
||
/**
|
||
* Where a change to `node`'s declaration has to be mirrored. For most nodes that is the node
|
||
* itself. A terminal is the exception: its ports are also ports of every node running or hearing the
|
||
* behavior, or calling the shader function – facing the other way, because what a start node hands
|
||
* out the caller passes in.
|
||
* A node that has no such port (an `On behavior` and a flow port of the start) has no edges on it either.
|
||
*/
|
||
function portSites(project: Project, assetId: string, node: GraphNode): PortSite[] {
|
||
const sites = ownSites(requireGraph(project, assetId), node);
|
||
if (!isTerminalNode(node)) return sites;
|
||
// A shader function's ends are the ports of its calls in shaders and in other functions (#303).
|
||
const callsIt = (caller: GraphNode) =>
|
||
caller.type === SHADER_CALL
|
||
? caller.params[FUNCTION_PARAM] === assetId
|
||
: namesBehavior(caller.type) && caller.params[BEHAVIOR_PARAM] === assetId;
|
||
for (const other of project.assets) {
|
||
if (!isGraphAsset(other)) continue;
|
||
for (const caller of other.graph.nodes) {
|
||
if (!callsIt(caller)) continue;
|
||
sites.push({ graph: other.graph, nodeId: caller.id, asInput: isEntryTerminal(node.type) });
|
||
}
|
||
}
|
||
return sites;
|
||
}
|
||
|
||
function ownSites(graph: Graph, node: GraphNode): PortSite[] {
|
||
const side = requireNodeDef(node.type).declaresPorts;
|
||
const site = (asInput: boolean): PortSite => ({ graph, nodeId: node.id, asInput });
|
||
return side === 'both' ? [site(false), site(true)] : [site(side === 'inputs')];
|
||
}
|
||
|
||
type EdgeTest = { graph: Graph; test: (edge: GraphEdge) => boolean };
|
||
type DroppedEdges = { graph: Graph; entries: Removed<GraphEdge>[] };
|
||
|
||
/** One pass per edge list: a second pass over the same list would be restored at shifted indices. */
|
||
function dropEdges(tests: readonly EdgeTest[]): DroppedEdges[] {
|
||
const byGraph = new Map<Graph, EdgeTest['test'][]>();
|
||
for (const { graph, test } of tests) byGraph.set(graph, [...(byGraph.get(graph) ?? []), test]);
|
||
return [...byGraph].map(([graph, all]) => ({
|
||
graph,
|
||
entries: removeWhere(graph.edges, (edge) => all.some((test) => test(edge))),
|
||
}));
|
||
}
|
||
|
||
function restoreEdges(dropped: readonly DroppedEdges[]): void {
|
||
for (const { graph, entries } of dropped) restoreAt(graph.edges, entries);
|
||
}
|
||
|
||
/** A node declaring both sides has its flow pins only while one of its ports writes. */
|
||
function flowEdgesOnceSilent(graph: Graph, node: GraphNode): EdgeTest[] {
|
||
if (requireNodeDef(node.type).declaresPorts !== 'both' || (node.ports ?? []).some((p) => p.write))
|
||
return [];
|
||
const onFlow = (end: { node: string; port: string }) => end.node === node.id && end.port === 'exec';
|
||
return [{ graph, test: (edge) => onFlow(edge.to) || onFlow(edge.from) }];
|
||
}
|
||
|
||
/** An output's edges include those on its field ports, while it is split. */
|
||
function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean {
|
||
return (edge) =>
|
||
site.asInput
|
||
? edge.to.node === site.nodeId && edge.to.port === key
|
||
: edge.from.node === site.nodeId && (edge.from.port === key || edge.from.port.startsWith(`${key}.`));
|
||
}
|
||
|
||
function nodeAt(site: PortSite): GraphNode | undefined {
|
||
return findNode(site.graph, site.nodeId);
|
||
}
|
||
|
||
/** The port's edges follow its new key, and so does an input's typed-in value, which lives under it. */
|
||
function carryPort(site: PortSite, from: string, to: string): void {
|
||
for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) {
|
||
if (site.asInput) edge.to.port = to;
|
||
else edge.from.port = `${to}${edge.from.port.slice(from.length)}`;
|
||
}
|
||
const split = site.asInput ? undefined : nodeAt(site)?.splits?.find((s) => s.port === from);
|
||
if (split) split.port = to;
|
||
const node = site.asInput ? nodeAt(site) : undefined;
|
||
const value = node?.params[from];
|
||
if (!node || value === undefined) return;
|
||
node.params[to] = value;
|
||
delete node.params[from];
|
||
}
|
||
|
||
// A struct field may be called `exec`, a port may not – so this message has to name that reason.
|
||
const INVALID_NAME =
|
||
'Port names must start with a letter, contain only letters, digits or _, and "exec" is taken by the flow port';
|
||
|
||
function checkPortName(node: GraphNode, key: string): void {
|
||
if (!isValidPortName(key)) throw new CommandError(INVALID_NAME);
|
||
if (fixedPortKeys(requireNodeDef(node.type)).includes(key))
|
||
throw new CommandError(`"${key}" is taken by a port of the node itself`);
|
||
}
|
||
|
||
/** Several ports as one step, so seeding a node from a struct is one undo rather than one per field. */
|
||
export class AddPortsCommand implements Command {
|
||
readonly label: string;
|
||
protected readonly ports: readonly DeclaredPort[];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
ports: readonly DeclaredPort[],
|
||
) {
|
||
this.ports = ports.map((p) => ({ ...p }));
|
||
this.label = this.ports.length === 1 ? `Add ${this.ports[0]?.key}` : `Add ${this.ports.length} ports`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
const keys = new Set((node.ports ?? []).map((p) => p.key));
|
||
const kinds = requireNodeDef(node.type).portKinds;
|
||
for (const port of this.ports) {
|
||
checkPortName(node, port.key);
|
||
if (kinds && !kinds.includes(port.kind))
|
||
throw new CommandError(`A ${kindLabel(port.kind)} port has no place on this node`);
|
||
if (isFlowPort(port) && !isTerminalNode(node))
|
||
throw new CommandError('Only a start or an end node declares a flow port');
|
||
if (keys.has(port.key)) throw new CommandError(`"${port.key}" already exists`);
|
||
keys.add(port.key);
|
||
}
|
||
node.ports = [...(node.ports ?? []), ...this.ports.map((p) => ({ ...p }))];
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
const added = new Set(this.ports.map((p) => p.key));
|
||
node.ports = (node.ports ?? []).filter((p) => !added.has(p.key));
|
||
}
|
||
}
|
||
|
||
export class AddPortCommand extends AddPortsCommand {
|
||
constructor(assetId: string, nodeId: string, port: DeclaredPort) {
|
||
super(assetId, nodeId, [port]);
|
||
}
|
||
}
|
||
|
||
/** Takes the edges hanging on the port with it – in this graph and in every calling one. */
|
||
export class RemovePortCommand implements Command {
|
||
readonly label: string;
|
||
private removed?: Removed<DeclaredPort>;
|
||
private edges: DroppedEdges[] = [];
|
||
private values: { site: PortSite; value: PropertyValue }[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
private readonly key: string,
|
||
) {
|
||
this.label = `Remove ${key}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
const sites = portSites(project, this.assetId, node);
|
||
const ports = [...(node.ports ?? [])];
|
||
const removed = removeFirst(ports, (p) => p.key === this.key);
|
||
if (!removed) throw new CommandError(`Port "${this.key}" not found`);
|
||
this.removed = removed;
|
||
node.ports = ports;
|
||
const graph = requireGraph(project, this.assetId);
|
||
this.edges = dropEdges([
|
||
...sites.map((site) => ({ graph: site.graph, test: edgesOnPort(site, this.key) })),
|
||
...flowEdgesOnceSilent(graph, node),
|
||
]);
|
||
this.values = [];
|
||
for (const site of sites) {
|
||
const target = site.asInput ? nodeAt(site) : undefined;
|
||
const value = target?.params[this.key];
|
||
if (!target || value === undefined) continue;
|
||
this.values.push({ site, value });
|
||
delete target.params[this.key];
|
||
}
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.removed) return;
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
const ports = [...(node.ports ?? [])];
|
||
restoreAt(ports, [this.removed]);
|
||
node.ports = ports;
|
||
restoreEdges(this.edges);
|
||
for (const { site, value } of this.values) {
|
||
const target = nodeAt(site);
|
||
if (target) target.params[this.key] = value;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Switches whether a port of a node declaring both sides is written. Switched off, its input goes
|
||
* with its edge and typed-in value, and the last one off takes the flow pins and their edges along.
|
||
*/
|
||
export class SetPortWriteCommand implements Command {
|
||
readonly label: string;
|
||
private edges: DroppedEdges[] = [];
|
||
private value: PropertyValue | undefined;
|
||
private was = false;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
private readonly key: string,
|
||
private readonly write: boolean,
|
||
) {
|
||
this.label = write ? `Set ${key}` : `Stop setting ${key}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
if (requireNodeDef(node.type).declaresPorts !== 'both')
|
||
throw new CommandError('This node does not write its ports');
|
||
const port = (node.ports ?? []).find((p) => p.key === this.key);
|
||
if (!port) throw new CommandError(`Port "${this.key}" not found`);
|
||
this.was = port.write === true;
|
||
this.mark(node, this.write);
|
||
this.edges = [];
|
||
this.value = undefined;
|
||
if (this.write) return;
|
||
const graph = requireGraph(project, this.assetId);
|
||
const input: PortSite = { graph, nodeId: node.id, asInput: true };
|
||
this.edges = dropEdges([
|
||
{ graph, test: edgesOnPort(input, this.key) },
|
||
...flowEdgesOnceSilent(graph, node),
|
||
]);
|
||
this.value = node.params[this.key];
|
||
delete node.params[this.key];
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
this.mark(node, this.was);
|
||
restoreEdges(this.edges);
|
||
if (this.value !== undefined) node.params[this.key] = this.value;
|
||
}
|
||
|
||
private mark(node: GraphNode, write: boolean): void {
|
||
node.ports = (node.ports ?? []).map((port) => {
|
||
if (port.key !== this.key) return port;
|
||
const { write: _, ...plain } = port;
|
||
return write ? { ...plain, write: true } : plain;
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */
|
||
export class RenamePortCommand extends RenameCommand {
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
from: string,
|
||
to: string,
|
||
) {
|
||
super(`Rename ${from}`, from, to);
|
||
}
|
||
|
||
protected rename(project: Project, from: string, to: string): void {
|
||
if (from === to) return;
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
const sites = portSites(project, this.assetId, node);
|
||
checkPortName(node, to);
|
||
const ports = node.ports ?? [];
|
||
if (!ports.some((p) => p.key === from)) throw new CommandError(`Port "${from}" not found`);
|
||
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
|
||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||
for (const site of sites) carryPort(site, from, to);
|
||
}
|
||
}
|
||
|
||
/** Renames one declared port on one node, taking its edges and its typed-in value along. */
|
||
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
|
||
const ports = node.ports ?? [];
|
||
if (from === to || !ports.some((p) => p.key === from) || ports.some((p) => p.key === to)) return;
|
||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||
for (const site of ownSites(graph, node)) carryPort(site, from, to);
|
||
}
|
||
|
||
/**
|
||
* Points one declared port at another variable or property. Key and kind change together, because
|
||
* there the kind is the variable's (the property's) rather than a choice of its own; the edges
|
||
* hanging on the port come along, so correcting a pick is not a rewiring.
|
||
*/
|
||
export class RepointPortCommand implements Command {
|
||
readonly label: string;
|
||
private previous?: DeclaredPort;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
private readonly from: string,
|
||
private readonly port: DeclaredPort,
|
||
) {
|
||
this.label = `Point ${from} at ${port.key}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
const ports = node.ports ?? [];
|
||
const current = ports.find((p) => p.key === this.from);
|
||
if (!current) throw new CommandError(`Port "${this.from}" not found`);
|
||
checkPortName(node, this.port.key);
|
||
if (this.from !== this.port.key && ports.some((p) => p.key === this.port.key))
|
||
throw new CommandError(`"${this.port.key}" already exists`);
|
||
this.previous = { ...current };
|
||
this.swap(project, node, this.from, this.port);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.previous) return;
|
||
this.swap(project, requireNode(project, this.assetId, this.nodeId), this.port.key, this.previous);
|
||
}
|
||
|
||
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
|
||
renamePortOn(requireGraph(project, this.assetId), node, from, to.key);
|
||
node.ports = (node.ports ?? []).map((p) => (p.key === to.key ? { ...p, kind: to.kind } : p));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Follows a renamed variable into the nodes that name it as a port: the Variables nodes of its own
|
||
* behavior, and Properties nodes anywhere that point at that behavior. Their edges and typed-in
|
||
* values move with it – the port key *is* the variable name there.
|
||
*/
|
||
export function renameVariablePorts(project: Project, behaviorId: string, from: string, to: string): void {
|
||
for (const asset of project.assets) {
|
||
if (asset.type !== 'behavior') continue;
|
||
for (const node of asset.graph.nodes)
|
||
if (namesVariablesOf(node, asset.id, behaviorId)) renamePortOn(asset.graph, node, from, to);
|
||
}
|
||
}
|
||
|
||
function namesVariablesOf(node: GraphNode, graphOwner: string, behaviorId: string): boolean {
|
||
if (declaresVariablePorts(node.type)) {
|
||
// A node naming a Variables asset follows that asset wherever it sits, not the graph around it.
|
||
const source = variablesSourceOf(node.params);
|
||
return source ? source === behaviorId : graphOwner === behaviorId;
|
||
}
|
||
return (
|
||
declaresPropertyPorts(node.type) &&
|
||
propertyTargetOf(node.params) === 'behavior' &&
|
||
node.params[OBJECT_BEHAVIOR_PARAM] === behaviorId
|
||
);
|
||
}
|
||
|
||
/** "in1", "in2", … – the first port name the terminal node does not use yet. */
|
||
export function nextPortName(node: GraphNode, base: string): string {
|
||
return nextFieldName(node.ports ?? [], base);
|
||
}
|