Files
feuerwerk/src/editor/inspector/nodeSection.ts
T
adminandClaude Opus 5.5 0d8fd6ff52
feuerwerk / gate (push) Successful in 5m10s
feuerwerk / suite (push) Failing after 18m49s
Let the agent use the graph, scene and project commands (#429)
The agent knew 23 of the editor's commands. It could not point a
Variables node at a variable, group nodes, edit ports, transitions,
containers, colliders or the project settings. Stage 5a brings the
catalog to 85 commands in five areas and keeps every answer short.

- The catalog lists names and descriptions only. With a query (an
  area or command names) it adds the arguments. All schemas at once
  would pass the 60 000 character limit of one answer.
- add_port points a Variables node at a variable. The port takes the
  variable's kind, and an unknown name is refused with the list of
  the ones that exist.
- add_group and add_comment take nodes instead of coordinates. They
  follow the nodes after the arrange step, in the same undo step.
- The rule for which list a port picks from moved into portChoices.ts.
  Inspector and agent now share it.
- The frame of a group around nodes moved into annotationDrag.ts, so
  the button and the agent get the same frame.
- list_assets returns the project settings. get_asset shows the
  content of placed containers with the ids overrides need. get_graph
  shows declared ports and frame colors.
- A new node is placed at the height of its port, in the free spot
  whose links cross the fewest others. A source goes left of the node
  it feeds. Before, new nodes always went right of the rest.
- Arrange breaks a tie in crossings by putting the flow on top, and
  exec links pull harder when a column is sorted.
- A comment over a group keeps clear of the group's title bar.
- The agent's instructions and lint_graph ask to look at crossings
  too and to call arrange_graph when there are more than needed.

Left out on purpose: file assets (no upload), paste from the
clipboard, internal wrapper commands and switching plugins on.

Found on the way:
- Variable commands on a Struct asset did not run through
  WithStructTypes. New fields did not reach variables of that type.
- A query "variables" also matched set_variables_scope. A word that
  names an area now means only that area.

Tests:
- agent-catalog (new, 10), agent-edit, graph-arrange (3 new) and
  relay green. 11 bends of the code, all red.
- e2e 71 on atx: 22/22, the new check sees port speed and a group
  around all 4 drawn nodes.
- Live on a test project: a speed variable of 300 moved the player
  to x 150.03 in 0.5 s. A re-placed Variables node landed below Axis
  input with 4 crossings instead of 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:01:15 +02:00

216 lines
9.5 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 {
callProblem,
declaresPropertyPorts,
declaresSoundFxPorts,
declaresVariablePorts,
edgesInto,
edgesOfNode,
findNode,
type Graph,
type GraphEdge,
type GraphNode,
inlineInputDef,
inlineInputs,
isStateNode,
MACHINE_PORT,
OBJECT_BEHAVIOR_PARAM,
OBJECT_TYPE_PARAM,
portSignature,
resolveDef,
soundFxParamsMismatch,
soundFxSourceOf,
STATE_TRANSITION,
stateName,
STRUCT_TYPE_PARAM,
STRUCT_TYPE_SETTING,
TRANSITION_PARAM,
variablesSourceOf,
} from '../../core/graph';
import { graphLookup } from '../../core/model/graphLookup';
import { findAssetOf, findGraphAsset, type GraphHolder, type Project } from '../../core/model/Project';
import { type PropertyValue, textOf } from '../../core/model/Properties';
import {
isListNode,
isTypedNode,
listNodeItemType,
nodeStructType,
structAssetOf,
} from '../../core/model/structTypes';
import { SetListNodeTypeCommand, SetStructTypeCommand } from '../../core/commands/structTypeCommands';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import {
buildPortSection,
buildPropertyPortSection,
buildSoundFxPortSection,
buildVariablePortSection,
} from './portSection';
import { variableChoices } from '../graph/portChoices';
import { listSource, previewingHandlers } from './previewHandlers';
import { buildTransitionPicker, buildTransitionSection } from './transitionSection';
import { currentNode, nodeParamValue } from './valueSpecs';
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
const asset = findGraphAsset(project, assetId);
if (!asset) return 'missing';
const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing';
const lookup = graphLookup(project);
const def = resolveDef(node, asset.graph, lookup);
const connected = def
? inlineInputs(def)
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
.map((p) => p.key)
: [];
// The machine a port carries is drawn as the picker's text, its name included.
const machineOf = (id: string) => findAssetOf(project, id, 'statemachine')?.name ?? '';
const declared = (node.ports ?? [])
.map(
(p) =>
`${p.key}:${p.kind}${p.write ? ':set' : ''}${p.machine ? `:${p.machine}:${machineOf(p.machine)}` : ''}${
p.default !== undefined ? `=${p.default}` : ''
}`,
)
.join(',');
// They decide which rows the inspector builds, and two behaviors may share a name in the title.
const target = `${textOf(node.params[OBJECT_TYPE_PARAM] ?? '')}:${textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')}`;
// The transition rows and the picked name are drawn, not filled in by `refreshNodeSection`, so
// every part of them a command can change has to force a rebuild.
const transitions = isStateNode(node)
? edgesOfNode(asset.graph, node.id)
.map((e) => `${e.id}:${e.label ?? ''}:${farStateName(asset.graph, e, node.id)}`)
.join(',')
: `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`;
// The source names which declarations the port menu offers, so switching it has to rebuild.
const typeId = nodeStructType(node) || listNodeItemType(node);
const typeName = structAssetOf(project, typeId)?.name ?? '';
const source = `${variablesSourceOf(node.params)}:${soundFxSourceOf(node.params)}:${typeId}:${typeName}`;
const choices = variableChoices(project, assetId, node)
.map((v) => v.key)
.join(',');
// A setting that does nothing has no row, so picking an easing that needs it has to rebuild.
const idle = def?.idleParams?.(node.params, (key) => connected.includes(key)).join(',') ?? '';
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(
',',
)}|${source}:${choices}|${target}|${transitions}|${soundFxParamsMismatch(node, asset.graph) ?? ''}|${idle}`;
}
/** The name of the state at the other end of an edge – what a transition row is labelled with. */
function farStateName(graph: Graph, edge: GraphEdge, nodeId: string): string {
const far = findNode(graph, edge.from.node === nodeId ? edge.to.node : edge.from.node);
return far && isStateNode(far) ? stateName(far) : '';
}
/** A state machine declares no variables; its one node type has none to name. */
const variablesOf = (asset: GraphHolder): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []);
/** Description, settings (params) and the inline values of unconnected data inputs. */
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
const asset = findGraphAsset(host.state.project, assetId);
if (!asset) return;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
const def = node && resolveDef(node, asset.graph, graphLookup(host.state.project));
if (!node || !def) return;
addTitle(host, `${def.category.toUpperCase()} NODE`);
host.body.add(new Label(def.label, { style: 'bodyBold' }));
addNote(host, def.description);
const problem =
callProblem(node, graphLookup(host.state.project), assetId) ?? soundFxParamsMismatch(node, asset.graph);
if (problem) addNote(host, problem, 'warning');
const addSetting = (param: PropertyDef, handlers = paramHandlers(host, assetId, nodeId, param)) => {
const row = createFieldRow(host, param, node.params[param.key] ?? param.default, handlers);
host.fields.set(`param:${param.key}`, row);
host.body.add(row.row);
};
// Both draw their settings inside the port section: a setting decides what the menu offers.
const ownSettings =
declaresPropertyPorts(node.type) || declaresVariablePorts(node.type) || declaresSoundFxPorts(node.type);
// The type of a Pack/Expand decides its fields (#253), so it stands above them – and picking one
// rewrites the ports, which a plain param edit would not.
const typed = isTypedNode(node);
if (typed) {
const pick = (value: PropertyValue) => {
const target = { kind: 'node', assetId, nodeId } as const;
host.state.execute(new SetStructTypeCommand(target, textOf(value)));
};
addSetting(STRUCT_TYPE_SETTING, { onInput: () => {}, onChange: pick });
}
// A List node's type reshapes its items (#271), which is no plain param edit either.
const pickItemType = (value: PropertyValue) =>
host.state.execute(new SetListNodeTypeCommand(assetId, nodeId, textOf(value)));
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting);
else if (declaresSoundFxPorts(node.type)) buildSoundFxPortSection(host, assetId, node, addSetting);
else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
else if (def.declaresPorts) {
const typeName = structAssetOf(host.state.project, nodeStructType(node))?.name ?? null;
buildPortSection(host, assetId, node, variablesOf(asset), typeName);
}
const fed = (key: string) => edgesInto(asset.graph, node.id, key).length > 0;
const idle = new Set(def.idleParams?.(node.params, fed) ?? []);
const settings = def.params.filter(
(param) => !(typed && param.key === STRUCT_TYPE_PARAM) && !idle.has(param.key),
);
if (!ownSettings && settings.length > 0) {
addTitle(host, 'SETTINGS');
for (const param of settings) {
if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION)
buildTransitionPicker(host, assetId, node, asset.graph);
else if (param.key === STRUCT_TYPE_PARAM && isListNode(node))
addSetting(param, { onInput: () => {}, onChange: pickItemType });
else addSetting(param);
}
}
if (isStateNode(node)) buildTransitionSection(host, assetId, node, asset.graph);
const free = inlineInputs(def).filter((p) => !fed(p.key));
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)');
for (const port of free) {
const inline = inlineInputDef(port);
const row = createFieldRow(
host,
inline,
node.params[port.key] ?? inline.default,
paramHandlers(host, assetId, nodeId, inline),
);
host.fields.set(`input:${port.key}`, row);
row.row.setEnabled(!idle.has(port.key));
host.body.add(row.row);
}
}
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
const found = currentNode(host.state.project, assetId, nodeId);
const def = found && resolveDef(found.node, found.graph, graphLookup(host.state.project));
if (!found || !def) return false;
for (const [key, row] of host.fields) {
const [scope, name] = key.split(':') as ['param' | 'input', string];
const port = def.inputs.find((p) => p.key === name);
const fallback =
scope === 'param'
? def.params.find((p) => p.key === name)?.default
: port && inlineInputDef(port).default;
row.setValue(found.node.params[name] ?? fallback ?? '');
}
return true;
}
/** Previews straight into the node's params; the command records the value from before the preview. */
function paramHandlers(
host: InspectorHost,
assetId: string,
nodeId: string,
def: PropertyDef,
): FieldHandlers {
const key = def.key;
return {
...previewingHandlers<GraphNode>(host, nodeParamValue(host.state, assetId, nodeId, key)),
...listSource(host, def, () => ({ kind: 'node', assetId, nodeId, key })),
};
}