The fill of a sliced frame defaulted to `Stretch`. It is `Repeat` now, and the Fill dropdown lists `Repeat` first. The saved document keeps writing only the fill that differs from the default, so the key it carries is now `stretch`, and a frame without it repeats. A frame saved before with the key absent and meaning stretch would therefore tile; none of the stored projects has one - both sliced frames on the server carry `fill: "repeat"` explicitly, so they draw as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import { animationFrameAt } from './animation';
|
||
import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset';
|
||
import { type PropertyBag, textOf } from './Properties';
|
||
import { findAsset, type Project } from './Project';
|
||
|
||
/** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */
|
||
const FILE_PATTERN = /^[a-f0-9]{64}\.(png|jpg|webp|gif)$/;
|
||
|
||
export function isTextureFile(name: string): boolean {
|
||
return FILE_PATTERN.test(name);
|
||
}
|
||
|
||
export interface FrameRect {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
export const MIN_FRAME_SIZE = 1;
|
||
|
||
/**
|
||
* `rect` is null for the whole image rather than a rectangle covering it: the renderer can then
|
||
* skip the texture matrix (Pixi fits an unmatrixed fill to the shape) and the meaning survives
|
||
* the image being re-uploaded at a different size.
|
||
*/
|
||
export interface TextureRef {
|
||
file: string;
|
||
smooth: boolean;
|
||
rect: FrameRect | null;
|
||
sourceWidth: number;
|
||
sourceHeight: number;
|
||
/** Set when the frame this ref names is sliced (#41); null means "draw it over the whole box". */
|
||
slice: NineSlice | null;
|
||
}
|
||
|
||
/**
|
||
* A sliced frame as the renderer needs it: the borders, how the parts between them fill, and the
|
||
* size they are measured in. Carried on the ref rather than looked up while drawing, for the same
|
||
* reason the rectangle is – drawing has no access to the project.
|
||
*/
|
||
export interface NineSlice {
|
||
border: FrameBorders;
|
||
fill: FrameFill;
|
||
/** Size of the frame in image pixels; the borders are distances inside it. */
|
||
sourceW: number;
|
||
sourceH: number;
|
||
/** Sprite units one border pixel is drawn with – the texture's, shared by all its frames (#96). */
|
||
scale: number;
|
||
}
|
||
|
||
export const SLICE_SCALE_KEY = 'sliceScale';
|
||
|
||
/** The texture's nine-slice scale; anything that is not a size above zero reads as the default 1. */
|
||
export function sliceScaleOf(asset: TextureAsset): number {
|
||
const value = Number(asset.props[SLICE_SCALE_KEY] ?? 1);
|
||
return Number.isFinite(value) && value > 0 ? value : 1;
|
||
}
|
||
|
||
export interface FrameRef {
|
||
asset: TextureAsset;
|
||
frame: TextureFrame;
|
||
}
|
||
|
||
/**
|
||
* Frame ids share the generator with asset ids and are unique across the document, which lets a
|
||
* sprite's `texture` property hold either: an asset id means the whole picture, a frame id a cut-out.
|
||
*/
|
||
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)];
|
||
}
|
||
|
||
/**
|
||
* What a sprite wearing `props.texture` shows; an animation shows its frame at `time` seconds
|
||
* since it started.
|
||
*/
|
||
export function resolveTexture(project: Project, props: PropertyBag, time = 0): TextureRef | null {
|
||
const id = textOf(props.texture ?? '');
|
||
const asset = id ? findAsset(project, id) : undefined;
|
||
if (asset?.type === 'animation') {
|
||
const index = animationFrameAt(asset, time).index;
|
||
return index < 0 ? null : resolvePicture(project, asset.frames[index]!);
|
||
}
|
||
return resolvePicture(project, id);
|
||
}
|
||
|
||
/** A texture or frame id. An animation is not a picture, which keeps one inside another from resolving. */
|
||
export function resolvePicture(project: Project, id: string): TextureRef | null {
|
||
if (!id) return null;
|
||
const asset = findAsset(project, id);
|
||
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: textOf(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,
|
||
slice: frame ? nineSliceOf(frame, sliceScaleOf(asset)) : null,
|
||
};
|
||
}
|
||
|
||
export const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 };
|
||
export const DEFAULT_FILL: FrameFill = 'repeat';
|
||
|
||
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
|
||
export const MIN_SLICE_MIDDLE = 1;
|
||
|
||
export function frameBorders(frame: TextureFrame): FrameBorders {
|
||
return frame.border ? { ...frame.border } : { ...NO_BORDERS };
|
||
}
|
||
|
||
export function frameFill(frame: TextureFrame): FrameFill {
|
||
return frame.fill === 'stretch' ? 'stretch' : DEFAULT_FILL;
|
||
}
|
||
|
||
export function hasBorders(border: FrameBorders): boolean {
|
||
return border.left > 0 || border.top > 0 || border.right > 0 || border.bottom > 0;
|
||
}
|
||
|
||
/** What a frame is sliced into, or null when nobody sliced it – then a sprite draws it as one piece. */
|
||
export function nineSliceOf(frame: TextureFrame, scale = 1): NineSlice | null {
|
||
const border = normalizeBorders(frameBorders(frame), frame.w, frame.h);
|
||
if (!hasBorders(border)) return null;
|
||
return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h, scale };
|
||
}
|
||
|
||
/**
|
||
* Whole pixels, never negative, and never eating the whole frame: two opposite borders are scaled
|
||
* down together until at least one pixel is left between them. A middle of no pixels has nothing
|
||
* to draw the space between the corners with, which would show as a hole rather than as an error.
|
||
*/
|
||
export function normalizeBorders(border: FrameBorders, w: number, h: number): FrameBorders {
|
||
const [left, right] = fitPair(border.left, border.right, w);
|
||
const [top, bottom] = fitPair(border.top, border.bottom, h);
|
||
return { left, top, right, bottom };
|
||
}
|
||
|
||
function fitPair(near: number, far: number, size: number): [number, number] {
|
||
const a = Math.max(0, Math.round(Number.isFinite(near) ? near : 0));
|
||
const b = Math.max(0, Math.round(Number.isFinite(far) ? far : 0));
|
||
const room = Math.max(0, Math.floor(size) - MIN_SLICE_MIDDLE);
|
||
if (a + b <= room) return [a, b];
|
||
if (room <= 0) return [0, 0];
|
||
// Proportional, so a frame that is dragged small keeps the shape of its corners.
|
||
const scaled = Math.round((a / (a + b)) * room);
|
||
return [scaled, room - scaled];
|
||
}
|
||
|
||
/** The slice settings of a frame, as one value – what an inspector row and a command pass around. */
|
||
export interface FrameSlice {
|
||
border: FrameBorders;
|
||
fill: FrameFill;
|
||
}
|
||
|
||
export function frameSlice(frame: TextureFrame): FrameSlice {
|
||
return { border: frameBorders(frame), fill: frameFill(frame) };
|
||
}
|
||
|
||
/**
|
||
* Writes the slice back, dropping the keys again once they mean nothing: a frame nobody sliced
|
||
* reads and saves exactly as it did before #41, so undo of the first slice restores the old shape.
|
||
*/
|
||
export function setFrameSlice(frame: TextureFrame, slice: FrameSlice): void {
|
||
const border = normalizeBorders(slice.border, frame.w, frame.h);
|
||
if (hasBorders(border)) frame.border = border;
|
||
else delete frame.border;
|
||
// Only the fill that differs from `DEFAULT_FILL` is written. Kept even without borders: a fill
|
||
// chosen before the borders are dragged would otherwise snap back under the hand that just set it.
|
||
if (slice.fill !== DEFAULT_FILL) frame.fill = slice.fill;
|
||
else delete frame.fill;
|
||
}
|
||
|
||
/** The slice keys exactly as the frame carries them – absence included, which undo owes back. */
|
||
export function rawFrameSlice(frame: TextureFrame): Pick<TextureFrame, 'border' | 'fill'> {
|
||
const raw: Pick<TextureFrame, 'border' | 'fill'> = {};
|
||
if (frame.border) raw.border = { ...frame.border };
|
||
if (frame.fill) raw.fill = frame.fill;
|
||
return raw;
|
||
}
|
||
|
||
export function restoreFrameSlice(frame: TextureFrame, raw: Pick<TextureFrame, 'border' | 'fill'>): void {
|
||
if (raw.border) frame.border = { ...raw.border };
|
||
else delete frame.border;
|
||
if (raw.fill) frame.fill = raw.fill;
|
||
else delete frame.fill;
|
||
}
|
||
|
||
/**
|
||
* Rounds to whole pixels and forces the rectangle inside the image: a fractional frame samples
|
||
* half of a neighbouring pixel along every edge, the classic sprite-sheet seam.
|
||
*/
|
||
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));
|
||
}
|
||
|
||
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;
|
||
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. Cell size is derived from the image;
|
||
* a cell that would reach past the edge is dropped rather than clipped.
|
||
*/
|
||
export function sliceFrames(imageWidth: number, imageHeight: number, options: SliceOptions): FrameRect[] {
|
||
const columns = Math.floor(options.columns);
|
||
const rows = Math.floor(options.rows);
|
||
// NaN passes `columns < 1` (every comparison against it is false), so reject it explicitly.
|
||
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;
|
||
}
|
||
|
||
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 name free within the texture: `base`, else "base 2", "base 3" … – the shape `nextAssetName` produces. */
|
||
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. Taken numbers are skipped rather than suffixed again, so
|
||
* re-slicing a sheet that keeps its old frames cannot 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;
|
||
}
|
||
}
|