Make texture and colour full kinds and drop the load-time migrations

- `Texture` and `Color` can be picked as a variable kind, a struct field
  kind and a declared port kind; a new `Texture` node hands a picture id
  into the graph. Declared, a texture is its bare id – the `$texture` tag
  stays a list-only thing.
- The `String` node is labelled "Text" like its kind; the type id stays.
- Whole-number inputs (`Item at`, `Set item`, `Sub list`, `Fill`,
  `Repeat`, `Substring`, `To text`) show no decimals (`int()` in shared.ts).
- All server projects are reseeded, so `legacyNodes.ts`,
  `migrateDeclaredLists` and the `ownerId` fallback go, with their tests.
- Note `reseed-because-of-list` moved to done/; new note `refactor-types`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-12 14:06:14 +02:00
1 parent 11c900c998
commit 8a7185948d
39 files changed
+378 -765

No files matched your search

+11 -70
View File
@@ -13,9 +13,8 @@ import {
} from '../graph';
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { emptyEdit, type InstanceEdit, isEmptyEdit, overrideSites } from '../model/instance';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import {
type ColorItem,
colorItem,
colorItemValue,
type FieldValue,
@@ -23,18 +22,15 @@ import {
textureItem,
textureItemValue,
isColorItem,
isList,
type PropertyBag,
type PropertyValue,
setBagValue,
type StructValue,
} 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 { upgradePropertyNodes, upgradeVariableNodes } from './legacyNodes';
import { makeItem } from '../properties/listItems';
import type { FlatItem } from '../properties/listItems';
import {
coerceValue,
COMMON_PROPS,
@@ -42,12 +38,9 @@ import {
type FieldKind,
isValidFieldName,
isValidVariableName,
LIST_ITEM_KINDS,
type ListItemKind,
type PropertyDef,
type PropertySchema,
schemaFor,
structOf,
type StructFieldDef,
VARIABLE_KINDS,
variableDef,
@@ -65,13 +58,10 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
// Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2.
const schemas = new Map<string, PropertySchema>();
const variables = new Map<string, PropertyDef[]>();
const legacyLists = new Map<string, Map<string, DeclaredItems>>();
for (const a of rawAssets) {
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
if (a.type === 'behavior') {
const declared = new Map<string, DeclaredItems>();
const vars = normalizeVariables(a.variables, declared);
if (declared.size > 0) legacyLists.set(a.id, declared);
const vars = normalizeVariables(a.variables);
variables.set(a.id, vars);
schemas.set(a.id, [...COMMON_PROPS, ...vars]);
} else {
@@ -99,9 +89,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
name,
props,
variables: variables.get(id) ?? [],
graph: normalizeGraph(
upgradePropertyNodes(upgradeVariableNodes(a.graph, variables.get(id) ?? []), variables),
),
graph: normalizeGraph(a.graph),
});
} else if (type === 'texture') {
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
@@ -133,43 +121,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
assets,
activeSceneId,
};
migrateDeclaredLists(project, legacyLists);
return project;
}
/** What a list variable declared before its items carried their own kind – see `migrateDeclaredLists`. */
interface DeclaredItems {
kind: ListItemKind;
fields: readonly StructFieldDef[];
}
/**
* A list used to declare what it held, so its stored items did not have to say: a `color` list held
* plain numbers, and a list of structs could hold an item missing a declared field. Items carry
* their own kind now, so every value of such a variable is converted here – the declaration is read
* one last time and then gone.
*/
function migrateDeclaredLists(project: Project, legacy: Map<string, Map<string, DeclaredItems>>): void {
const convert =
({ kind, fields }: DeclaredItems) =>
(value: PropertyValue | undefined): PropertyValue | undefined =>
isList(value)
? value.map((item) => (kind === 'struct' ? structOf(fields, item) : makeItem(kind, item)))
: value;
for (const [assetId, declared] of legacy) {
const asset = project.assets.find((a) => a.id === assetId);
if (asset?.type !== 'behavior') continue;
for (const [key, items] of declared) {
const as = convert(items);
const def = asset.variables.find((v) => v.key === key);
if (def) def.default = as(def.default) ?? def.default;
setBagValue(asset.props, key, as(asset.props[key]));
for (const site of overrideSites(project, (id) => id === assetId))
setBagValue(site.bag, key, as(site.bag[key]));
}
}
}
/** Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). */
export function serializeProject(project: Project): unknown {
return JSON.parse(JSON.stringify(project));
@@ -191,8 +145,7 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
editorOffset: { x: toNumber(offset.x), y: toNumber(offset.y) },
overrides: normalizeBag(o.overrides, schema, true),
// `ownerId` is the pre-hierarchy name of the same link.
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
parentId: typeof o.parentId === 'string' ? o.parentId : '',
instanceEdits: normalizeInstanceEdits(o.instanceEdits),
});
}
@@ -233,8 +186,7 @@ function normalizeInstanceEdits(raw: unknown): Record<string, InstanceEdit> {
return edits;
}
/** `legacy` collects what a list variable still declares about its items – see `migrateDeclaredLists`. */
function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>): PropertyDef[] {
function normalizeVariables(raw: unknown): PropertyDef[] {
const defs: PropertyDef[] = [];
const seen = new Set<string>();
for (const item of Array.isArray(raw) ? raw : []) {
@@ -247,8 +199,6 @@ function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>):
if (!isValidVariableName(key) || seen.has(key)) continue;
seen.add(key);
const fields = normalizeStructFields(v.fields);
if (kind === 'list' && (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items)))
legacy?.set(key, { kind: v.items as ListItemKind, fields: primitivesOf(fields) });
const def = variableDef(key, kind, undefined, fields);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
defs.push(def);
@@ -256,10 +206,6 @@ function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>):
return defs;
}
/** A declared item shape held primitives only; a list field of one would have been a level too deep. */
const primitivesOf = (fields: readonly StructFieldDef[]): StructFieldDef[] =>
fields.filter((f) => f.kind !== 'list');
/** An unreadable field is dropped rather than defaulted: an invented key would claim a value. */
function normalizeStructFields(raw: unknown): StructFieldDef[] {
const fields: StructFieldDef[] = [];
@@ -410,21 +356,16 @@ 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 one object a flat position may hold: a tagged color. A tagged texture is a
* list item only, so here it keeps its id as text – the same degrade `flatItemOf` makes.
*/
const asFlatItem = (value: unknown): number | string | boolean | ColorItem | undefined => {
/** A primitive, or the objects a flat position may hold: a tagged color or texture. */
const asFlatItem = (value: unknown): FlatItem | undefined => {
if (isPrimitive(value)) return value;
if (isColorItem(value)) return colorItem(colorItemValue(value));
return isTextureItem(value) ? textureItemValue(value) : undefined;
return isTextureItem(value) ? textureItem(textureItemValue(value)) : undefined;
};
/** A list item: a tagged texture, a flat one, or a struct of flat ones. */
/** A list item: a flat one, or a struct of flat ones. */
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
isTextureItem(value)
? textureItem(textureItemValue(value))
: (asFlatItem(value) ?? asStoredStruct(value, asFlatItem));
asFlatItem(value) ?? asStoredStruct(value, asFlatItem);
/** A struct field: a flat item or a flat list. A field that is itself a struct is dropped. */
const asStoredField = (value: unknown): FieldValue | undefined =>