import type { Asset, FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset'; import { generatedKey, generatedPictureOf } from './generated'; import { findPixelFrame, pixelFrameRect } from './pixelart'; import { type PropertyBag, textOf, type Vec2 } 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. */ export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null { return resolvePicture(project, textOf(props.texture ?? '')); } /** A texture, a frame or a generating asset; any other asset's id is no picture. */ 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) : generatedRef(asset, project); const found = findFrame(project, id); if (found) return refFor(found.asset, found.frame); // A frame of pixel art (#130) is a cut of its generated sheet, as a texture frame is of its image. const pixel = findPixelFrame(project, id); return pixel ? generatedRef(pixel.asset, project, pixelFrameRect(pixel.asset, pixel.index)) : null; } /** * An asset that draws its own picture (#185) reads like a texture of its canvas size: the key * stands where the file name stands, and the size is what a new sprite is measured by. No slicing – * that is cut out of an uploaded sheet; a frame only where the drawing has frames (pixel art). */ function generatedRef(asset: Asset, project: Project, rect: FrameRect | null = null): TextureRef | null { const picture = generatedPictureOf(asset, project); if (!picture) return null; return { file: generatedKey(asset.id, picture.revision), smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest', rect, sourceWidth: picture.width, sourceHeight: picture.height, slice: 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; /** The frame's collision outlines (#141), a copy; empty when it has none. */ export function frameCollision(frame: TextureFrame): Vec2[][] { return (frame.collision ?? []).map((outline) => outline.map((p) => ({ ...p }))); } /** * Sets the outlines. One without corners is dropped, and none left removes the key, so a frame * without an outline keeps saying so; one being drawn, with a corner or two, is kept. */ export function setFrameCollision(frame: TextureFrame, outlines: readonly (readonly Vec2[])[]): void { const kept = outlines.filter((outline) => outline.length > 0); if (kept.length === 0) delete frame.collision; else frame.collision = kept.map((outline) => outline.map((p) => ({ x: p.x, y: p.y }))); } 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 { const raw: Pick = {}; if (frame.border) raw.border = { ...frame.border }; if (frame.fill) raw.fill = frame.fill; return raw; } export function restoreFrameSlice(frame: TextureFrame, raw: Pick): 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 { 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; } }