Structs and lists were capped at two fixed levels (list of structs, struct with a list field). Anything deeper was dropped silently on load, in node settings and when written to a variable. The cap came from an early design choice, not from a technical need, so it is lifted completely instead of adding a third fixed level. - One kind list NESTED_KINDS replaces ListItemKind and StructFieldKind. The flat special cases in listItems.ts are gone, every value goes through one recursive path. - A struct inside a struct is declared, like a top-level struct. Values coming in over an edge are brought into that shape. Struct items in lists still take their fields from the value. - New valuePath.ts addresses any nested spot by keys and indices. ListSlot.field is now ListSlot.path. - Field commands (add, rename, remove) take the path to the struct. They change declaration, own value and all overrides in one undo step, as before on the top level. - Inspector: "+ Field" offers Struct and List on every level. A nested struct is a header row with its own "+ Field", its fields are indented one step further. - List tab: a list item shows "n items" and opens one level deeper. The title shows the path, e.g. "Listy · nums · [3]". - Pack in Pack now writes a struct into a struct field instead of text. Objects are kept at any depth and stripped at every exit. - A list edit in a struct field without an existing object override now starts from the shown value instead of an empty struct. - Old projects load unchanged. Save, load and save again gives the same result. Tests: - ./check.sh: 172 files, 2498 tests, CHECK EXIT 0 - e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii, 21d and 22f updated) - 20 new unit tests in nested-values.test.ts and nested-list-slot.test.ts, three tests that pinned the old cap turned around - 6 counter-probes, each fails 1 to 8 tests, originals restored and checked with cmp - Not covered: a nested struct inside a struct item in the list tab has no e2e click Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
8.6 KiB
TypeScript
214 lines
8.6 KiB
TypeScript
import {
|
||
type FieldValue,
|
||
isList,
|
||
isStruct,
|
||
type ListValue,
|
||
type PropertyBag,
|
||
type PropertyValue,
|
||
NO_OBJECT,
|
||
type StructValue,
|
||
type TaggedItem,
|
||
tagOf,
|
||
textOf,
|
||
booleanOf,
|
||
} from '../model/Properties';
|
||
import { ENTITY_HANDLE, type EntityHandle } from './NodeContext';
|
||
|
||
/**
|
||
* A list travelling along a data edge. Unlike a stored one it may hold live object handles, which is
|
||
* what lets `Raycast all` hand out the objects it met (#157). Items nest like stored ones (#239); an
|
||
* object deeper down is held the way a bag holds one (`heldObject`).
|
||
*/
|
||
export type ValueList = readonly (PropertyValue | EntityHandle)[];
|
||
|
||
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
|
||
export type Value = PropertyValue | EntityHandle | ValueList | null;
|
||
|
||
export function isEntity(value: Value | undefined): value is EntityHandle {
|
||
return typeof value === 'object' && value !== null && ENTITY_HANDLE in value;
|
||
}
|
||
|
||
/**
|
||
* Whether two numbers are the same up to float noise (#224): a relative 1e-9, never less than 1e-9
|
||
* absolute. `time - late` lands 1e-14 off a whole second, and after hours of summed `dt` the error
|
||
* still stays far below this, while 6 and 6.001 remain apart.
|
||
*/
|
||
export function nearlyEqual(a: number, b: number): boolean {
|
||
if (a === b) return true;
|
||
return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
|
||
}
|
||
|
||
/** A list of anything a data edge carries – a handle among the items is what `isList` cannot say. */
|
||
function isValueList(value: Value | undefined): value is ValueList {
|
||
return Array.isArray(value);
|
||
}
|
||
|
||
export function asNumber(value: Value | undefined): number {
|
||
if (typeof value === 'number') return value;
|
||
if (typeof value === 'boolean') return value ? 1 : 0;
|
||
if (typeof value === 'string') {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|
||
if (isList(value)) return value.length;
|
||
// A bundle counts as its parts, the same way a list counts as its items.
|
||
if (isStructValue(value)) return Object.keys(value).length;
|
||
return 0;
|
||
}
|
||
|
||
/** Anything that is not a list reads as a one-item list, so a lone value can feed a list input. */
|
||
export function asList(value: Value | undefined): ValueList {
|
||
if (isValueList(value)) return value;
|
||
if (value === null || value === undefined) return [];
|
||
if (isEntity(value)) return [value];
|
||
if (isStructValue(value)) return Object.values(value).map((field) => asValueItem(field as Value));
|
||
return [value];
|
||
}
|
||
|
||
/**
|
||
* A handle as a run's bag holds it. Types say a bag is what a file holds, and at rest that is
|
||
* true: every way out of a run goes through `withoutObjects` (`liveObjects.ts`).
|
||
*/
|
||
export function heldObject(handle: EntityHandle): TaggedItem {
|
||
return handle as unknown as TaggedItem;
|
||
}
|
||
|
||
/** What an object's place in a bag takes: the object that arrives, or the empty one. */
|
||
function objectPlaceOf(value: Value | undefined): TaggedItem {
|
||
return isEntity(value) ? heldObject(value) : NO_OBJECT;
|
||
}
|
||
|
||
/** Whether a slot of a run's bag is an object's place: holding one, or holding none (`NO_OBJECT`). */
|
||
export function isObjectPlace(value: unknown): boolean {
|
||
return isEntity(value as Value) || tagOf(value) === 'object';
|
||
}
|
||
|
||
/** A value taken out of a list or struct: the empty object's place reads as no object at all. */
|
||
export function unplaced(value: Value | undefined): Value | undefined {
|
||
return tagOf(value) === 'object' ? null : value;
|
||
}
|
||
|
||
/** A list as a run's bag holds it: an object stays the object, however deep it sits. */
|
||
function heldList(items: ValueList): ListValue {
|
||
return items.map(asField);
|
||
}
|
||
|
||
/** `value` with every object in it named instead – how a value reads as text, however deep it sits. */
|
||
export function namedValue(value: Value | undefined): PropertyValue | undefined {
|
||
if (value === null || value === undefined) return undefined;
|
||
if (isEntity(value)) return value.name;
|
||
if (isValueList(value)) return value.map((item) => namedValue(item) as ListValue[number]);
|
||
if (isStructValue(value) && tagOf(value) === undefined) {
|
||
const fields: Record<string, FieldValue> = {};
|
||
for (const [key, field] of Object.entries(value)) fields[key] = namedValue(field) as FieldValue;
|
||
return fields;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/** A value narrowed to what a *travelling* list may hold: unlike `asField`, a handle stays one. */
|
||
export function asValueItem(value: Value | undefined): ValueList[number] {
|
||
return isEntity(value) ? value : asField(value);
|
||
}
|
||
|
||
/**
|
||
* A value as a struct field or list item holds it in a run's bag: lists and structs stay what they
|
||
* are at any depth (#239), an object stays the object, a missing value becomes 0.
|
||
*/
|
||
export function asField(value: Value | undefined): FieldValue {
|
||
if (isEntity(value)) return heldObject(value);
|
||
if (isValueList(value)) return heldList(value);
|
||
if (isStructValue(value)) return heldStruct(value);
|
||
return asPrimitive(value);
|
||
}
|
||
|
||
/** A struct as a run's bag holds it: each field as `asField` says, a tagged item as itself. */
|
||
function heldStruct(value: StructValue): StructValue {
|
||
if (tagOf(value) !== undefined) return value;
|
||
const fields: Record<string, FieldValue> = {};
|
||
for (const [key, field] of Object.entries(value)) fields[key] = asField(field);
|
||
return fields;
|
||
}
|
||
|
||
function asPrimitive(value: Value | undefined): number | string | boolean {
|
||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value;
|
||
if (value === null || value === undefined) return 0;
|
||
return asString(value);
|
||
}
|
||
|
||
export function asBoolean(value: Value | undefined): boolean {
|
||
return booleanOf(value);
|
||
}
|
||
|
||
/** Entities read as their name, so a handle can be logged or joined into text directly. */
|
||
export function asString(value: Value | undefined): string {
|
||
if (value === null) return '';
|
||
// A handle, at the top or in a list or struct, would otherwise be printed as what it is made of.
|
||
if (isObjectPlace(value)) return isEntity(value) ? value.name : '';
|
||
return textOf(namedValue(value));
|
||
}
|
||
|
||
/**
|
||
* How much counts as empty: every empty value of its kind, only the ones that carry nothing (0 and
|
||
* false are values then), or nothing but a missing one.
|
||
*/
|
||
export type EmptyMode = 'any' | 'nothing' | 'null';
|
||
|
||
/**
|
||
* Whether a value is the empty one of its kind: no object, 0, '', false, an empty list, or a struct
|
||
* whose fields are all empty – which is how a position (0, 0) arrives. `mode` narrows that down.
|
||
*/
|
||
export function isEmptyValue(value: Value | undefined, mode: EmptyMode = 'any'): boolean {
|
||
if (value === null || value === undefined) return true;
|
||
if (mode === 'null') return false;
|
||
if (isEntity(value)) return false;
|
||
if (isValueList(value)) return value.length === 0;
|
||
if (isStructValue(value)) return Object.values(value).every((field) => isEmptyValue(field, mode));
|
||
if (mode === 'nothing') return value === '';
|
||
return value === 0 || value === '' || value === false;
|
||
}
|
||
|
||
export function asEntity(value: Value | undefined): EntityHandle | null {
|
||
return isEntity(value) ? value : null;
|
||
}
|
||
|
||
/** A struct value, or the empty one: a number has no fields to become, so nothing is invented. */
|
||
export function asStruct(value: Value | undefined): StructValue {
|
||
return isStructValue(value) ? value : {};
|
||
}
|
||
|
||
function isStructValue(value: Value | undefined): value is StructValue {
|
||
return !isEntity(value) && isStruct(value);
|
||
}
|
||
|
||
/**
|
||
* A struct slot keeps the fields it was declared with: what arrives fills the ones it names and
|
||
* the rest stay put. That is what lets one node set some values of a bundle without erasing it.
|
||
*/
|
||
function mergeStruct(current: StructValue, value: Value | undefined): StructValue {
|
||
const incoming = asStruct(value);
|
||
const merged: Record<string, PropertyValue> = {};
|
||
for (const [field, own] of Object.entries(current))
|
||
merged[field] = field in incoming ? asTypeOf(own, incoming[field]) : own;
|
||
return merged;
|
||
}
|
||
|
||
/** `value` converted to the type the slot already holds, so a number stays a number. */
|
||
function asTypeOf(current: PropertyValue | undefined, value: Value | undefined): PropertyValue {
|
||
// First: an object's place is a struct to the eye, and one holding a handle all the more.
|
||
if (isObjectPlace(current)) return objectPlaceOf(value);
|
||
if (isList(current)) return heldList(asList(value));
|
||
if (isStruct(current)) return mergeStruct(current, value);
|
||
if (typeof current === 'number') return asNumber(value);
|
||
if (typeof current === 'boolean') return asBoolean(value);
|
||
return asString(value);
|
||
}
|
||
|
||
/**
|
||
* Writes `value` into `bag[key]` converted to the type the slot currently holds, so a number
|
||
* variable stays a number no matter what arrives on an `any` input.
|
||
*/
|
||
export function writeTyped(bag: PropertyBag, key: string, value: Value | undefined): void {
|
||
bag[key] = asTypeOf(bag[key], value);
|
||
}
|