Define a struct once as an asset and reuse its shape (#253)
A struct used to declare its fields wherever it stood. The same shape in two variables meant typing it twice, and Pack/Expand copied it once without staying in sync. A Struct asset now holds the shape in one place, and every struct that picks it as its type follows it. - New asset type "Struct" (not placeable, group Structs, own icon). The inspector shows its fields with name, kind and default value, and "Open" shows them in the variables tab. - Struct variables and struct fields get a Type row: own fields, any Struct asset, or "Make struct type". With a type the fields come from the asset and cannot be added, renamed or removed there. The values stay per variable, overrides on placed objects still work. - Pack and Expand get the setting Type. With a type their ports are the fields of the asset and read-only in the inspector. - A change to the asset reaches every typed place in one undo step (syncStructTypes). A new field arrives with its default. A removed field goes along with its port, edge and typed value. A renamed field keeps its value in variables, overrides and on ports with edges, also through nested types. - Undo for these edits records plain assignments (Recorder), because the reach of a change is not known in advance. - Cycles such as Loot in Loot, also through another type, are refused and not offered in the picker. - Deleting the asset names how many places use it. Afterwards each place keeps its fields as its own. - Loading reshapes a stale copy in a project file to its type. - "Make struct type" creates an asset from an existing struct and links the place to it. Differs from the plan: - A changed default value applies to new fields and newly typed places only. Existing values stay. - Removing a single field shows no warning. Undo brings it back. Typed lists are left for #254. Lists stay dynamic for now. Found by e2e: after deleting or renaming the type the inspector did not rebuild and kept showing the old name. Fixed, the check was red before. Tests: - ./check.sh: 182 files, 2661 tests, CHECK EXIT 0 - GM_STEPS=1,4,21,22,54: ALL PASSED, 176 checks - 14 unit tests in tests/core/struct-types.test.ts - New e2e module 54 with 14 checks, real mouse. Module 04 counts 23 asset types. - Counter-probes: without the rename pass 4 unit tests fail, without reshaping values 8, without the cycle rule 1. In the browser, without spreading changes and with + Field despite a type, 3 checks fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4857bb75e6
commit
61bbb96a96
29 files changed
+1523
-66
No files matched your search
@@ -77,6 +77,16 @@ export interface VariablesAsset extends AssetBase {
|
||||
scope?: Scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named shape for structs (#253). Its fields are declared as a Variables asset declares its
|
||||
* variables, so the same rows and commands edit them, and `props` holds their defaults. A struct
|
||||
* variable or field naming it in `structType` takes on these fields (see `model/structTypes.ts`).
|
||||
*/
|
||||
export interface StructAsset extends AssetBase {
|
||||
type: 'struct';
|
||||
variables: PropertyDef[];
|
||||
}
|
||||
|
||||
/** Placed as a child of the object it belongs to; its position is the offset from that body. */
|
||||
export interface ColliderAsset extends AssetBase {
|
||||
type: 'collider';
|
||||
@@ -458,6 +468,7 @@ export type Asset =
|
||||
| SceneAsset
|
||||
| BehaviorAsset
|
||||
| VariablesAsset
|
||||
| StructAsset
|
||||
| ColliderAsset
|
||||
| RigidbodyAsset
|
||||
| ContainerAsset
|
||||
@@ -475,6 +486,13 @@ export type Asset =
|
||||
| SoundAsset
|
||||
| ParticlesAsset;
|
||||
|
||||
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
|
||||
export type VariableOwnerAsset = BehaviorAsset | VariablesAsset | StructAsset;
|
||||
|
||||
export function isVariableOwner(asset: Asset | undefined): asset is VariableOwnerAsset {
|
||||
return asset?.type === 'behavior' || asset?.type === 'variables' || asset?.type === 'struct';
|
||||
}
|
||||
|
||||
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
|
||||
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset | ParticlesAsset;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export const ASSET_TYPES = [
|
||||
'text',
|
||||
'behavior',
|
||||
'variables',
|
||||
'struct',
|
||||
'collider',
|
||||
'rigidbody',
|
||||
'container',
|
||||
@@ -32,6 +33,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
text: 'Text',
|
||||
behavior: 'Behavior',
|
||||
variables: 'Variables',
|
||||
struct: 'Struct',
|
||||
collider: 'Collider',
|
||||
rigidbody: 'Rigidbody',
|
||||
container: 'Container',
|
||||
@@ -57,6 +59,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place. A tile is
|
||||
* what a tilemap draws with, so it is placed no more directly than a texture is – what is placed
|
||||
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
|
||||
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
@@ -73,6 +76,7 @@ export type PlaceableAssetType = Exclude<
|
||||
| 'font'
|
||||
| 'sound'
|
||||
| 'variables'
|
||||
| 'struct'
|
||||
>;
|
||||
|
||||
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
@@ -89,6 +93,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'font',
|
||||
'sound',
|
||||
'variables',
|
||||
'struct',
|
||||
]);
|
||||
|
||||
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Struct assets as types (#253). A struct variable or field naming one in `structType` keeps a copy
|
||||
* of its fields, so everything reading `fields` works as it did; `syncStructTypes` brings every such
|
||||
* copy – and every value and `Pack`/`Expand` port hanging on it – back in line after the asset
|
||||
* changed. A type that is gone leaves the copy as it was: the place keeps its last fields as its own.
|
||||
*/
|
||||
import type { DeclaredPort, Graph, GraphNode } from '../graph/Graph';
|
||||
import { fixedPortKeys, isValidPortName, STRUCT_TYPE_PARAM } from '../graph/nested';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { isKindWith } from '../properties/kinds';
|
||||
import {
|
||||
coerceFieldValue,
|
||||
emptyFieldValue,
|
||||
type PropertyDef,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
structOf,
|
||||
} from '../properties/PropertySchema';
|
||||
import { valueAt, withValueAt } from '../properties/valuePath';
|
||||
import type { Recorder } from '../util/recorder';
|
||||
import { isGraphAsset, isVariableOwner, type StructAsset, type VariableOwnerAsset } from './Asset';
|
||||
import { overrideSites } from './instance';
|
||||
import { type FieldValue, isStruct, type PropertyValue, type StructValue, tagOf, textOf } from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
|
||||
export const PACK_STRUCT = 'PackStruct';
|
||||
export const EXPAND_STRUCT = 'ExpandStruct';
|
||||
|
||||
/** Deeper than this only a cycle written by hand can go; it is cut there. */
|
||||
const MAX_DEPTH = 32;
|
||||
|
||||
export function structAssetOf(project: Project, id: string | undefined): StructAsset | undefined {
|
||||
return id ? findAssetOf(project, id, 'struct') : undefined;
|
||||
}
|
||||
|
||||
export function isTypedNode(node: GraphNode): boolean {
|
||||
return node.type === PACK_STRUCT || node.type === EXPAND_STRUCT;
|
||||
}
|
||||
|
||||
export function nodeStructType(node: GraphNode): string {
|
||||
return isTypedNode(node) ? textOf(node.params[STRUCT_TYPE_PARAM] ?? '') : '';
|
||||
}
|
||||
|
||||
/** A declared variable as the struct field it becomes in every value of the type. */
|
||||
export function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined {
|
||||
if (def.kind === 'struct')
|
||||
return {
|
||||
key: def.key,
|
||||
kind: 'struct',
|
||||
fields: def.fields,
|
||||
...(def.structType ? { structType: def.structType } : {}),
|
||||
};
|
||||
if (def.kind === 'list') return { key: def.key, kind: 'list' };
|
||||
return isKindWith('field', def.kind) ? { key: def.key, kind: def.kind } : undefined;
|
||||
}
|
||||
|
||||
export function typeFields(asset: StructAsset): StructFieldDef[] {
|
||||
return asset.variables.flatMap((def) => fieldOfVariable(def) ?? []);
|
||||
}
|
||||
|
||||
/** A new value of the type: every field at the default the asset declares for it. */
|
||||
export function typeDefault(asset: StructAsset): StructValue {
|
||||
return structOf(typeFields(asset), asset.props);
|
||||
}
|
||||
|
||||
/** The Struct assets named anywhere inside these fields; a typed field is not looked into. */
|
||||
function typesIn(fields: readonly StructFieldDef[]): string[] {
|
||||
return fields.flatMap((f) =>
|
||||
f.kind !== 'struct' ? [] : f.structType ? [f.structType] : typesIn(f.fields),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value of type `from` holds one of type `target` anywhere inside, or is one. Such a
|
||||
* type cannot be a field of `target`: its default would never end.
|
||||
*/
|
||||
export function containsType(project: Project, from: string, target: string, depth = 0): boolean {
|
||||
if (from === target) return true;
|
||||
const asset = structAssetOf(project, from);
|
||||
if (!asset || depth > MAX_DEPTH) return false;
|
||||
return typesIn(typeFields(asset)).some((id) => containsType(project, id, target, depth + 1));
|
||||
}
|
||||
|
||||
/** Where values of type `target` sit inside a value of type `from`: `[]` when it is one itself. */
|
||||
function typePaths(project: Project, from: string, target: string, depth = 0): string[][] {
|
||||
if (from === target) return [[]];
|
||||
const asset = structAssetOf(project, from);
|
||||
if (!asset || depth > MAX_DEPTH) return [];
|
||||
return pathsIn(project, typeFields(asset), target, depth + 1);
|
||||
}
|
||||
|
||||
function pathsIn(
|
||||
project: Project,
|
||||
fields: readonly StructFieldDef[],
|
||||
target: string,
|
||||
depth: number,
|
||||
): string[][] {
|
||||
if (depth > MAX_DEPTH) return [];
|
||||
const paths: string[][] = [];
|
||||
for (const f of fields) {
|
||||
if (f.kind !== 'struct') continue;
|
||||
const inner = f.structType
|
||||
? typePaths(project, f.structType, target, depth + 1)
|
||||
: pathsIn(project, f.fields, target, depth + 1);
|
||||
for (const path of inner) paths.push([f.key, ...path]);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* A place that takes on a type: the struct at `path` inside the variable `def` of `owner`. Below it
|
||||
* nothing is a place of its own – the type's fields say what is there.
|
||||
*/
|
||||
export interface TypedSite {
|
||||
owner: VariableOwnerAsset;
|
||||
def: StructDef;
|
||||
path: string[];
|
||||
structType: string;
|
||||
}
|
||||
|
||||
export function typedSites(project: Project): TypedSite[] {
|
||||
const sites: TypedSite[] = [];
|
||||
const visit = (
|
||||
owner: VariableOwnerAsset,
|
||||
def: StructDef,
|
||||
at: { structType?: string; fields: readonly StructFieldDef[] },
|
||||
path: string[],
|
||||
): void => {
|
||||
if (at.structType) {
|
||||
sites.push({ owner, def, path, structType: at.structType });
|
||||
return;
|
||||
}
|
||||
for (const f of at.fields) if (f.kind === 'struct') visit(owner, def, f, [...path, f.key]);
|
||||
};
|
||||
for (const asset of project.assets)
|
||||
if (isVariableOwner(asset))
|
||||
for (const def of asset.variables) if (def.kind === 'struct') visit(asset, def, def, []);
|
||||
return sites;
|
||||
}
|
||||
|
||||
/** Everywhere the value of a variable is kept: its declared default, the owner's, the overrides. */
|
||||
interface Holder {
|
||||
read(): PropertyValue | undefined;
|
||||
write(value: PropertyValue): void;
|
||||
}
|
||||
|
||||
function holdersOf(project: Project, site: TypedSite, rec: Recorder): Holder[] {
|
||||
const { owner, def } = site;
|
||||
const holders: Holder[] = [
|
||||
{ read: () => def.default, write: (value) => rec.set(def, 'default', value as StructValue) },
|
||||
];
|
||||
if (owner.props[def.key] !== undefined)
|
||||
holders.push({
|
||||
read: () => owner.props[def.key],
|
||||
write: (value) => rec.set(owner.props, def.key, value),
|
||||
});
|
||||
for (const { bag } of overrideSites(project, (id) => id === owner.id))
|
||||
if (bag[def.key] !== undefined)
|
||||
holders.push({ read: () => bag[def.key], write: (value) => rec.set(bag, def.key, value) });
|
||||
return holders;
|
||||
}
|
||||
|
||||
/**
|
||||
* `value` in the shape of `fields`: what it has keeps its value (brought to the field's kind), what
|
||||
* it lacks takes the type's default, and what the type does not declare falls away.
|
||||
*/
|
||||
export function shapedStruct(
|
||||
project: Project,
|
||||
fields: readonly StructFieldDef[],
|
||||
value: PropertyValue | undefined,
|
||||
defaults: StructValue,
|
||||
depth = 0,
|
||||
): StructValue {
|
||||
const source = isStruct(value) && tagOf(value) === undefined ? value : {};
|
||||
const shaped: Record<string, FieldValue> = {};
|
||||
for (const field of fields) {
|
||||
const own = source[field.key];
|
||||
const fallback = defaults[field.key];
|
||||
const inner = field.kind === 'struct' ? structAssetOf(project, field.structType) : undefined;
|
||||
if (inner && field.kind === 'struct' && depth < MAX_DEPTH) {
|
||||
const innerDefaults = isStruct(fallback) ? fallback : typeDefault(inner);
|
||||
shaped[field.key] = shapedStruct(project, field.fields, own ?? innerDefaults, innerDefaults, depth + 1);
|
||||
} else if (own !== undefined) shaped[field.key] = coerceFieldValue(own, field);
|
||||
else
|
||||
shaped[field.key] = fallback !== undefined ? coerceFieldValue(fallback, field) : emptyFieldValue(field);
|
||||
}
|
||||
return shaped;
|
||||
}
|
||||
|
||||
/** The declaration with the fields of the struct at `path` replaced – and nothing else touched. */
|
||||
function withFieldsAt(
|
||||
fields: readonly StructFieldDef[],
|
||||
path: readonly string[],
|
||||
change: (field: Extract<StructFieldDef, { kind: 'struct' }>) => StructFieldDef,
|
||||
): readonly StructFieldDef[] {
|
||||
const [step, ...rest] = path;
|
||||
return fields.map((f) =>
|
||||
f.key !== step || f.kind !== 'struct'
|
||||
? f
|
||||
: rest.length === 0
|
||||
? change(f)
|
||||
: { ...f, fields: withFieldsAt(f.fields, rest, change) },
|
||||
);
|
||||
}
|
||||
|
||||
/** The struct field at `path` of a variable, or the variable itself for none. */
|
||||
export function structAt(
|
||||
def: StructDef,
|
||||
path: readonly string[],
|
||||
): { fields: readonly StructFieldDef[]; structType?: string } | undefined {
|
||||
let at: { fields: readonly StructFieldDef[]; structType?: string } = def;
|
||||
for (const step of path) {
|
||||
const next = at.fields.find((f) => f.key === step);
|
||||
if (next?.kind !== 'struct') return undefined;
|
||||
at = next;
|
||||
}
|
||||
return at;
|
||||
}
|
||||
|
||||
function setFields(
|
||||
def: StructDef,
|
||||
path: readonly string[],
|
||||
fields: readonly StructFieldDef[],
|
||||
rec: Recorder,
|
||||
) {
|
||||
if (path.length === 0) rec.set(def, 'fields', fields);
|
||||
else
|
||||
rec.set(
|
||||
def,
|
||||
'fields',
|
||||
withFieldsAt(def.fields, path, (f) => ({ ...f, fields })),
|
||||
);
|
||||
}
|
||||
|
||||
/** Names the type of the struct at `path`; `''` makes it hold its own fields again. */
|
||||
export function setStructType(def: StructDef, path: readonly string[], typeId: string, rec: Recorder): void {
|
||||
if (path.length === 0) {
|
||||
if (typeId) rec.set(def, 'structType', typeId);
|
||||
else rec.delete(def, 'structType');
|
||||
return;
|
||||
}
|
||||
rec.set(
|
||||
def,
|
||||
'fields',
|
||||
withFieldsAt(def.fields, path, ({ structType: _dropped, ...f }) =>
|
||||
typeId ? { ...f, structType: typeId } : f,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A field of a Struct asset renamed: `path` leads through the type's value to the struct holding it. */
|
||||
export interface FieldRename {
|
||||
structType: string;
|
||||
path: readonly string[];
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
function renamedKey(value: StructValue, from: string, to: string): StructValue {
|
||||
const next: Record<string, FieldValue> = {};
|
||||
for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Carries a renamed field's values over, before the shapes are rebuilt – which would drop them. */
|
||||
function applyRename(project: Project, rec: Recorder, rename: FieldRename): void {
|
||||
const { from, to } = rename;
|
||||
for (const site of typedSites(project))
|
||||
for (const inside of typePaths(project, site.structType, rename.structType)) {
|
||||
const path = [...site.path, ...inside, ...rename.path];
|
||||
for (const holder of holdersOf(project, site, rec)) {
|
||||
const value = holder.read();
|
||||
const at = valueAt(value, path);
|
||||
if (!isStruct(at) || !(from in at)) continue;
|
||||
holder.write(withValueAt(value, path, renamedKey(at, from, to)) ?? value!);
|
||||
}
|
||||
}
|
||||
if (rename.path.length > 0) return;
|
||||
for (const { graph, node } of typedNodes(project)) {
|
||||
if (nodeStructType(node) !== rename.structType) continue;
|
||||
const ports = node.ports ?? [];
|
||||
if (!ports.some((p) => p.key === from)) continue;
|
||||
rec.set(
|
||||
node,
|
||||
'ports',
|
||||
ports.map((p) => (p.key === from ? { ...p, key: to } : p)),
|
||||
);
|
||||
for (const edge of graph.edges) {
|
||||
if (edge.to.node === node.id && edge.to.port === from) rec.set(edge.to, 'port', to);
|
||||
if (edge.from.node === node.id && edge.from.port === from) rec.set(edge.from, 'port', to);
|
||||
}
|
||||
const inline = node.params[from];
|
||||
if (inline !== undefined) {
|
||||
rec.set(node.params, to, inline);
|
||||
rec.delete(node.params, from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function typedNodes(project: Project): { graph: Graph; node: GraphNode }[] {
|
||||
return project.assets.flatMap((asset) =>
|
||||
isGraphAsset(asset)
|
||||
? asset.graph.nodes.filter(isTypedNode).map((node) => ({ graph: asset.graph, node }))
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** The ports follow the fields; an edge on a port that is gone goes with it, as its typed-in value does. */
|
||||
function syncNodePorts(
|
||||
graph: Graph,
|
||||
node: GraphNode,
|
||||
fields: readonly StructFieldDef[],
|
||||
rec: Recorder,
|
||||
): void {
|
||||
const def = nodeDef(node.type);
|
||||
if (!def) return;
|
||||
const fixed = new Set(fixedPortKeys(def));
|
||||
const ports: DeclaredPort[] = fields
|
||||
.filter((f) => isValidPortName(f.key) && !fixed.has(f.key))
|
||||
.map((f) => ({ key: f.key, kind: f.kind }));
|
||||
const kept = new Set(ports.map((p) => p.key));
|
||||
const gone = new Set((node.ports ?? []).map((p) => p.key).filter((key) => !kept.has(key)));
|
||||
rec.set(node, 'ports', ports);
|
||||
if (gone.size === 0) return;
|
||||
const inputs = def.declaresPorts === 'inputs';
|
||||
const hangs = (end: { node: string; port: string }) => end.node === node.id && gone.has(end.port);
|
||||
if (graph.edges.some((e) => (inputs ? hangs(e.to) : hangs(e.from))))
|
||||
rec.set(
|
||||
graph,
|
||||
'edges',
|
||||
graph.edges.filter((e) => !(inputs ? hangs(e.to) : hangs(e.from))),
|
||||
);
|
||||
if (inputs) for (const key of gone) rec.delete(node.params, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings every typed place in line with its Struct asset: the copied fields, every value kept for
|
||||
* it, the ports of typed `Pack`/`Expand` nodes. A type holding another type is only right once that
|
||||
* one is, so the passes repeat until one changes nothing. Every write goes through `rec`, so the
|
||||
* whole reach of an edit is undone in one go.
|
||||
*/
|
||||
export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldRename): void {
|
||||
if (rename) applyRename(project, rec, rename);
|
||||
const passes = project.assets.filter((a) => a.type === 'struct').length + 2;
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
const before = rec.count;
|
||||
for (const site of typedSites(project)) {
|
||||
const type = structAssetOf(project, site.structType);
|
||||
// A struct asset naming a type that holds it is a cycle written by hand: left as it is.
|
||||
if (!type || (site.owner.type === 'struct' && containsType(project, type.id, site.owner.id))) continue;
|
||||
const fields = typeFields(type);
|
||||
setFields(site.def, site.path, fields, rec);
|
||||
const defaults = typeDefault(type);
|
||||
for (const holder of holdersOf(project, site, rec)) {
|
||||
const value = holder.read();
|
||||
const next = shapedStruct(project, fields, valueAt(value, site.path), defaults);
|
||||
holder.write(withValueAt(value, site.path, next) ?? next);
|
||||
}
|
||||
}
|
||||
for (const { graph, node } of typedNodes(project)) {
|
||||
const type = structAssetOf(project, nodeStructType(node));
|
||||
if (type) syncNodePorts(graph, node, typeFields(type), rec);
|
||||
}
|
||||
if (rec.count === before) return;
|
||||
}
|
||||
}
|
||||
|
||||
/** How many places name the type – what deleting it would cut loose. */
|
||||
export function structTypeUsages(project: Project, typeId: string): { places: number; nodes: number } {
|
||||
return {
|
||||
places: typedSites(project).filter((site) => site.structType === typeId).length,
|
||||
nodes: typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user