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

+24 -2
View File
@@ -65,6 +65,22 @@ export interface ContainerAsset extends AssetBase {
objects: SceneObject[];
}
/**
* One named rectangle of a texture, in image pixels – a frame of a sprite sheet.
*
* Its `id` is drawn from the same generator as asset ids and is unique across the document,
* which is what lets a sprite's `texture` property name either a whole texture or one frame
* of it (see `model/texture.ts`).
*/
export interface TextureFrame {
id: string;
name: string;
x: number;
y: number;
w: number;
h: number;
}
/**
* An uploaded image. `file` names it in `data/textures/` and is content-addressed
* (`{sha256}.{ext}`), so the same picture uploaded twice is one file and a stored file can be
@@ -75,15 +91,21 @@ export interface ContainerAsset extends AssetBase {
* is the reference to it – sprites carry `texture` (see PropertySchema), which is how
* instance overrides, "Apply to container" and `Set property` work on it for free.
*
* `frames` cuts the picture into named rectangles (the Texture tab draws them); a texture
* with no frames is simply used whole, which is what every single-image texture wants and
* what every document written before frames existed already says.
*
* `width`/`height` are the pixel size as uploaded, kept so a sprite can be sized to its
* picture without waiting for the image to load. An empty `file` means the upload failed or
* has not happened yet; such a texture draws as nothing.
* picture without waiting for the image to load – and so frames can be validated and sliced
* before it arrives. An empty `file` means the upload failed or has not happened yet; such a
* texture draws as nothing.
*/
export interface TextureAsset extends AssetBase {
type: 'texture';
file: string;
width: number;
height: number;
frames: TextureFrame[];
}
export type Asset =
+18 -10
View File
@@ -71,32 +71,40 @@ export type PropertyUsage =
| { on: 'object'; holder: ObjectHolder; object: SceneObject; key: string };
/**
* Every property that references `assetId`, on assets and on the overrides of placed objects.
* Every property that references one of `ids`, on assets and on the overrides of placed
* objects.
*
* This is the other half of "where is this asset used": `findUsages` finds assets that were
* *placed*, which is meaningless for a texture – a texture is never placed, it is worn by
* sprites through their `texture` property. Deleting one therefore has to count both, or it
* reports "0 uses" for a picture that ten sprites are showing.
*
* A reference is deliberately left dangling rather than cleared when its asset goes: a
* Several ids rather than one because a texture is referenced by more than its own id: every
* frame cut from it is a reference to the same picture (see `textureRefIds`). Deleting the
* texture has to count the sprites wearing a frame of it too, or it under-reports exactly the
* sheets that were sliced – the ones with the most users.
*
* A reference is deliberately left dangling rather than cleared when its target goes: a
* dangling one resolves to "no picture" everywhere it is read, and it comes back by itself
* when the delete is undone.
*/
export function findPropertyUsages(project: Project, assetId: string): PropertyUsage[] {
if (!assetId) return [];
export function findPropertyUsages(project: Project, ids: string | readonly string[]): PropertyUsage[] {
const wanted = new Set((typeof ids === 'string' ? [ids] : ids).filter(Boolean));
if (wanted.size === 0) return [];
const result: PropertyUsage[] = [];
const assetKeys = new Map<string, string[]>();
const refKeys = new Map<string, string[]>();
const matches = (value: unknown): boolean => typeof value === 'string' && wanted.has(value);
for (const asset of project.assets) {
const keys = schemaForAsset(asset)
.filter((def) => def.kind === 'asset')
.filter((def) => def.kind === 'asset' || def.kind === 'texture')
.map((def) => def.key);
assetKeys.set(asset.id, keys);
for (const key of keys) if (asset.props[key] === assetId) result.push({ on: 'asset', asset, key });
refKeys.set(asset.id, keys);
for (const key of keys) if (matches(asset.props[key])) result.push({ on: 'asset', asset, key });
}
for (const holder of objectHolders(project)) {
for (const object of holder.objects) {
for (const key of assetKeys.get(object.assetId) ?? [])
if (object.overrides[key] === assetId) result.push({ on: 'object', holder, object, key });
for (const key of refKeys.get(object.assetId) ?? [])
if (matches(object.overrides[key])) result.push({ on: 'object', holder, object, key });
}
}
return result;
+205 -6
View File
@@ -1,3 +1,4 @@
import type { TextureAsset, TextureFrame } from './Asset';
import type { PropertyBag } from './Properties';
import { findAsset, type Project } from './Project';
@@ -12,23 +13,221 @@ export function isTextureFile(name: string): boolean {
return FILE_PATTERN.test(name);
}
/** A picture ready to be drawn: which file, and whether to smooth it when scaled. */
/** A rectangle in image pixels: the part of a texture a frame cuts out. */
export interface FrameRect {
x: number;
y: number;
w: number;
h: number;
}
/** Smallest frame that is still a picture rather than a slip of the mouse. */
export const MIN_FRAME_SIZE = 1;
/**
* A picture ready to be drawn: which file, whether to smooth it when scaled, and which part
* of it. `rect` is null for the whole image – not a rectangle covering everything, because
* the renderer can then skip the texture matrix entirely (Pixi fits an unmatrixed fill to the
* shape by itself) and because "the whole picture" survives the image being re-uploaded at a
* different size.
*/
export interface TextureRef {
file: string;
/** False for pixel art, where interpolation is the difference between crisp and mush. */
smooth: boolean;
/** Sub-rectangle in image pixels, or null for the whole image. */
rect: FrameRect | null;
/** Size of the whole image in pixels – the reference frame `rect` is expressed in. */
sourceWidth: number;
sourceHeight: number;
}
/** A texture asset together with one of its frames. */
export interface FrameRef {
asset: TextureAsset;
frame: TextureFrame;
}
/**
* The frame with that id, wherever in the project it lives.
*
* Frame ids come from the same generator as asset ids and are unique across the document,
* which is what lets a sprite's `texture` property hold either kind: an asset id means the
* whole picture, a frame id means one cut-out of it. One property, one picker, and every
* sprite written before frames existed keeps meaning exactly what it meant.
*/
export function findFrame(project: Project, frameId: string): FrameRef | null {
if (!frameId) return null;
for (const asset of project.assets) {
if (asset.type !== 'texture') continue;
const frame = asset.frames.find((f) => f.id === frameId);
if (frame) return { asset, frame };
}
return null;
}
/** Every id that means "this picture": the texture asset itself and each of its frames. */
export function textureRefIds(asset: TextureAsset): string[] {
return [asset.id, ...asset.frames.map((f) => f.id)];
}
/**
* The picture a bag of properties points at, or null when it points at nothing usable –
* no reference, a dangling one (the texture was deleted), or one whose upload never
* finished. Callers draw their plain colour in that case, which is what a sprite looked
* like before textures existed.
* no reference, a dangling one (the texture or the frame was deleted), or one whose upload
* never finished. Callers draw their plain colour in that case, which is what a sprite
* looked like before textures existed.
*/
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
const id = String(props.texture ?? '');
if (!id) return null;
const asset = findAsset(project, id);
if (asset?.type !== 'texture' || !isTextureFile(asset.file)) return null;
return { file: asset.file, smooth: String(asset.props.filter ?? 'linear') !== 'nearest' };
if (asset) return asset.type === 'texture' ? refFor(asset, null) : null;
const found = findFrame(project, id);
return found ? refFor(found.asset, found.frame) : null;
}
function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | null {
if (!isTextureFile(asset.file)) return null;
return {
file: asset.file,
smooth: String(asset.props.filter ?? 'linear') !== 'nearest',
rect: frame ? { x: frame.x, y: frame.y, w: frame.w, h: frame.h } : null,
sourceWidth: asset.width,
sourceHeight: asset.height,
};
}
/**
* A rectangle rounded to whole pixels and forced inside the image.
*
* Frames are pixel coordinates into a bitmap, so a fractional one has no meaning – it would
* sample half of a neighbouring pixel along every edge, which on a sprite sheet is the
* classic seam of foreign colour. Dragging produces fractions constantly (the view is
* zoomed), so this is applied on the way into the model rather than trusted from callers.
*/
export function normalizeFrameRect(rect: FrameRect, imageWidth: number, imageHeight: number): FrameRect {
// An image of unknown size (upload still running) cannot constrain anything; only round.
const limitX = imageWidth > 0 ? imageWidth : Infinity;
const limitY = imageHeight > 0 ? imageHeight : Infinity;
const x0 = clampInt(Math.min(rect.x, rect.x + rect.w), 0, limitX - MIN_FRAME_SIZE);
const y0 = clampInt(Math.min(rect.y, rect.y + rect.h), 0, limitY - MIN_FRAME_SIZE);
const x1 = clampInt(Math.max(rect.x, rect.x + rect.w), x0 + MIN_FRAME_SIZE, limitX);
const y1 = clampInt(Math.max(rect.y, rect.y + rect.h), y0 + MIN_FRAME_SIZE, limitY);
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
function clampInt(value: number, min: number, max: number): number {
const n = Math.round(Number.isFinite(value) ? value : 0);
return Math.max(min, Math.min(max, n));
}
/** How a sheet is cut into a regular grid – the shape of nearly every sprite sheet. */
export interface SliceOptions {
columns: number;
rows: number;
/** Pixels skipped at the left/top edge of the sheet before the first cell. */
offsetX: number;
offsetY: number;
/** Pixels between neighbouring cells (sheets exported with padding). */
spacingX: number;
spacingY: number;
}
export const DEFAULT_SLICE: SliceOptions = {
columns: 4,
rows: 4,
offsetX: 0,
offsetY: 0,
spacingX: 0,
spacingY: 0,
};
/**
* The rectangles a grid cuts a sheet into, in reading order (left to right, top to bottom) –
* which is the order sprite sheets are authored in and therefore the order an animation will
* want them.
*
* Cell size is derived from the image rather than asked for: everyone knows how many tiles
* their sheet has, almost nobody knows the pixel size off-hand, and deriving it cannot
* disagree with the picture. A cell that would reach past the image is dropped instead of
* clipped – a clipped one looks like a frame and is not one.
*/
export function sliceFrames(imageWidth: number, imageHeight: number, options: SliceOptions): FrameRect[] {
const columns = Math.floor(options.columns);
const rows = Math.floor(options.rows);
// NaN has to be rejected explicitly: every comparison against it is false, so a NaN column
// count would pass `columns < 1` and only produce nothing by accident of the loop condition.
if (!Number.isFinite(columns) || !Number.isFinite(rows)) return [];
if (!Object.values(options).every((value) => Number.isFinite(value))) return [];
if (columns < 1 || rows < 1 || imageWidth <= 0 || imageHeight <= 0) return [];
const cellW = Math.floor((imageWidth - options.offsetX - options.spacingX * (columns - 1)) / columns);
const cellH = Math.floor((imageHeight - options.offsetY - options.spacingY * (rows - 1)) / rows);
if (cellW < MIN_FRAME_SIZE || cellH < MIN_FRAME_SIZE) return [];
const rects: FrameRect[] = [];
for (let row = 0; row < rows; row++) {
for (let column = 0; column < columns; column++) {
const x = options.offsetX + column * (cellW + options.spacingX);
const y = options.offsetY + row * (cellH + options.spacingY);
if (x + cellW > imageWidth || y + cellH > imageHeight) continue;
rects.push({ x, y, w: cellW, h: cellH });
}
}
return rects;
}
/** The rectangle part of a frame, and the way to write one back – the only glue between the two. */
export function frameRect(frame: TextureFrame): FrameRect {
return { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
}
export function setFrameRect(frame: TextureFrame, rect: FrameRect): void {
frame.x = rect.x;
frame.y = rect.y;
frame.w = rect.w;
frame.h = rect.h;
}
/**
* A frame name that is free within its texture: `base`, else "base 2", "base 3" … – the same
* shape `nextAssetName` produces, because a name the user reads should not be numbered by two
* conventions in one document.
*
* Names matter more here than they look: they are what the sprite's picker shows, so two
* frames called "idle" would be one choice repeated rather than two.
*/
export function uniqueFrameName(asset: TextureAsset, base: string, ignoreId = ''): string {
const taken = takenFrameNames(asset, ignoreId);
const stem = base || 'Frame';
return taken.has(stem) ? firstFreeName(taken, stem, 2) : stem;
}
/**
* `count` free names numbered from 1 – what slicing a sheet wants, where the number is the
* cell's position and "Frame 1" next to "Frame 2" is the whole point.
*
* Numbers already taken are skipped rather than suffixed again: re-slicing a sheet that keeps
* its old frames must not produce "Frame 2 1".
*/
export function nextFrameNames(asset: TextureAsset, count: number, base = 'Frame'): string[] {
const taken = takenFrameNames(asset);
const names: string[] = [];
let n = 1;
for (let i = 0; i < count; i++) {
const name = firstFreeName(taken, base, n);
taken.add(name);
names.push(name);
n = Number(name.slice(base.length + 1)) + 1;
}
return names;
}
function takenFrameNames(asset: TextureAsset, ignoreId = ''): Set<string> {
return new Set(asset.frames.filter((f) => f.id !== ignoreId).map((f) => f.name));
}
function firstFreeName(taken: ReadonlySet<string>, base: string, from: number): string {
for (let i = from; ; i++) {
const candidate = `${base} ${i}`;
if (!taken.has(candidate)) return candidate;
}
}