import { animationFrameAt } from './animation'; import type { 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; } 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, }; } /** * 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; /** Pixels between neighbouring cells. */ 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 { return new Set(asset.frames.filter((f) => f.id !== ignoreId).map((f) => f.name)); } function firstFreeName(taken: ReadonlySet, base: string, from: number): string { for (let i = from; ; i++) { const candidate = `${base} ${i}`; if (!taken.has(candidate)) return candidate; } }