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

+68
View File
@@ -0,0 +1,68 @@
import type { TextureAsset, TextureFrame } from '../model/Asset';
import { findAsset, type Project } from '../model/Project';
import { findFrame } from '../model/texture';
/** Shown for a `texture` property that references nothing. */
export const NO_TEXTURE_LABEL = 'None';
/** Separates a texture's name from a frame's name wherever the two are shown together. */
const FRAME_SEPARATOR = ' / ';
/** One thing a sprite's `texture` property can be set to. */
export interface TextureChoice {
/** Asset id for the whole picture, frame id for one cut-out of it. */
id: string;
label: string;
/** The texture this choice belongs to – frames of one sheet stay together in the menu. */
asset: TextureAsset;
/** Null for the whole-picture entry. */
frame: TextureFrame | null;
}
/**
* How a texture reference reads wherever it is displayed: the texture's name for a whole
* picture, "Sheet / Frame" for a frame, "None" for the empty id, and `<id>?` when the id no
* longer resolves.
*
* A dangling reference keeps showing its id instead of falling back to "None": the id is the
* only trace left of what was meant, and blanking it would make a deleted frame look like a
* field that was never filled in. `project` may be null while a view has no project yet.
*/
export function textureRefLabel(project: Project | null, id: string): string {
if (id === '') return NO_TEXTURE_LABEL;
if (!project) return `${id}?`;
const asset = findAsset(project, id);
if (asset) return asset.type === 'texture' ? asset.name : `${id}?`;
const found = findFrame(project, id);
return found ? `${found.asset.name}${FRAME_SEPARATOR}${found.frame.name}` : `${id}?`;
}
/**
* Everything a sprite may wear, in menu order: each texture followed by its own frames.
*
* The whole picture stays on offer next to the frames rather than being replaced by them.
* A texture that was never sliced has no frames at all, and a sheet's full image is
* occasionally what you want (a background, a check of the sheet itself) – forcing a frame
* would mean slicing every single-image texture into one pointless frame.
*
* Textures are sorted by name so the order does not depend on upload time; frames keep the
* order they were created in, which for a sliced sheet is reading order and therefore the
* order the animation runs in.
*/
export function textureRefChoices(project: Project): TextureChoice[] {
const textures = project.assets
.filter((a): a is TextureAsset => a.type === 'texture')
.sort((a, b) => a.name.localeCompare(b.name));
const choices: TextureChoice[] = [];
for (const asset of textures) {
choices.push({ id: asset.id, label: asset.name, asset, frame: null });
for (const frame of asset.frames)
choices.push({
id: frame.id,
label: `${asset.name}${FRAME_SEPARATOR}${frame.name}`,
asset,
frame,
});
}
return choices;
}