Add a Texture tab for defining frames

A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets
frames be drawn on it: drag to create, move, resize via eight handles, edit
name and rect in the inspector, Delete to remove. Every change is a command,
so one gesture is one undo step. "Slice..." cuts a grid from columns and rows
with optional offset and spacing, previewing the resulting frame count and
cell size live.

Sprites pick a frame instead of the whole texture through the same `texture`
property: it now holds either a texture id or a frame id. Frame ids come from
the same generator and are document-wide unique, so one picker, one property
and one set of instance overrides cover both cases, and every sprite written
before this still means the same thing.

A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image,
because it derives its uvs from the shape bounds and the inverted style
matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which
keeps a sheet at a single GPU upload no matter how many of its frames are on
screen at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 14:27:23 +02:00
1 parent 5a6af74e44
commit 35a1a23af8
29 files changed
+2650 -53

No files matched your search

+28 -6
View File
@@ -1,11 +1,11 @@
// The graph package index registers the built-in nodes; unknown node types are dropped below.
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
import type { Asset } from '../model/Asset';
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile } from '../model/texture';
import { isTextureFile, normalizeFrameRect } from '../model/texture';
import { hasParentCycle } from '../model/walk';
import {
coerceValue,
@@ -43,8 +43,10 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
}
}
// Pass 2: build assets.
// Pass 2: build assets. Frame ids live in the same reference space as asset ids (a sprite's
// `texture` may name either), so they are handed the ids already spoken for.
const assets: Asset[] = [];
const takenIds = new Set(schemas.keys());
for (const a of rawAssets) {
const id = typeof a.id === 'string' ? a.id : '';
const schema = schemas.get(id);
@@ -64,7 +66,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
graph: normalizeGraph(a.graph),
});
} else if (type === 'texture') {
assets.push({ id, type, name, props, ...normalizeTexture(a) });
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
} else {
assets.push({ id, type, name, props });
}
@@ -189,11 +191,31 @@ function normalizeGraph(raw: unknown): Graph {
* is dropped rather than kept: it can only have been hand-written, and a request built from it
* would 404. Such a texture loads as "no picture", which draws as the sprite's plain colour.
*/
function normalizeTexture(raw: Record<string, unknown>): { file: string; width: number; height: number } {
function normalizeTexture(
raw: Record<string, unknown>,
takenIds: Set<string>,
): { file: string; width: number; height: number; frames: TextureFrame[] } {
const file = typeof raw.file === 'string' && isTextureFile(raw.file) ? raw.file : '';
const size = (value: unknown): number =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
return { file, width: size(raw.width), height: size(raw.height) };
const width = size(raw.width);
const height = size(raw.height);
const frames: TextureFrame[] = [];
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
const f = asRecord(item);
const id = typeof f.id === 'string' ? f.id : '';
// A frame id shares the reference space with asset ids (a sprite may name either), so a
// duplicate would make one reference mean two pictures. Drop it rather than resolve it.
if (!id || takenIds.has(id)) continue;
takenIds.add(id);
const rect = normalizeFrameRect(
{ x: toNumber(f.x), y: toNumber(f.y), w: toNumber(f.w), h: toNumber(f.h) },
width,
height,
);
frames.push({ id, name: typeof f.name === 'string' ? f.name : '', ...rect });
}
return { file, width, height, frames };
}
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {