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:
1 parent
5a6af74e44
commit
35a1a23af8
29 files changed
+2650
-53
No files matched your search
+24
-2
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user