Describe every value kind in one table instead of spreading it over the code

A kind like colour or texture used to be spelled out in five parallel
lists, its port type, its empty value, its glyph, its label and its list
tag - 22 literal places for texture alone. core/properties/kinds.ts now
holds one row per kind and the rest is derived from it, so adding a kind
is one row. The five literal nodes collapse into literals.ts, the six
kind menus into one helper.

Number, Text, Boolean is now the one order everywhere; the variable and
port menus used to list Boolean before Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-12 14:47:17 +02:00
1 parent 8a7185948d
commit 3ed8b68ecf
39 files changed
+699 -565

No files matched your search

+1 -1
View File
@@ -7,13 +7,13 @@ import {
} from '../model/Properties';
import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project';
import type { VariableKind } from '../properties/kinds';
import {
emptyFieldValue,
isValidFieldName,
isValidVariableName,
type PropertyDef,
type StructFieldDef,
type VariableKind,
variableDef,
} from '../properties/PropertySchema';
import { type Removed, removeFirst, restoreAt } from '../util/list';
+5 -16
View File
@@ -1,4 +1,7 @@
import type { PropertyBag } from '../model/Properties';
import { type DeclaredPortKind, KIND } from '../properties/kinds';
export type { DeclaredPortKind } from '../properties/kinds';
/** `exec` carries control flow, the others values; `any` accepts every value type. */
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'any';
@@ -28,7 +31,7 @@ export interface GraphNode {
collapsed?: boolean;
}
/** A port a start/end node declares. `kind` maps to the PortType of the same name (`text` → string). */
/** A port a start/end node declares. `kind` travels as the kind table says (`text` → string). */
export interface DeclaredPort {
key: string;
kind: DeclaredPortKind;
@@ -36,22 +39,8 @@ export interface DeclaredPort {
write?: true;
}
/** A color travels as a number and a texture as text; the kind says which field edits the value. */
export const DECLARED_PORT_KINDS = [
'number',
'boolean',
'text',
'color',
'texture',
'object',
'list',
'struct',
] as const;
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
export function declaredPortType(kind: DeclaredPortKind): PortType {
if (kind === 'text' || kind === 'texture') return 'string';
return kind === 'color' ? 'number' : kind;
return KIND[kind].travels;
}
export interface GraphEdge {
+8 -19
View File
@@ -1,9 +1,10 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { PropertyDef } from '../properties/PropertySchema';
import { isKindWith, plainKindOf } from '../properties/kinds';
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
import type { BehaviorLookup } from './lookup';
import type { NodeContext } from './NodeContext';
import { asBoolean, asList, asNumber, asString, type Value } from './values';
import { asList, type Value } from './values';
export interface PortDef {
key: string;
@@ -142,23 +143,11 @@ export function withInlineDefault(port: PortDef): PortDef {
export function inlineInputDef(port: PortDef): PropertyDef {
if (port.field) return { ...port.field, key: port.key, label: port.label };
switch (port.type) {
case 'boolean':
return { key: port.key, label: port.label, kind: 'boolean', default: asBoolean(port.default ?? false) };
case 'string':
case 'any':
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
case 'list':
return { key: port.key, label: port.label, kind: 'list', default: asList(port.default) };
default:
return {
key: port.key,
label: port.label,
kind: 'number',
default: asNumber(port.default ?? 0),
step: 1,
};
}
const kind = port.type === 'any' ? 'text' : plainKindOf(port.type);
if (kind === 'list') return { key: port.key, label: port.label, kind, default: asList(port.default) };
const flat = kind !== undefined && isKindWith('field', kind) ? kind : 'number';
const def = flatDef(port.key, flat, { label: port.label, value: port.default });
return def.kind === 'number' ? { ...def, step: 1 } : def;
}
/** The value an unconnected input carries: the node's own inline value, else the port default. */
+4 -7
View File
@@ -1,5 +1,6 @@
import { textOf } from '../model/Properties';
import { isValidFieldName } from '../properties/PropertySchema';
import { isTaggedKind } from '../properties/kinds';
import { flatDef, isValidFieldName } from '../properties/PropertySchema';
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
import type { BehaviorLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
@@ -75,14 +76,10 @@ export function isTerminalNode(node: GraphNode): boolean {
return isTerminalType(node.type);
}
/** A color or texture port carries the field that edits it; the graph itself sees a number or a text. */
/** A tagged kind is one the value cannot show, so its port carries the field that edits it. */
export function portDefOf(port: DeclaredPort): PortDef {
const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) };
if (port.kind === 'color')
return { ...def, field: { key: port.key, label: port.key, kind: 'color', default: 0 } };
if (port.kind === 'texture')
return { ...def, field: { key: port.key, label: port.key, kind: 'texture', default: '' } };
return def;
return isTaggedKind(port.kind) ? { ...def, field: flatDef(port.key, port.kind) } : def;
}
/** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean } from '../../values';
import { bool } from '../shared';
export const BooleanLiteral: NodeDef = {
type: 'Boolean',
label: 'Boolean',
category: 'data',
description: 'A constant true/false.',
inputs: [],
outputs: [bool('value')],
params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asBoolean(params.value) } }),
};
+3 -4
View File
@@ -1,5 +1,4 @@
import { sameValue } from '../../../model/Properties';
import { itemValue } from '../../../properties/listItems';
import { sameValue, untag } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem } from '../../values';
import { any, list, num } from '../shared';
@@ -15,9 +14,9 @@ export const IndexOf: NodeDef = {
// Compared by content, not by identity: two equal structs are never the same object. A color item
// is compared as its number, or searching a list of colors with one would never find it.
run: (_ctx, inputs) => {
const wanted = itemValue(asListItem(inputs.value));
const wanted = untag(asListItem(inputs.value));
return {
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(itemValue(item), wanted)) },
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(untag(item), wanted)) },
};
},
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { itemValue } from '../../../properties/listItems';
import { untag } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber } from '../../values';
import { any, int, list } from '../shared';
@@ -14,7 +14,7 @@ export const ItemAt: NodeDef = {
run: (_ctx, inputs) => {
const items = asList(inputs.list);
const i = Math.floor(asNumber(inputs.index));
return { outputs: { item: itemValue(items[i]) ?? 0 } };
return { outputs: { item: untag(items[i]) ?? 0 } };
},
params: [],
};
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList } from '../../values';
import { list } from '../shared';
export const ListLiteral: NodeDef = {
type: 'List',
label: 'List',
category: 'data',
description: 'A constant list, edited item by item in the List tab – each item with its own kind.',
inputs: [],
outputs: [list('value')],
params: [{ key: 'value', label: 'Value', kind: 'list', default: [] }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asList(params.value) } }),
};
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { num } from '../shared';
export const NumberLiteral: NodeDef = {
type: 'Number',
label: 'Number',
category: 'data',
description: 'A constant number.',
inputs: [],
outputs: [num('value')],
params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asNumber(params.value) } }),
};
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asString } from '../../values';
import { str } from '../shared';
export const StringLiteral: NodeDef = {
type: 'String',
label: 'Text',
category: 'data',
description: 'A constant text.',
inputs: [],
outputs: [str('value')],
params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value) } }),
};
-15
View File
@@ -1,15 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asString } from '../../values';
import { str } from '../shared';
/** The picture is its id on the edge: a whole texture, one frame of a sheet or an animation. */
export const TextureLiteral: NodeDef = {
type: 'Texture',
label: 'Texture',
category: 'data',
description: 'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.',
inputs: [],
outputs: [str('value')],
params: [{ key: 'value', label: 'Value', kind: 'texture', default: '' }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value) } }),
};
+37
View File
@@ -0,0 +1,37 @@
import { KIND, kindLabel, type VariableKind } from '../../../properties/kinds';
import { coerceValue, flatDef, type PropertyDef } from '../../../properties/PropertySchema';
import type { NodeDef } from '../../NodeRegistry';
/** A node handing out one constant of a kind: its one setting, edited as that kind, is its output. */
function literal(type: string, kind: Exclude<VariableKind, 'struct'>, description: string): NodeDef {
const value: PropertyDef =
kind === 'list'
? { key: 'value', label: 'Value', kind, default: [] }
: kind === 'number'
? { ...flatDef('value', kind, { label: 'Value' }), step: 1 }
: flatDef('value', kind, { label: 'Value' });
return {
type,
label: kindLabel(kind),
category: 'data',
description,
inputs: [],
outputs: [{ key: 'value', label: 'value', type: KIND[kind].travels }],
params: [value],
run: (_ctx, _inputs, params) => ({ outputs: { value: coerceValue(value, params.value ?? value.default) } }),
};
}
export const NumberLiteral = literal('Number', 'number', 'A constant number.');
export const BooleanLiteral = literal('Boolean', 'boolean', 'A constant true/false.');
export const StringLiteral = literal('String', 'text', 'A constant text.');
export const TextureLiteral = literal(
'Texture',
'texture',
'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.',
);
export const ListLiteral = literal(
'List',
'list',
'A constant list, edited item by item in the List tab – each item with its own kind.',
);
+1 -5
View File
@@ -10,7 +10,6 @@ import { Abs } from './data/Abs';
import { Add } from './data/Add';
import { And } from './data/And';
import { AxisInput } from './data/AxisInput';
import { BooleanLiteral } from './data/Boolean';
import { Ceil } from './data/Ceil';
import { Clamp } from './data/Clamp';
import { Compare } from './data/Compare';
@@ -27,6 +26,7 @@ import { IsTouching } from './data/IsTouching';
import { Join } from './data/Join';
import { KeyIsDown } from './data/KeyIsDown';
import { Length } from './data/Length';
import { BooleanLiteral, ListLiteral, NumberLiteral, StringLiteral, TextureLiteral } from './data/literals';
import { Max } from './data/Max';
import { Min } from './data/Min';
import { Modulo } from './data/Modulo';
@@ -36,7 +36,6 @@ import { Multiply } from './data/Multiply';
import { NameOf } from './data/NameOf';
import { Normalize } from './data/Normalize';
import { Not } from './data/Not';
import { NumberLiteral } from './data/Number';
import { ObjectByName } from './data/ObjectByName';
import { Or } from './data/Or';
import { Owner } from './data/Owner';
@@ -47,10 +46,8 @@ import { Random } from './data/Random';
import { Round } from './data/Round';
import { Self } from './data/Self';
import { Sqrt } from './data/Sqrt';
import { StringLiteral } from './data/String';
import { Substring } from './data/Substring';
import { Subtract } from './data/Subtract';
import { TextureLiteral } from './data/Texture';
import { Time } from './data/Time';
import { ToNumber } from './data/ToNumber';
import { ToText } from './data/ToText';
@@ -60,7 +57,6 @@ import { ExpandStruct } from './data/ExpandStruct';
import { Fill } from './data/Fill';
import { IndexOf } from './data/IndexOf';
import { ItemAt } from './data/ItemAt';
import { ListLiteral } from './data/ListLiteral';
import { SetItem } from './data/SetItem';
import { SubList } from './data/SubList';
import { OnAnimationFinished } from './events/OnAnimationFinished';
+6 -5
View File
@@ -1,12 +1,13 @@
import { type DeclaredPortKind, isKindWith, KIND, plainKindOf } from '../properties/kinds';
import { itemKindOf, structItemFields } from '../properties/listItems';
import type { PropertyDef, StructFieldDef } from '../properties/PropertySchema';
import { DECLARED_PORT_KINDS, type DeclaredPort, type DeclaredPortKind, type GraphNode } from './Graph';
import type { DeclaredPort, GraphNode } from './Graph';
/** The port a variable or property travels on. An enum, a key or an asset is text. */
/** The port a variable or property travels on. A kind no port declares (an enum, a key) is what it travels as. */
export function portKindOf(def: PropertyDef): DeclaredPortKind {
return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind)
? (def.kind as DeclaredPortKind)
: 'text';
if (isKindWith('port', def.kind)) return def.kind;
const plain = plainKindOf(KIND[def.kind].travels);
return plain !== undefined && isKindWith('port', plain) ? plain : 'text';
}
/**
+28 -31
View File
@@ -1,8 +1,10 @@
import type { TaggedKind, ValueOf } from '../properties/kinds';
/** Depth is capped: `list → struct → primitive` is the deepest a stored value goes. */
export type ListValue = readonly (number | string | boolean | StructValue)[];
/** What a struct field may hold. A field is never itself a struct – except a tagged colour or texture. */
export type FieldValue = number | string | boolean | ListValue | ColorItem | TextureItem;
/** What a struct field may hold. A field is never itself a struct – except a tagged item. */
export type FieldValue = number | string | boolean | ListValue | TaggedItem;
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
export type StructValue = { readonly [field: string]: FieldValue };
@@ -15,42 +17,38 @@ export type PropertyBag = Record<string, PropertyValue>;
export const isList = (value: unknown): value is ListValue => Array.isArray(value);
/**
* Inside a list a color and a texture have to say so: a list declares nothing about its items, and
* there 0xRRGGBB is a plain number and an asset id a plain text. A field name cannot start with `$`
* (`isValidFieldName`), so a tag can never collide with a struct item of one field.
* A value of a kind JSON cannot show, under its kind's tag: `{ "$color": 16711680 }`,
* `{ "$texture": "walk-up" }`. Only a list needs it, where nothing is declared. A field name cannot
* start with `$` (`isValidFieldName`), so a tag never collides with a struct item of one field.
*/
export const COLOR_TAG = '$color';
export const TEXTURE_TAG = '$texture';
export type TaggedItem = {
[K in TaggedKind]: { readonly [P in `$${K}`]: ValueOf<K> } & TaggedFields;
}[TaggedKind];
/** Indexable like any struct, so the one place that reads fields by name needs no special case. */
export interface ColorItem {
readonly $color: number;
interface TaggedFields {
readonly [field: string]: FieldValue;
}
/** A picture a sprite can wear: a texture, one of its frames, or an animation – all one id. */
export interface TextureItem {
readonly $texture: string;
readonly [field: string]: FieldValue;
const TAG = '$';
/** The kind `value` wears as a tag, if it is a tagged item. */
export function tagOf(value: unknown): string | undefined {
if (!isStruct(value)) return undefined;
const keys = Object.keys(value);
const key = keys.length === 1 ? keys[0] : undefined;
return key?.startsWith(TAG) ? key.slice(TAG.length) : undefined;
}
const isTagged = (value: unknown, tag: string): boolean =>
isStruct(value) && tag in value && Object.keys(value).length === 1;
export function tagged(kind: TaggedKind, value: number | string): TaggedItem {
return { [TAG + kind]: value } as TaggedItem;
}
export const isColorItem = (value: unknown): value is ColorItem => isTagged(value, COLOR_TAG);
export const isTextureItem = (value: unknown): value is TextureItem =>
isTagged(value, TEXTURE_TAG) && typeof (value as TextureItem).$texture === 'string';
export const colorItem = (color: number): ColorItem => ({ $color: color });
export const textureItem = (id: string): TextureItem => ({ $texture: id });
/** The number behind a tagged color. */
export const colorItemValue = (item: ColorItem): number => colorOf(item.$color, 0);
/** The id behind a tagged texture. */
export const textureItemValue = (item: TextureItem): string => item.$texture;
/** A tagged item as what it wraps; anything else as itself. */
export function untag(value: PropertyValue | undefined): PropertyValue | undefined {
const tag = tagOf(value);
return tag === undefined ? value : (value as StructValue)[TAG + tag];
}
/**
* Model-level only: a live entity handle is an object too, and the graph tells the two apart by
@@ -67,8 +65,7 @@ export function textOf(value: PropertyValue | undefined): string {
if (value === undefined) return '';
if (isList(value)) return value.map((item) => textOf(item)).join(', ');
// A tagged value reads as what it wraps, the same as the matching property does.
if (isColorItem(value)) return String(colorItemValue(value));
if (isTextureItem(value)) return textureItemValue(value);
if (tagOf(value) !== undefined) return textOf(untag(value));
if (isStruct(value))
return Object.entries(value)
.map(([field, item]) => `${field}: ${textOf(item)}`)
+2 -1
View File
@@ -3,6 +3,7 @@
* `Project.ts` so that module stays free of the property schema and of container instancing:
* both of those already import it, and answering this question needs both.
*/
import { isKindWith } from '../properties/kinds';
import { schemaForAsset } from '../properties/PropertySchema';
import type { AnimationAsset, Asset } from './Asset';
import { overrideSites } from './instance';
@@ -48,7 +49,7 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri
const matches = (value: unknown): boolean => typeof value === 'string' && wanted.has(value);
for (const asset of project.assets) {
const keys = schemaForAsset(asset)
.filter((def) => def.kind === 'asset' || def.kind === 'texture')
.filter((def) => isKindWith('reference', def.kind))
.map((def) => def.key);
refKeys.set(asset.id, keys);
for (const key of keys) if (matches(asset.props[key])) result.push({ on: 'asset', asset, key });
+47 -77
View File
@@ -9,44 +9,40 @@ import {
type PropertyValue,
type StructValue,
textOf,
booleanOf,
colorOf,
} from '../model/Properties';
import { coerceField, flatListOf, listItemOf } from './listItems';
import {
coerceAs,
coerceField,
emptyOf,
type FieldKind,
type FlatKind,
type ValueOf,
type VariableKind,
} from './kinds';
import { flatListOf, listItemOf } from './listItems';
/**
* What a kind's definition carries beyond key, label and default. `decimals` belongs only on
* properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging
* produces fractions, and the field rounds the value away rather than hiding it. Without
* `assetTypes` an asset picker offers every placeable asset.
*/
type Extras<K extends FlatKind> = K extends 'number'
? { min?: number; max?: number; step?: number; decimals?: number }
: K extends 'enum'
? { options: { value: string; label: string }[] }
: K extends 'asset'
? { assetTypes?: readonly AssetType[] }
: unknown;
/** The definition of a flat kind: its default is a value of that kind, as the table says. */
export type FlatDef<K extends FlatKind = FlatKind> = {
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P> } & Extras<P>;
}[K];
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
/**
* `decimals` belongs only on properties that are integral by meaning (counts, sizes in whole
* pixels), not on geometry: dragging produces fractions, and the field rounds the value away
* rather than hiding it.
*/
| {
key: string;
label: string;
kind: 'number';
default: number;
min?: number;
max?: number;
step?: number;
decimals?: number;
}
| { key: string; label: string; kind: 'text'; default: string }
| { key: string; label: string; kind: 'color'; default: number }
| { key: string; label: string; kind: 'boolean'; default: boolean }
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
| { key: string; label: string; kind: 'key'; default: string }
/**
* An asset id (`''` = none); without `assetTypes` the picker offers every placeable asset.
* Ids are not validated on load, so a reference can dangle and readers must cope.
*/
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] }
/**
* The id of a texture asset or of one of its frames. Its own kind rather than an `asset`
* reference, because a frame is not an asset and the picker has to offer both. May dangle.
*/
| { key: string; label: string; kind: 'texture'; default: string }
| FlatDef
/**
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
* and the graph types a list as a list, so one `list` port fits them all. There is no `object`
@@ -60,18 +56,6 @@ export type PropertyDef =
*/
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
/** The primitives. No `struct` anywhere below: that is the rule that bounds the nesting depth. */
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color', 'texture'] as const;
export type FieldKind = (typeof FIELD_KINDS)[number];
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
/** What a field of a struct property holds – a primitive plus `list`. */
export const STRUCT_FIELD_KINDS = [...FIELD_KINDS, 'list'] as const;
export type StructFieldKind = (typeof STRUCT_FIELD_KINDS)[number];
export interface PrimitiveFieldDef {
key: string;
kind: FieldKind;
@@ -89,8 +73,17 @@ export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
export type PropertySchema = readonly PropertyDef[];
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'color', 'texture', 'list', 'struct'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
/**
* The definition of a field kind, its default read from `value` (empty when nothing is given). The
* cast is the table's promise that `coerce` yields the kind.
*/
export function flatDef<K extends FieldKind>(
key: string,
kind: K,
{ label = key, value }: { label?: string; value?: PropertyValue } = {},
): FlatDef<K> {
return { key, label, kind, default: coerceField(value, kind) } as FlatDef<K>;
}
/**
* Properties every placeable asset has. A disabled object is not drawn, does not collide and its
@@ -238,35 +231,20 @@ export function variableDef(
fields?: readonly StructFieldDef[],
): PropertyDef {
switch (kind) {
case 'number':
return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 };
case 'boolean':
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
case 'text':
case 'texture':
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
case 'color':
return { key, label: key, kind, default: colorOf(defaultValue ?? 0, 0) };
case 'list':
return { key, label: key, kind, default: (isList(defaultValue) ? defaultValue : []).map(listItemOf) };
case 'struct': {
const declared = fields ?? [];
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
}
default:
return flatDef(key, kind, { value: defaultValue });
}
}
/** The empty value of a kind, so a field that is not there yet still has one. */
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
number: 0,
text: '',
boolean: false,
color: 0,
texture: '',
};
export function emptyFieldValue(field: StructFieldDef): FieldValue {
return field.kind === 'list' ? [] : EMPTY_FIELD[field.kind];
return field.kind === 'list' ? [] : emptyOf(field.kind);
}
/**
@@ -285,21 +263,11 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
switch (def.kind) {
case 'number': {
let n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) n = def.default;
let n = coerceAs(value, def.kind, def.default);
if (def.min !== undefined) n = Math.max(def.min, n);
if (def.max !== undefined) n = Math.min(def.max, n);
return n;
}
case 'color':
return colorOf(value, def.default);
case 'boolean':
return booleanOf(value);
case 'text':
case 'key':
case 'asset':
case 'texture':
return textOf(value);
case 'enum': {
const s = textOf(value);
return def.options.some((o) => o.value === s) ? s : def.default;
@@ -309,6 +277,8 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
return isList(value) ? value.map(listItemOf) : def.default;
case 'struct':
return structOf(def.fields, value);
default:
return coerceAs(value, def.kind, def.default);
}
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Every kind a value can be, in one table: what it is called, how it is marked, what it travels as
* on a data edge, its empty value, how any value is read as it, and where it may be picked. A new
* kind is a row here – the kind lists, the menus, the tags and the coercions follow from it.
*
* A `tagged` kind is one JSON cannot show: a color is a number, a texture id a text. In a list,
* where nothing is declared, such an item wears its tag (`tagOf`), and a port of that kind carries
* the field that edits it. A `reference` kind names something in the project.
*/
import type { PortType } from '../graph/Graph';
import { booleanOf, colorOf, type PropertyValue, textOf } from '../model/Properties';
interface KindRow {
readonly kind: string;
readonly label: string;
readonly mark?: string;
readonly travels: PortType;
readonly empty?: number | string | boolean;
readonly coerce?: (value: unknown, fallback: never) => number | string | boolean;
readonly tagged?: true;
readonly reference?: true;
readonly variable?: true;
readonly field?: true;
readonly port?: true;
}
const numberOf = (value: unknown, fallback: number): number => {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
};
const text = (value: unknown): string => textOf(value as PropertyValue | undefined);
const bool = (value: unknown): boolean => booleanOf(value);
const color = (value: unknown, fallback: number): number => colorOf(value as PropertyValue, fallback);
export const KINDS = [
{
kind: 'number',
label: 'Number',
mark: '#',
travels: 'number',
empty: 0,
coerce: numberOf,
variable: true,
field: true,
port: true,
},
{
kind: 'text',
label: 'Text',
mark: 'Aa',
travels: 'string',
empty: '',
coerce: text,
variable: true,
field: true,
port: true,
},
{
kind: 'boolean',
label: 'Boolean',
mark: '✓',
travels: 'boolean',
empty: false,
coerce: bool,
variable: true,
field: true,
port: true,
},
{
kind: 'color',
label: 'Color',
mark: '◧',
travels: 'number',
empty: 0,
coerce: color,
tagged: true,
variable: true,
field: true,
port: true,
},
/** A texture, one of its frames or an animation – all one id, which is why it is not an `asset`. */
{
kind: 'texture',
label: 'Texture',
mark: '▦',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
{ kind: 'object', label: 'Object', mark: '◇', travels: 'object', port: true },
{ kind: 'list', label: 'List', mark: '≡', travels: 'list', variable: true, port: true },
{ kind: 'struct', label: 'Struct', mark: '⊞', travels: 'struct', variable: true, port: true },
// Schema-only kinds: a setting has them, nothing a user declares does.
{ kind: 'enum', label: 'Choice', travels: 'string', empty: '', coerce: text },
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
{ kind: 'key', label: 'Key', travels: 'string', empty: '', coerce: text },
/** An asset id, `''` = none. Ids are not validated on load, so a reference can dangle. */
{ kind: 'asset', label: 'Asset', travels: 'string', empty: '', coerce: text, reference: true },
] as const satisfies readonly KindRow[];
type Row = (typeof KINDS)[number];
export type Kind = Row['kind'];
type RowOf<K extends Kind> = Extract<Row, { kind: K }>;
type With<Role extends string> = Extract<Row, Record<Role, true>>['kind'];
export type VariableKind = With<'variable'>;
export type FieldKind = With<'field'>;
export type DeclaredPortKind = With<'port'>;
export type TaggedKind = With<'tagged'>;
export type ReferenceKind = With<'reference'>;
/** The kinds whose value is one primitive – the ones with an empty value. */
export type FlatKind = Extract<Row, { empty: unknown }>['kind'];
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
export type ListItemKind = FieldKind | 'struct';
/** What a field of a struct holds – a primitive plus `list`. No `struct`: that bounds the depth. */
export type StructFieldKind = FieldKind | 'list';
type Widen<T> = T extends number ? number : T extends string ? string : T extends boolean ? boolean : never;
export type ValueOf<K extends FlatKind> = Widen<RowOf<K>['empty']>;
export const KIND = Object.fromEntries(KINDS.map((row) => [row.kind, row])) as { [K in Kind]: RowOf<K> };
const rowOf = (kind: string): Row | undefined => (KIND as Partial<Record<string, Row>>)[kind];
export function isKindWith<Role extends string>(role: Role, kind: string): kind is With<Role> {
return (rowOf(kind) as Partial<Record<string, unknown>> | undefined)?.[role] === true;
}
const kindsWith = <Role extends string>(role: Role): readonly With<Role>[] =>
KINDS.map((row) => row.kind).filter((kind): kind is With<Role> => isKindWith(role, kind));
export const VARIABLE_KINDS = kindsWith('variable');
export const FIELD_KINDS = kindsWith('field');
export const DECLARED_PORT_KINDS = kindsWith('port');
export const LIST_ITEM_KINDS: readonly ListItemKind[] = [...FIELD_KINDS, 'struct'];
export const STRUCT_FIELD_KINDS: readonly StructFieldKind[] = [...FIELD_KINDS, 'list'];
export const isTaggedKind = (kind: string): kind is TaggedKind => isKindWith('tagged', kind);
export function kindLabel(kind: string): string {
return rowOf(kind)?.label ?? kind;
}
export function kindMark(kind: string): string {
const row = rowOf(kind);
return row && 'mark' in row ? row.mark : '?';
}
/** A flat row as its kind's value type – what the table promises and a generic `K` cannot see. */
const flatRow = <K extends FlatKind>(kind: K) =>
KIND[kind] as unknown as {
empty: ValueOf<K>;
coerce: (value: unknown, fallback: ValueOf<K>) => ValueOf<K>;
};
export function emptyOf<K extends FlatKind>(kind: K): ValueOf<K> {
return flatRow(kind).empty;
}
/** `value` read as `kind`, `fallback` where it cannot be. */
export function coerceAs<K extends FlatKind>(value: unknown, kind: K, fallback: ValueOf<K>): ValueOf<K> {
return flatRow(kind).coerce(value, fallback);
}
export function coerceField<K extends FlatKind>(value: unknown, kind: K): ValueOf<K> {
return coerceAs(value, kind, emptyOf(kind));
}
/** The kind a bare value travelling as `type` reads as: the untagged kind of that port type. */
export function plainKindOf(type: string): VariableKind | undefined {
return VARIABLE_KINDS.find((kind) => !isTaggedKind(kind) && KIND[kind].travels === type);
}
+31 -61
View File
@@ -1,45 +1,44 @@
/**
* A list declares nothing about what it holds: every item carries its own kind, read back off the
* value. JSON already says what a number, a text, a boolean and a struct are, so only a color and a
* texture need a tag (`COLOR_TAG`, `TEXTURE_TAG`) – which keeps every list ever stored readable.
* value. JSON says what a number, a text, a boolean and a struct are; a tagged kind says so itself
* (`tagOf`), which keeps every list ever stored readable.
*
* Depth is still capped the way the declarations used to cap it: a struct item holds flat items,
* and so does a list inside a struct field.
* Depth is capped the way the declarations used to cap it: a struct item holds flat items, and so
* does a list inside a struct field.
*/
import {
booleanOf,
type ColorItem,
colorItem,
colorItemValue,
colorOf,
type FieldValue,
isColorItem,
isList,
isStruct,
isTextureItem,
type PropertyValue,
type StructValue,
textOf,
type TextureItem,
textureItem,
textureItemValue,
type TaggedItem,
tagged,
tagOf,
untag,
} from '../model/Properties';
import type { FieldKind, ListItemKind } from './PropertySchema';
import {
coerceField,
type FieldKind,
isKindWith,
isTaggedKind,
type ListItemKind,
plainKindOf,
} from './kinds';
/** What a list holds. Identical to `ListValue[number]`, named for the places that mean one item. */
export type ListItem = number | string | boolean | StructValue;
/** What a flat position holds: no struct except a tagged color or texture, so the depth stays capped. */
export type FlatItem = number | string | boolean | ColorItem | TextureItem;
/** What a flat position holds: no struct except a tagged item, so the depth stays capped. */
export type FlatItem = number | string | boolean | TaggedItem;
/** The kind an item is, taken from the item itself – there is nowhere else it could come from. */
export function itemKindOf(item: PropertyValue | undefined): ListItemKind {
if (isColorItem(item)) return 'color';
if (isTextureItem(item)) return 'texture';
const tag = tagOf(item);
if (tag !== undefined && isTaggedKind(tag)) return tag;
if (isStruct(item) || isList(item)) return 'struct';
if (typeof item === 'string') return 'text';
if (typeof item === 'boolean') return 'boolean';
return 'number';
const plain = plainKindOf(typeof item);
return plain !== undefined && isKindWith('field', plain) ? plain : 'number';
}
/** Flat positions hold no struct, so one reads as its text. */
@@ -48,26 +47,9 @@ export function fieldKindOf(value: PropertyValue | undefined): FieldKind {
return kind === 'struct' ? 'text' : kind;
}
/** What the widgets and the graph see: a tag is what it wraps, everything else is itself. */
export function itemValue(item: PropertyValue | undefined): PropertyValue | undefined {
if (isColorItem(item)) return colorItemValue(item);
if (isTextureItem(item)) return textureItemValue(item);
return item;
}
/** `value` as an item of `kind`, which is how a row changes what it holds. */
export function makeItem(kind: ListItemKind, value: PropertyValue | undefined): ListItem {
switch (kind) {
case 'color':
// Through `itemValue`: a tagged color handed to `colorOf` would be read as hex text.
return colorItem(colorOf(itemValue(value) ?? 0, 0));
case 'texture':
return textureItem(textOf(itemValue(value)));
case 'struct':
return structItemOf(value);
default:
return coerceField(itemValue(value), kind);
}
return kind === 'struct' ? structItemOf(value) : makeFlatItem(kind, value);
}
/** `value` as an item of the kind the slot already holds, so writing a color keeps it a color. */
@@ -77,9 +59,8 @@ export function itemLike(current: PropertyValue | undefined, value: PropertyValu
/** `value` as a flat item of `kind` – a struct item's field, where a struct itself cannot go. */
export function makeFlatItem(kind: FieldKind, value: PropertyValue | undefined): FlatItem {
if (kind === 'color') return colorItem(colorOf(itemValue(value) ?? 0, 0));
if (kind === 'texture') return textureItem(textOf(itemValue(value)));
return coerceField(itemValue(value), kind);
if (isTaggedKind(kind)) return tagged(kind, coerceField(untag(value), kind));
return coerceField(untag(value), kind);
}
/** A flat slot (a struct item's field, an item of a list in a struct field) keeps its kind too. */
@@ -93,19 +74,17 @@ export function listItemOf(value: PropertyValue | undefined): ListItem {
}
/**
* A flat position holds a primitive or a tagged color or texture. Anything deeper is dropped rather
* than flattened – the same bound the declarations used to draw, and the one the store writes.
* A flat position holds a primitive or a tagged item. Anything deeper is dropped rather than
* flattened – the same bound the declarations used to draw, and the one the store writes.
*/
export function flatItemOf(value: PropertyValue | undefined): FlatItem | undefined {
if (isColorItem(value)) return colorItem(colorItemValue(value));
if (isTextureItem(value)) return textureItem(textureItemValue(value));
if (isList(value) || isStruct(value)) return undefined;
return coerceField(value, fieldKindOf(value));
if (isList(value) || (isStruct(value) && !isTaggedKind(tagOf(value) ?? ''))) return undefined;
return makeFlatItem(fieldKindOf(value), value);
}
/** A struct item keeps the fields it carries, each as the kind its own value says. */
export function structItemOf(value: PropertyValue | undefined): StructValue {
if (!isStruct(value) || isColorItem(value) || isTextureItem(value)) return {};
if (!isStruct(value) || tagOf(value) !== undefined) return {};
const fields: Record<string, FieldValue> = {};
for (const [key, item] of Object.entries(value)) {
const flat = flatItemOf(item);
@@ -122,15 +101,6 @@ export function flatListOf(value: PropertyValue | undefined): readonly FlatItem[
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: FieldKind }[] {
if (!isStruct(item) || isColorItem(item) || isTextureItem(item)) return [];
if (!isStruct(item) || tagOf(item) !== undefined) return [];
return Object.entries(item).map(([key, value]) => ({ key, kind: fieldKindOf(value) }));
}
/** A primitive as one of the flat kinds. The spelling of a text counts, see `booleanOf`. */
export function coerceField(value: PropertyValue | undefined, kind: FieldKind): number | string | boolean {
if (kind === 'text' || kind === 'texture') return textOf(value);
if (kind === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
if (kind === 'color') return colorOf(value ?? 0, 0);
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : 0;
}
+13 -15
View File
@@ -1,7 +1,6 @@
// Importing the graph package index is what registers the built-in nodes.
import {
createEmptyGraph,
DECLARED_PORT_KINDS,
type DeclaredPort,
type DeclaredPortKind,
type Graph,
@@ -15,36 +14,35 @@ import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import {
colorItem,
colorItemValue,
type FieldValue,
isTextureItem,
textureItem,
textureItemValue,
isColorItem,
type PropertyBag,
type PropertyValue,
type StructValue,
tagOf,
} from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile, normalizeFrameRect } from '../model/texture';
import { hasParentCycle } from '../model/walk';
import type { FlatItem } from '../properties/listItems';
import {
DECLARED_PORT_KINDS,
FIELD_KINDS,
type FieldKind,
isTaggedKind,
VARIABLE_KINDS,
type VariableKind,
} from '../properties/kinds';
import { type FlatItem, makeFlatItem } from '../properties/listItems';
import {
coerceValue,
COMMON_PROPS,
FIELD_KINDS,
type FieldKind,
isValidFieldName,
isValidVariableName,
type PropertyDef,
type PropertySchema,
schemaFor,
type StructFieldDef,
VARIABLE_KINDS,
variableDef,
type VariableKind,
} from '../properties/PropertySchema';
/**
@@ -356,11 +354,11 @@ function asStoredValue(value: unknown): PropertyValue | undefined {
const isPrimitive = (value: unknown): value is number | string | boolean =>
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
/** A primitive, or the objects a flat position may hold: a tagged color or texture. */
/** A primitive, or the one object a flat position may hold: a tagged item. */
const asFlatItem = (value: unknown): FlatItem | undefined => {
if (isPrimitive(value)) return value;
if (isColorItem(value)) return colorItem(colorItemValue(value));
return isTextureItem(value) ? textureItem(textureItemValue(value)) : undefined;
const tag = tagOf(value);
return tag !== undefined && isTaggedKind(tag) ? makeFlatItem(tag, value as PropertyValue) : undefined;
};
/** A list item: a flat one, or a struct of flat ones. */