import type { TaggedKind, ValueOf } from '../properties/kinds'; /** Lists and structs nest to any depth (#239): an item may be a list or a struct itself. */ export type ListValue = readonly PropertyValue[]; /** What a struct field or a list item holds – any value, since both nest to any depth. */ export type FieldValue = PropertyValue; /** Several named values travelling as one, so a bundle needs one port instead of one per part. */ export type StructValue = { readonly [field: string]: PropertyValue }; /** Colors are stored as 0xRRGGBB numbers. */ export type PropertyValue = number | string | boolean | ListValue | StructValue; export type PropertyBag = Record; export const isList = (value: unknown): value is ListValue => Array.isArray(value); /** * 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 type TaggedItem = { [K in TaggedKind]: { readonly [P in `$${K}`]: ValueOf } & TaggedFields; }[TaggedKind]; /** Indexable like any struct, so the one place that reads fields by name needs no special case. */ interface TaggedFields { readonly [field: string]: FieldValue; } const TAG = '$'; /** * The Struct asset a struct item follows (#254), kept in the item because a list declares at most * one type for all of them – and a mixed list none. Model only: a run never sees it * (`withoutItemTypes`), and no field can be called that, as no field name starts with `$`. */ export const ITEM_TYPE_KEY = '$struct'; /** 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 !== ITEM_TYPE_KEY ? key.slice(TAG.length) : undefined; } /** The Struct asset id a struct item is marked with, if any. */ export function itemTypeOf(value: PropertyValue | undefined): string | undefined { if (!isStruct(value)) return undefined; const id = value[ITEM_TYPE_KEY]; return typeof id === 'string' && id !== '' ? id : undefined; } /** The struct with its fields as they are, marked as `typeId` – or unmarked for `''`. */ export function withItemType(value: StructValue, typeId: string): StructValue { const { [ITEM_TYPE_KEY]: _dropped, ...fields } = value; return typeId ? { ...fields, [ITEM_TYPE_KEY]: typeId } : fields; } export function withoutField(value: StructValue, key: string): StructValue { const { [key]: _dropped, ...rest } = value; return rest; } /** Rebuilt rather than reassigned, so the field keeps its place in the value. */ export function withFieldRenamed(value: StructValue, from: string, to: string): StructValue { const next: Record = {}; for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item; return next; } /** * `value` with each part `swap` answers for replaced by the answer, through lists and untagged * structs to any depth; `undefined` keeps a part and looks inside it. What is untouched keeps its * identity, so a caller can tell whether anything changed. */ export function mapNested(value: unknown, swap: (part: unknown) => unknown): unknown { const swapped = swap(value); if (swapped !== undefined) return swapped; if (isList(value)) { const items = value.map((item) => mapNested(item, swap)); return items.some((item, i) => item !== value[i]) ? items : value; } if (isStruct(value) && tagOf(value) === undefined) { const entries = Object.entries(value); const mapped = entries.map(([key, field]) => [key, mapNested(field, swap)] as const); return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value; } return value; } /** The value as a run holds it: every item-type mark taken out, at any depth. */ export function withoutItemTypes(value: T): T { if (isList(value)) return (value.some(holdsItemType) ? value.map(withoutItemTypes) : value) as T; if (!isStruct(value) || !holdsItemType(value)) return value; const out: Record = {}; for (const [key, item] of Object.entries(value)) if (key !== ITEM_TYPE_KEY) out[key] = withoutItemTypes(item); return out as T; } export function bagWithoutItemTypes(bag: PropertyBag): PropertyBag { if (!Object.values(bag).some(holdsItemType)) return bag; const out: PropertyBag = {}; for (const [key, value] of Object.entries(bag)) out[key] = withoutItemTypes(value); return out; } function holdsItemType(value: PropertyValue | undefined): boolean { if (isList(value)) return value.some(holdsItemType); if (!isStruct(value)) return false; return ITEM_TYPE_KEY in value || Object.values(value).some(holdsItemType); } export function tagged(kind: TaggedKind, value: number | string): TaggedItem { return { [TAG + kind]: value } as TaggedItem; } /** * What an object's place holds at rest when it names no object – a variable, a struct field, a list * item alike. Tagged, so a run tells the place apart from an empty text (`graph/liveObjects.ts`). */ export const NO_OBJECT: TaggedItem = tagged('object', ''); /** * `value` as an object's place: the object of the same scene it names (#258, `model/objectRefs.ts`), * or none – whatever else it was. */ export function objectPlace(value: PropertyValue | undefined): TaggedItem { const id = tagOf(value) === 'object' ? (value as StructValue)[TAG + 'object'] : ''; return typeof id === 'string' && id ? tagged('object', id) : NO_OBJECT; } /** 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 * the handle's brand (see `values.ts`). Nothing in a PropertyBag is ever a handle. */ export const isStruct = (value: unknown): value is StructValue => typeof value === 'object' && value !== null && !Array.isArray(value); /** * A property value as text. Nothing may hand a PropertyValue to `String()` directly: a struct would * come out as "[object Object]" and a list without separators. */ 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 (tagOf(value) !== undefined) return textOf(untag(value)); if (isStruct(value)) return Object.entries(value) .filter(([field]) => field !== ITEM_TYPE_KEY) .map(([field, item]) => `${field}: ${textOf(item)}`) .join(', '); return String(value); } /** The spelling of a text counts: `Boolean('false')` would be true. */ export function booleanOf(value: unknown): boolean { if (typeof value !== 'string') return Boolean(value); const s = value.trim().toLowerCase(); return !(s === '' || s === 'false' || s === '0' || s === 'no'); } /** A number, or a hex text with or without `#`, clamped to 0…0xffffff. */ export function colorOf(value: PropertyValue, fallback: number): number { const n = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16); return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : fallback; } /** Values are compared by content, because two equal lists (or structs) are never the same object. */ export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean { if (a === b) return true; // A live handle (a run's bag may hold one, branded by a symbol) is only ever itself. if (isBranded(a) || isBranded(b)) return false; if (isList(a) || isList(b)) { if (!isList(a) || !isList(b) || a.length !== b.length) return false; return a.every((item, i) => sameValue(item, b[i])); } if (isStruct(a) || isStruct(b)) { if (!isStruct(a) || !isStruct(b)) return false; const keys = Object.keys(a); return keys.length === Object.keys(b).length && keys.every((key) => sameValue(a[key], b[key])); } return a === b; } const isBranded = (value: unknown): boolean => isStruct(value) && Object.getOwnPropertySymbols(value).length > 0; /** `undefined` removes the key, which is how an undo of "no value" works. */ export function setBagValue(bag: PropertyBag, key: string, value: PropertyValue | undefined): void { if (value === undefined) delete bag[key]; else bag[key] = value; } export interface Vec2 { x: number; y: number; } /** `x`/`y` in world units; `z` is the draw order – higher is on top, ties keep hierarchy order. */ export interface Position extends Vec2 { z: number; } export type Axis = keyof Position; export function toPosition(point: Vec2 | Position): Position { return { x: point.x, y: point.y, z: 'z' in point ? point.z : 0 }; } /** Stable draw order: ascending `z`, ties keep the input (hierarchy) order. */ export function sortByZ(items: readonly T[]): T[] { return [...items].sort((a, b) => a.z - b.z); }