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 = {}; 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 = {}; 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 = {}; 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); }