Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection, duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own contract. The three Create*Command share ImportFileCommand, the three renames share RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in portSection.ts replace in-file clones, and three comments that told history now state the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
import {
|
||
AddPortCommand,
|
||
AddPortsCommand,
|
||
nextPortName,
|
||
RemovePortCommand,
|
||
RenamePortCommand,
|
||
RepointPortCommand,
|
||
SetPortWriteCommand,
|
||
} from '../../core/commands/portCommands';
|
||
import {
|
||
BEHAVIOR_START,
|
||
type DeclaredPort,
|
||
type GraphNode,
|
||
isPortOf,
|
||
isTerminalNode,
|
||
missingFieldPorts,
|
||
OBJECT_BEHAVIOR_PARAM,
|
||
OBJECT_BEHAVIOR_SETTING,
|
||
OBJECT_TYPE_SETTING,
|
||
portKindOf,
|
||
VARIABLES_SETTING,
|
||
variablesSourceOf,
|
||
propertyTargetOf,
|
||
structShapeOf,
|
||
targetLabel,
|
||
targetProperties,
|
||
} from '../../core/graph';
|
||
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
|
||
import { textOf } from '../../core/model/Properties';
|
||
import { DECLARED_PORT_KINDS, kindMark, VARIABLE_KINDS } from '../../core/properties/kinds';
|
||
import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
|
||
import {
|
||
Checkbox,
|
||
Dropdown,
|
||
HStack,
|
||
Label,
|
||
Menu,
|
||
type MenuItem,
|
||
Spacer,
|
||
TextField,
|
||
Theme,
|
||
type Widget,
|
||
} from '../../ui';
|
||
import { NAME_WIDTH, removeButton } from './fieldFactory';
|
||
import { addNote, type InspectorHost } 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 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
|
||
// sprite property may share a name, and only the shader's entry earns the note.
|
||
const shaderRows = target === 'sprite' ? 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.',
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function buildPickedPortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
{ noun, choices, owner, empty, custom = false, note }: PickedPorts,
|
||
): void {
|
||
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[] =>
|
||
choices.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 = new HStack({ gap: Theme.gap, align: 'center' });
|
||
header.add(new Label(plural.toUpperCase(), { style: 'title' }), 1);
|
||
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 || choices.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[] = [],
|
||
): void {
|
||
const onStart = node.type === BEHAVIOR_START;
|
||
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
|
||
const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
|
||
|
||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||
header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1);
|
||
if (noun === 'Field') addShapePicker(host, assetId, node, variables, header);
|
||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||
add.onClick = () => {
|
||
const items = kindMenu(DECLARED_PORT_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.'
|
||
: onStart
|
||
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior.'
|
||
: 'No outputs yet. Every output added here becomes a result of running this behavior.',
|
||
);
|
||
|
||
for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port));
|
||
}
|
||
|
||
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(kindMark(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: HStack,
|
||
): 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);
|
||
}
|