From 35a1a23af8b401750c66b9e9a5cf4acbaafab3bc Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 14:27:23 +0200 Subject: [PATCH] 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 --- README.md | 36 +- src/core/commands/assetCommands.ts | 13 +- src/core/commands/frameCommands.ts | 217 +++++++++++ src/core/model/Asset.ts | 26 +- src/core/model/Project.ts | 28 +- src/core/model/texture.ts | 211 ++++++++++- src/core/properties/PropertySchema.ts | 18 +- src/core/properties/textureRef.ts | 68 ++++ src/core/store/serialize.ts | 34 +- src/editor/Editor.ts | 31 +- src/editor/actions.ts | 7 +- src/editor/debugHook.ts | 15 +- src/editor/inspector/Inspector.ts | 10 +- src/editor/inspector/fieldFactory.ts | 31 ++ src/editor/inspector/frameSection.ts | 136 +++++++ src/editor/shortcuts.ts | 2 + src/editor/state.ts | 22 ++ src/editor/texture/SliceDialog.ts | 115 ++++++ src/editor/texture/TextureCanvas.ts | 498 ++++++++++++++++++++++++++ src/editor/texture/TextureEditor.ts | 131 +++++++ src/editor/texture/sheetView.ts | 135 +++++++ src/viewport/symbols.ts | 29 +- src/viewport/textureFill.ts | 48 +++ tests/core/frame-commands.test.ts | 282 +++++++++++++++ tests/core/texture.test.ts | 114 +++++- tests/editor/sheet-view.test.ts | 143 ++++++++ tests/viewport/texture-fill.test.ts | 104 ++++++ tools/e2e/13-frames.mjs | 197 ++++++++++ tools/e2e/index.mjs | 2 + 29 files changed, 2650 insertions(+), 53 deletions(-) create mode 100644 src/core/commands/frameCommands.ts create mode 100644 src/core/properties/textureRef.ts create mode 100644 src/editor/inspector/frameSection.ts create mode 100644 src/editor/texture/SliceDialog.ts create mode 100644 src/editor/texture/TextureCanvas.ts create mode 100644 src/editor/texture/TextureEditor.ts create mode 100644 src/editor/texture/sheetView.ts create mode 100644 src/viewport/textureFill.ts create mode 100644 tests/core/frame-commands.test.ts create mode 100644 tests/editor/sheet-view.test.ts create mode 100644 tests/viewport/texture-fill.test.ts create mode 100644 tools/e2e/13-frames.mjs diff --git a/README.md b/README.md index 45e7e202..62d69c44 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,35 @@ picture becomes ten differently coloured coins, per instance, for nothing. Witho **`filter`** is the texture's only property: *Smooth* (default) or *Pixel art*, which is the difference between crisp and mush when a small image is scaled up. +### Frames — cutting a sheet up + +The **Texture tab** shows one texture with its **frames** on top: named rectangles in image +pixels. Drag on empty picture to cut one, drag it or its eight handles to adjust it, and edit +the exact numbers in the inspector; `Delete` removes the selected frame. **Slice…** cuts a +regular grid at once — you give columns and rows (not a cell size: everyone knows how many +tiles their sheet has, and deriving the cell size from the picture cannot disagree with it), +with optional offset and spacing for padded sheets, and the dialog says how many frames of +what size the numbers would produce before it commits. + +A sprite then wears **a frame instead of the whole picture** — but through the *same* +property. `texture` holds either a texture id (the whole image) or a frame id, because frame +ids come from the same generator and are unique across the document. That is what keeps one +picker, one property and one set of instance overrides, and what makes every sprite written +before frames existed still mean exactly what it meant. The picker lists each sheet followed +by its frames as "Sheet / Frame", and it is searchable, because one sliced sheet adds an entry +per cell. + +Frames are intrinsic data on the texture, like a scene's objects and a behavior's graph — not +properties, since nothing about them is overridable per instance. All edits go through +commands, so cutting, dragging, slicing and re-slicing are undoable, and one drag is **one** +undo step. Deleting a frame leaves the sprites pointing at it: a dangling reference draws the +plain colour and comes back by itself on undo. Deleting the whole texture counts the sprites +wearing *any* of its frames, not just the ones naming the texture directly. + +Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill +ignores a `Texture`'s `frame` (measured, see `viewport/textureFill.ts`). One consequence is +worth having: a sheet stays **one** GPU upload however many frames of it are on screen. + **Nothing ever breaks on a missing picture.** While an image loads, and for a reference that dangles because the texture was deleted, the sprite draws its plain colour — exactly what it looked like before textures existed. So there is no placeholder, no flicker and no error path. @@ -347,6 +376,7 @@ object in the selection (hierarchy rows too). Editing a container's template directly (today changes reach it only via **Apply to container** from an instance), reordering the hierarchy, several scenes in one run (a `Load scene` node – screens are containers today), sound, auth, touch-optimised layout, -graph comments/groups. Textures exist but are always stretched to the sprite's `width` × -`height`: no sprite sheets, no animation, no nine-slice, and no "size from texture" button. -Unreferenced image files are never collected. +graph comments/groups. A frame is always stretched to the sprite's `width` × `height`: no +animation (nothing steps through a sheet's frames yet), no nine-slice, and no "size from +frame" button. Slicing cuts a regular grid only — no detection of where the sprites actually +sit on the sheet. Unreferenced image files are never collected. diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 55423822..62148659 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -22,7 +22,7 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { const base = { id, name, props: defaultsFor(type) }; switch (type) { case 'texture': - return { ...base, type: 'texture', file: '', width: 0, height: 0 }; + return buildTextureAsset(name, { file: '', width: 0, height: 0 }, id); case 'scene': return { ...base, type: 'scene', objects: [] }; case 'camera': @@ -70,6 +70,15 @@ export interface TextureImage { height: number; } +/** + * A texture asset for a stored image, without frames – slicing it is a separate, undoable + * edit. Kept apart from `buildAsset` so callers that need a `TextureAsset` get one, instead + * of the `Asset` union they would have to narrow again. + */ +export function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset { + return { id, name, props: defaultsFor('texture'), type: 'texture', ...image, frames: [] }; +} + /** * Adds a texture asset for an image that is already stored on the server. Uploading is I/O and * therefore happens before the command: this one only records the result, so undo and redo of @@ -80,7 +89,7 @@ export class CreateTextureCommand implements Command { readonly asset: TextureAsset; constructor(name: string, image: TextureImage) { - this.asset = { ...buildAsset('texture', name), type: 'texture', ...image }; + this.asset = buildTextureAsset(name, image); this.label = `Import texture "${name}"`; } diff --git a/src/core/commands/frameCommands.ts b/src/core/commands/frameCommands.ts new file mode 100644 index 00000000..b68ce86e --- /dev/null +++ b/src/core/commands/frameCommands.ts @@ -0,0 +1,217 @@ +/** + * Undoable edits of a texture's frames – the named rectangles a sprite sheet is cut into + * (see `model/texture.ts`). Every rectangle that enters the model passes `normalizeFrameRect` + * here rather than at the call site: frames come from dragging in a zoomed view, so callers + * produce fractional and inverted rectangles as a matter of course. + */ +import { newId } from '../ids'; +import type { TextureAsset, TextureFrame } from '../model/Asset'; +import type { Project } from '../model/Project'; +import { + frameRect, + type FrameRect, + nextFrameNames, + normalizeFrameRect, + setFrameRect, + sliceFrames, + type SliceOptions, + uniqueFrameName, +} from '../model/texture'; +import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; +import { type Command, CommandError } from './Command'; +import { requireAsset } from './require'; + +/** The name new frames are derived from when the caller has nothing better to offer. */ +const DEFAULT_NAME = 'Frame'; + +/** The asset as a texture, rejecting both "no such asset" and "that one is not a picture". */ +function requireTexture(project: Project, assetId: string): TextureAsset { + const asset = requireAsset(project, assetId); + if (asset.type !== 'texture') throw new CommandError(`${asset.name} is not a texture`); + return asset; +} + +function requireFrame(asset: TextureAsset, frameId: string): TextureFrame { + const frame = asset.frames.find((f) => f.id === frameId); + if (!frame) throw new CommandError(`Frame ${frameId} not found in ${asset.name}`); + return frame; +} + +/** + * Adds one frame to a texture. The id is known before execution, which is what lets the caller + * select the new frame right after executing – the same trick `PlaceAssetCommand` uses. + */ +export class AddFrameCommand implements Command { + readonly label = 'Add frame'; + readonly frameId = newId(); + + constructor( + private readonly assetId: string, + private readonly rect: FrameRect, + private readonly name = DEFAULT_NAME, + ) {} + + execute(project: Project): void { + const asset = requireTexture(project, this.assetId); + asset.frames.push({ + id: this.frameId, + // Resolved on every execution rather than in the constructor: a redo has to fit the + // names that exist when it runs, not the ones that existed when the command was built. + name: uniqueFrameName(asset, this.name), + ...normalizeFrameRect(this.rect, asset.width, asset.height), + }); + } + + undo(project: Project): void { + const asset = requireTexture(project, this.assetId); + asset.frames = removeById(asset.frames, this.frameId); + } +} + +/** + * Moves and resizes a frame – one command per drag, not one per pointer move. + * + * A drag previews itself by writing into the frame directly; on release the caller puts the + * starting rectangle back and executes this once, exactly as `SceneViewport` does for + * `MoveSceneObjectCommand`. The rectangle to revert to is therefore read from the model at + * execution time: a caller that forgets to undo its preview would record the preview as the + * state to return to, and the whole drag would collapse into one undo step of nothing. + */ +export class SetFrameRectCommand implements Command { + readonly label = 'Move frame'; + private previous: FrameRect = { x: 0, y: 0, w: 0, h: 0 }; + + constructor( + private readonly assetId: string, + private readonly frameId: string, + private readonly rect: FrameRect, + ) {} + + execute(project: Project): void { + const asset = requireTexture(project, this.assetId); + const frame = requireFrame(asset, this.frameId); + this.previous = frameRect(frame); + setFrameRect(frame, normalizeFrameRect(this.rect, asset.width, asset.height)); + } + + undo(project: Project): void { + const asset = requireTexture(project, this.assetId); + // Restored verbatim instead of normalized again: undo owes the user the state that was + // there, even if it came out of a document written before the image was measured. + setFrameRect(requireFrame(asset, this.frameId), this.previous); + } +} + +/** Renames a frame. The name is what the sprite's texture picker shows, so it may not be blank. */ +export class RenameFrameCommand implements Command { + readonly label = 'Rename frame'; + private previous = ''; + + constructor( + private readonly assetId: string, + private readonly frameId: string, + private readonly name: string, + ) {} + + execute(project: Project): void { + const asset = requireTexture(project, this.assetId); + const frame = requireFrame(asset, this.frameId); + const name = this.name.trim(); + if (!name) throw new CommandError('A frame needs a name'); + this.previous = frame.name; + frame.name = uniqueFrameName(asset, name, this.frameId); + } + + undo(project: Project): void { + requireFrame(requireTexture(project, this.assetId), this.frameId).name = this.previous; + } +} + +/** + * Removes a frame, putting it back at its former index on undo – the frame order is the order + * of the sheet, so appending it again would silently reshuffle the picture. + * + * Sprites wearing the frame are left pointing at it. A dangling reference reads as "no + * picture" everywhere (`resolveTexture`) and comes back by itself when this is undone, which + * clearing the references could not offer. + */ +export class RemoveFrameCommand implements Command { + readonly label = 'Remove frame'; + private removed?: Removed; + + constructor( + private readonly assetId: string, + private readonly frameId: string, + ) {} + + execute(project: Project): void { + const asset = requireTexture(project, this.assetId); + const removed = removeFirst(asset.frames, (f) => f.id === this.frameId); + if (!removed) throw new CommandError(`Frame ${this.frameId} not found in ${asset.name}`); + this.removed = removed; + } + + undo(project: Project): void { + if (!this.removed) return; + restoreAt(requireTexture(project, this.assetId).frames, [this.removed]); + } +} + +/** + * Cuts a sheet into a grid of frames (see `sliceFrames`), either adding them to the frames + * already there or replacing those. + * + * A grid that yields nothing is rejected rather than applied as an empty change: it means the + * image has not been measured yet or the cells came out smaller than a pixel, and both look to + * the user like the Slice button doing nothing at all. + */ +export class SliceFramesCommand implements Command { + readonly label: string; + private created: TextureFrame[] = []; + private previous: TextureFrame[] = []; + + constructor( + private readonly assetId: string, + private readonly options: SliceOptions, + private readonly replace: boolean, + ) { + this.label = replace ? 'Re-slice frames' : 'Slice frames'; + } + + execute(project: Project): void { + const asset = requireTexture(project, this.assetId); + if (asset.width <= 0 || asset.height <= 0) + throw new CommandError(`The size of ${asset.name} is not known, so it cannot be sliced`); + const rects = sliceFrames(asset.width, asset.height, this.options); + if (rects.length === 0) + throw new CommandError(`${this.options.columns}×${this.options.rows} does not fit ${asset.name}`); + + this.previous = [...asset.frames]; + if (this.replace) asset.frames = []; + // A redo reuses the frames the first run created: while the command sat on the undo stack + // a sprite may have been pointed at one of them, and a fresh id would break that. + if (this.created.length === 0) this.created = this.buildFrames(asset, rects); + else asset.frames.push(...this.created); + } + + undo(project: Project): void { + requireTexture(project, this.assetId).frames = this.previous; + } + + /** + * Frames named after their position in the sheet ("Frame 1" is the first cell in reading + * order). The whole run of names is asked for at once, so that keeping the existing frames + * shifts the numbering past them instead of suffixing a number onto a number. + */ + private buildFrames(asset: TextureAsset, rects: readonly FrameRect[]): TextureFrame[] { + const names = nextFrameNames(asset, rects.length, DEFAULT_NAME); + const created = rects.map((rect, index) => ({ + id: newId(), + name: names[index]!, + // A no-op for a whole-pixel grid, but the offsets come from a form and may be anything. + ...normalizeFrameRect(rect, asset.width, asset.height), + })); + asset.frames.push(...created); + return created; + } +} diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 98bcce83..2042b747 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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 = diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index 40466134..66dd5a84 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -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(); + const refKeys = new Map(); + 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; diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 25e2f4af..18276002 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -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 { + 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; + } } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 408abe17..ca672b25 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -23,7 +23,16 @@ export type PropertyDef = * The id is not validated on load – a reference can dangle, exactly like a graph node's * asset parameter, and the places that read one have to cope with that. */ - | { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] }; + | { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] } + /** + * A picture: the id of a texture asset (the whole image) or of one of its frames. Its own + * kind rather than an `asset` reference with `assetTypes: ['texture']`, because a frame is + * not an asset – it is a rectangle inside one, and the picker has to offer both. + * + * Like an asset reference the id is not validated on load and may dangle; everything that + * reads one falls back to drawing no picture. + */ + | { key: string; label: string; kind: 'texture'; default: string }; export type PropertySchema = readonly PropertyDef[]; @@ -69,7 +78,7 @@ const ASSET_SCHEMAS: Record = { * white coin picture becomes ten differently coloured coins, per instance, for free. */ { key: 'color', label: 'Color', kind: 'color', default: 0xffffff }, - { key: 'texture', label: 'Texture', kind: 'asset', default: '', assetTypes: ['texture'] }, + { key: 'texture', label: 'Texture', kind: 'texture', default: '' }, ], // The picture itself is intrinsic data on the asset (see TextureAsset), not a property: // only how it is sampled is worth deciding, and that decision is the whole schema. @@ -190,11 +199,12 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal } case 'boolean': return Boolean(value); - // An asset reference is an id like the other two are plain strings; whether it still - // resolves is not decidable from a single property definition. + // An asset or texture reference is an id like the other two are plain strings; whether it + // still resolves is not decidable from a single property definition. case 'text': case 'key': case 'asset': + case 'texture': return String(value); case 'enum': { const s = String(value); diff --git a/src/core/properties/textureRef.ts b/src/core/properties/textureRef.ts new file mode 100644 index 00000000..a669cf35 --- /dev/null +++ b/src/core/properties/textureRef.ts @@ -0,0 +1,68 @@ +import type { TextureAsset, TextureFrame } from '../model/Asset'; +import { findAsset, type Project } from '../model/Project'; +import { findFrame } from '../model/texture'; + +/** Shown for a `texture` property that references nothing. */ +export const NO_TEXTURE_LABEL = 'None'; + +/** Separates a texture's name from a frame's name wherever the two are shown together. */ +const FRAME_SEPARATOR = ' / '; + +/** One thing a sprite's `texture` property can be set to. */ +export interface TextureChoice { + /** Asset id for the whole picture, frame id for one cut-out of it. */ + id: string; + label: string; + /** The texture this choice belongs to – frames of one sheet stay together in the menu. */ + asset: TextureAsset; + /** Null for the whole-picture entry. */ + frame: TextureFrame | null; +} + +/** + * How a texture reference reads wherever it is displayed: the texture's name for a whole + * picture, "Sheet / Frame" for a frame, "None" for the empty id, and `?` when the id no + * longer resolves. + * + * A dangling reference keeps showing its id instead of falling back to "None": the id is the + * only trace left of what was meant, and blanking it would make a deleted frame look like a + * field that was never filled in. `project` may be null while a view has no project yet. + */ +export function textureRefLabel(project: Project | null, id: string): string { + if (id === '') return NO_TEXTURE_LABEL; + if (!project) return `${id}?`; + const asset = findAsset(project, id); + if (asset) return asset.type === 'texture' ? asset.name : `${id}?`; + const found = findFrame(project, id); + return found ? `${found.asset.name}${FRAME_SEPARATOR}${found.frame.name}` : `${id}?`; +} + +/** + * Everything a sprite may wear, in menu order: each texture followed by its own frames. + * + * The whole picture stays on offer next to the frames rather than being replaced by them. + * A texture that was never sliced has no frames at all, and a sheet's full image is + * occasionally what you want (a background, a check of the sheet itself) – forcing a frame + * would mean slicing every single-image texture into one pointless frame. + * + * Textures are sorted by name so the order does not depend on upload time; frames keep the + * order they were created in, which for a sliced sheet is reading order and therefore the + * order the animation runs in. + */ +export function textureRefChoices(project: Project): TextureChoice[] { + const textures = project.assets + .filter((a): a is TextureAsset => a.type === 'texture') + .sort((a, b) => a.name.localeCompare(b.name)); + const choices: TextureChoice[] = []; + for (const asset of textures) { + choices.push({ id: asset.id, label: asset.name, asset, frame: null }); + for (const frame of asset.frames) + choices.push({ + id: frame.id, + label: `${asset.name}${FRAME_SEPARATOR}${frame.name}`, + asset, + frame, + }); + } + return choices; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 2c0b6d98..7dca71b2 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -1,11 +1,11 @@ // The graph package index registers the built-in nodes; unknown node types are dropped below. import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph'; -import type { Asset } from '../model/Asset'; +import type { Asset, TextureFrame } from '../model/Asset'; import { isAssetType } from '../model/AssetTypes'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; -import { isTextureFile } from '../model/texture'; +import { isTextureFile, normalizeFrameRect } from '../model/texture'; import { hasParentCycle } from '../model/walk'; import { coerceValue, @@ -43,8 +43,10 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { } } - // Pass 2: build assets. + // Pass 2: build assets. Frame ids live in the same reference space as asset ids (a sprite's + // `texture` may name either), so they are handed the ids already spoken for. const assets: Asset[] = []; + const takenIds = new Set(schemas.keys()); for (const a of rawAssets) { const id = typeof a.id === 'string' ? a.id : ''; const schema = schemas.get(id); @@ -64,7 +66,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { graph: normalizeGraph(a.graph), }); } else if (type === 'texture') { - assets.push({ id, type, name, props, ...normalizeTexture(a) }); + assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) }); } else { assets.push({ id, type, name, props }); } @@ -189,11 +191,31 @@ function normalizeGraph(raw: unknown): Graph { * is dropped rather than kept: it can only have been hand-written, and a request built from it * would 404. Such a texture loads as "no picture", which draws as the sprite's plain colour. */ -function normalizeTexture(raw: Record): { file: string; width: number; height: number } { +function normalizeTexture( + raw: Record, + takenIds: Set, +): { file: string; width: number; height: number; frames: TextureFrame[] } { const file = typeof raw.file === 'string' && isTextureFile(raw.file) ? raw.file : ''; const size = (value: unknown): number => typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; - return { file, width: size(raw.width), height: size(raw.height) }; + const width = size(raw.width); + const height = size(raw.height); + const frames: TextureFrame[] = []; + for (const item of Array.isArray(raw.frames) ? raw.frames : []) { + const f = asRecord(item); + const id = typeof f.id === 'string' ? f.id : ''; + // A frame id shares the reference space with asset ids (a sprite may name either), so a + // duplicate would make one reference mean two pictures. Drop it rather than resolve it. + if (!id || takenIds.has(id)) continue; + takenIds.add(id); + const rect = normalizeFrameRect( + { x: toNumber(f.x), y: toNumber(f.y), w: toNumber(f.w), h: toNumber(f.h) }, + width, + height, + ); + frames.push({ id, name: typeof f.name === 'string' ? f.name : '', ...rect }); + } + return { file, width, height, frames }; } function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag { diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 1e0d2cf5..d4cfa541 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -14,9 +14,13 @@ import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession'; import { ProjectSession } from './ProjectSession'; import { installShortcuts } from './shortcuts'; import type { EditorState } from './state'; +import { TextureEditor } from './texture/TextureEditor'; import { installTextureImport } from './textureImport'; -export type CentreTab = 'scene' | 'graph' | 'game'; +export type CentreTab = 'scene' | 'graph' | 'game' | 'texture'; + +/** Tab strip order – also the one list `showTab` walks, so a new tab cannot be half-added. */ +const TAB_ORDER = ['scene', 'game', 'graph', 'texture'] as const; const MIN_LEFT_WIDTH = 160; const MAX_LEFT_WIDTH = 500; @@ -34,6 +38,8 @@ export class Editor implements PlayHost { /** Game tab: the running world through its camera. */ readonly gameView: GameViewport; readonly graphEditor: GraphEditor; + /** Texture tab: one sheet with its frames. */ + readonly textureEditor: TextureEditor; readonly hierarchy: HierarchyPanel; tab: CentreTab = 'scene'; /** Frame rate, refreshed twice a second; null until the first measurement. */ @@ -63,6 +69,7 @@ export class Editor implements PlayHost { this.viewport = new SceneViewport(state); this.gameView = new GameViewport(state); this.graphEditor = new GraphEditor(state); + this.textureEditor = new TextureEditor(state); this.hierarchy = new HierarchyPanel(state); this.inspector = new Inspector(state); this.play = new PlaySession(state, this); @@ -89,7 +96,7 @@ export class Editor implements PlayHost { installTextureImport(app.canvas, { state, textures }); this.leftColumn.add(this.hierarchy, 1); - // Centre: a tab strip (Scene | Game | Graph) above the scene view, the game view or the graph editor. + // Centre: a tab strip above the scene view, the game view, the graph editor or the sheet editor. this.centre = new VStack(); const tabs = new HStack({ gap: 2, padding: 4, align: 'center' }); tabs.minHeight = Theme.controlHeight + 8; @@ -97,14 +104,21 @@ export class Editor implements PlayHost { scene: new Button('Scene', { variant: 'ghost', width: 90, onClick: () => this.showTab('scene') }), game: new Button('Game', { variant: 'ghost', width: 90, onClick: () => this.showTab('game') }), graph: new Button('Graph', { variant: 'ghost', width: 90, onClick: () => this.showTab('graph') }), + texture: new Button('Texture', { + variant: 'ghost', + width: 90, + onClick: () => this.showTab('texture'), + }), }; - for (const tab of ['scene', 'game', 'graph'] as const) tabs.add(this.tabButtons[tab]); + for (const tab of TAB_ORDER) tabs.add(this.tabButtons[tab]); this.centre.add(tabs); this.centre.add(this.viewport, 1); this.centre.add(this.graphEditor, 1); this.centre.add(this.gameView, 1); + this.centre.add(this.textureEditor, 1); this.graphEditor.visible = false; this.gameView.visible = false; + this.textureEditor.visible = false; this.tabButtons.scene.setToggled(true); this.leftColumn.minWidth = this.leftWidth; @@ -130,9 +144,13 @@ export class Editor implements PlayHost { attachAutosave(state, store); installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() }); app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); - // Selecting a graph node switches to the graph; selecting a behavior does not force it. + // Selecting a graph node switches to the graph, selecting a frame to the sheet: both are + // things that exist *only* in their tab, so a selection you could not see would be a lie. + // Selecting the behavior or the texture asset itself does not force anything. state.on('selection', () => { - if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph'); + const kind = state.selection?.kind; + if (kind === 'node' && this.tab !== 'graph') this.showTab('graph'); + if (kind === 'frame' && this.tab !== 'texture') this.showTab('texture'); }); // While playing, every edit (command, undo, scrub preview) is mirrored into the world at once. state.on('project', () => this.play.syncFromModel()); @@ -143,7 +161,8 @@ export class Editor implements PlayHost { this.viewport.visible = tab === 'scene'; this.graphEditor.visible = tab === 'graph'; this.gameView.visible = tab === 'game'; - for (const [key, button] of Object.entries(this.tabButtons)) button.setToggled(key === tab); + this.textureEditor.visible = tab === 'texture'; + for (const key of TAB_ORDER) this.tabButtons[key].setToggled(key === tab); this.centre.relayout(); ui().focus.validate(); } diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 3c18c867..638f398c 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -7,6 +7,7 @@ import { AddColliderCommand } from '../core/commands/colliderCommands'; import { CreateContainerCommand } from '../core/commands/containerCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import { findAsset, findPropertyUsages, findUsages } from '../core/model/Project'; +import { textureRefIds } from '../core/model/texture'; import { Dialog } from '../ui'; import { type EditorState, objectSelection } from './state'; @@ -41,8 +42,10 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void { if (!asset) return; const usages = findUsages(state.project, assetId).length; // A texture is never placed – it is worn through a property – so counting placed instances - // would report "unused" for a picture a dozen sprites are showing. - const references = findPropertyUsages(state.project, assetId).length; + // would report "unused" for a picture a dozen sprites are showing. Its frames count as the + // texture itself: a sprite wearing one of them loses its picture just the same. + const ids = asset.type === 'texture' ? textureRefIds(asset) : assetId; + const references = findPropertyUsages(state.project, ids).length; const detail = asset.type === 'scene' ? `The scene and its ${asset.objects.length} object(s) are removed.` diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index ae7acbde..95c5a943 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -1,6 +1,6 @@ import { type Application, type Container, Rectangle, Text } from 'pixi.js'; import { ui, Widget } from '../ui'; -import type { Editor } from './Editor'; +import type { CentreTab, Editor } from './Editor'; import type { EditorState } from './state'; /** @@ -8,7 +8,7 @@ import type { EditorState } from './state'; * visible text and to read editor state. Read-only access; harmless in production. */ export function installDebugHook(app: Application, state: EditorState, editor: Editor): void { - const { viewport, gameView, graphEditor, hierarchy } = editor; + const { viewport, gameView, graphEditor, textureEditor, hierarchy } = editor; const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => { const p = widget.getGlobalPosition(); return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; @@ -81,6 +81,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId), center: () => centreOf(graphEditor), }, + texture: { + current: () => textureEditor.currentAssetId, + /** + * Where an image pixel of the shown sheet sits on screen. Frames are dragged in image + * coordinates, so without this a check could only drag somewhere and hope – it could not + * aim at a known part of the picture and then assert which part came out. + */ + pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y), + }, world: () => editor.worldSnapshot(), /** Rows the hierarchy shows – collapsed subtrees are not in here. */ hierarchyOrder: () => hierarchy.rowOrder(), @@ -103,6 +112,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, fps: () => editor.fps, tab: () => editor.tab, - showTab: (tab: 'scene' | 'graph' | 'game') => editor.showTab(tab), + showTab: (tab: CentreTab) => editor.showTab(tab), }; } diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 76db9c66..2b809227 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -2,6 +2,7 @@ import type { Container } from 'pixi.js'; import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui'; import { type EditorState, sameSelection, type Selection } from '../state'; import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection'; +import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection'; import type { InspectorHost } from './host'; import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection'; import { @@ -13,7 +14,8 @@ import { /** * Shows the properties of the current selection. The rows are generated from the - * PropertySchema by one section per selection kind (asset, scene object, graph node); the + * PropertySchema by one section per selection kind (asset, scene object, graph node, texture + * frame); the * panel only decides when a section has to be rebuilt and when its values merely refresh. */ export class Inspector extends Panel { @@ -70,6 +72,7 @@ export class Inspector extends Panel { if (!s) this.host.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' })); else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId); else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId); + else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId); else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.sceneId, s.objectIds); else buildObjectSection(this.host, s.sceneId, s.objectId); this.scroll.setContent(this.host.body); @@ -80,6 +83,7 @@ export class Inspector extends Panel { if (!s) return ''; if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId); if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId); + if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId); return objectSignature(this.state.project, s.sceneId, s.objectIds, s.objectId); } @@ -96,7 +100,9 @@ export class Inspector extends Panel { ? refreshAssetSection(this.host, s.assetId) : s.kind === 'node' ? refreshNodeSection(this.host, s.assetId, s.nodeId) - : s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId); + : s.kind === 'frame' + ? refreshFrameSection(this.host, s.assetId, s.frameId) + : s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId); if (!ok) this.rebuild(); } } diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 1e364fb4..d73420c4 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -7,6 +7,7 @@ import { NO_ASSET_LABEL, } from '../../core/properties/assetRef'; import type { PropertyDef } from '../../core/properties/PropertySchema'; +import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef'; import { Button, Checkbox, @@ -228,6 +229,36 @@ export function createField( }, }; } + case 'texture': { + // The id is kept here for the same reason as in the asset case, and more urgently: a + // frame's label repeats its sheet's name, so several entries can read identically. + let current = String(value); + const button = new Dropdown(textureRefLabel(ctx.project(), current)); + button.onClick = () => { + const mark = (id: string, text: string) => (id === current ? `● ${text}` : text); + const items: MenuItem[] = [ + { label: mark('', NO_TEXTURE_LABEL), onSelect: () => handlers.onChange('') }, + // Built on click, not once per row: textures are uploaded and frames are cut, renamed + // and sliced away while the inspector stands, and the picker has to show what exists now. + ...textureRefChoices(ctx.project()).map((choice) => ({ + label: mark(choice.id, choice.label), + onSelect: () => handlers.onChange(choice.id), + })), + ]; + // Searchable: one sliced sheet alone contributes an entry per frame. The entries read + // "Sheet / Frame", so the sheet's own name gathers its frames into one block of the + // list and typing either name finds them. + new Menu(items, { searchable: true }).openBelow(button); + }; + return { + widget: button, + grow: true, + setValue: (v) => { + current = String(v); + button.setText(textureRefLabel(ctx.project(), current)); + }, + }; + } } } diff --git a/src/editor/inspector/frameSection.ts b/src/editor/inspector/frameSection.ts new file mode 100644 index 00000000..fc5ab01c --- /dev/null +++ b/src/editor/inspector/frameSection.ts @@ -0,0 +1,136 @@ +import { + RemoveFrameCommand, + RenameFrameCommand, + SetFrameRectCommand, +} from '../../core/commands/frameCommands'; +import type { TextureAsset, TextureFrame } from '../../core/model/Asset'; +import { findAsset, type Project } from '../../core/model/Project'; +import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture'; +import { Button, TextField } from '../../ui'; +import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; +import { addGap, addNote, addTitle, type InspectorHost } from './host'; +import { previewingHandlers } from './previewHandlers'; + +/** The components of the rectangle, in the order they are shown. */ +const RECT_COMPONENTS = [ + { key: 'x', label: 'X' }, + { key: 'y', label: 'Y' }, + { key: 'w', label: 'Width' }, + { key: 'h', label: 'Height' }, +] as const; + +/** + * Structure-defining facts of a frame selection: none. A frame is a name and four numbers + * whatever texture it was cut from, so the rows never differ – only the frame disappearing + * can force a rebuild, and that is what `refreshFrameSection` reports. + */ +export function frameSignature(project: Project, assetId: string, frameId: string): string { + return currentFrame(project, assetId, frameId) ? 'frame' : 'missing'; +} + +/** Name, the rectangle it cuts out of its texture, and the way to delete it. */ +export function buildFrameSection(host: InspectorHost, assetId: string, frameId: string): void { + const asset = currentTexture(host.state.project, assetId); + const frame = asset?.frames.find((f) => f.id === frameId); + if (!asset || !frame) return; + + addTitle(host, 'TEXTURE FRAME'); + host.nameField = new TextField(frame.name, { + onChange: (name) => { + // Like an asset name. The command rejects a blank name by itself; what it cannot do is + // put the old text back into the field, which is why an empty one is not sent at all. + if (name.trim()) host.state.execute(new RenameFrameCommand(assetId, frameId, name.trim())); + else host.nameField?.setValue(frame.name); + }, + }); + const { row } = labelledRow('Name'); + row.add(host.nameField, 1); + host.body.add(row); + addNote(host, `Frame of the texture "${asset.name}".`); + + addTitle(host, 'RECTANGLE (IMAGE PIXELS)'); + for (const component of RECT_COMPONENTS) + rectRow(host, assetId, frameId, component.key, component.label, frame[component.key]); + + addGap(host); + host.body.add( + new Button('Delete frame', { + variant: 'danger', + onClick: () => host.state.execute(new RemoveFrameCommand(assetId, frameId)), + }), + ); +} + +/** Pushes the frame's current name and rectangle into the rows. False when it is gone (rebuild). */ +export function refreshFrameSection(host: InspectorHost, assetId: string, frameId: string): boolean { + const frame = currentFrame(host.state.project, assetId, frameId); + if (!frame) return false; + host.nameField?.setValue(frame.name); + for (const component of RECT_COMPONENTS) host.fields.get(component.key)?.setValue(frame[component.key]); + return true; +} + +/** A number row editing one component of the rectangle. Whole pixels: a frame indexes a bitmap. */ +function rectRow( + host: InspectorHost, + assetId: string, + frameId: string, + key: keyof FrameRect, + label: string, + value: number, +): void { + const row = createFieldRow( + host, + { key, label, kind: 'number', default: 0, step: 1 }, + value, + rectHandlers(host, assetId, frameId, key), + ); + host.fields.set(key, row); + host.body.add(row.row); +} + +/** + * The whole rectangle with one component replaced; the other three are read from the model at + * that moment, so a frame dragged in the Texture tab in between is not undone by typing into a + * field beside it. + * + * Normalising here as well as in the command is not a duplicate rule but the same one applied + * to the *preview*: an un-normalised preview would draw the frame off the sheet on the way + * through a scrub, and a half-typed "1" of "16" would jump it to the corner. Because it is the + * same function the command uses, what is previewed is exactly what is committed. + */ +function rectHandlers( + host: InspectorHost, + assetId: string, + frameId: string, + key: keyof FrameRect, +): FieldHandlers { + return previewingHandlers(host, { + target: () => currentFrame(host.state.project, assetId, frameId), + read: frameRect, + write: setFrameRect, + toModel: (input, frame) => { + const asset = currentTexture(host.state.project, assetId); + return normalizeFrameRect( + { ...frameRect(frame), [key]: Number(input) }, + asset?.width ?? 0, + asset?.height ?? 0, + ); + }, + commit: (rect) => new SetFrameRectCommand(assetId, frameId, rect), + }); +} + +/** + * The frame is looked up inside the selected texture rather than by its id alone: the + * selection names both, and honouring both keeps a selection left over from a deleted texture + * from editing whichever frame later inherits the id. + */ +function currentFrame(project: Project, assetId: string, frameId: string): TextureFrame | undefined { + return currentTexture(project, assetId)?.frames.find((f) => f.id === frameId); +} + +function currentTexture(project: Project, assetId: string): TextureAsset | undefined { + const asset = findAsset(project, assetId); + return asset?.type === 'texture' ? asset : undefined; +} diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 544861b1..f3dc6ddb 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -1,3 +1,4 @@ +import { RemoveFrameCommand } from '../core/commands/frameCommands'; import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { ui } from '../ui'; import { confirmDeleteAsset, createContainer, removeObjects } from './actions'; @@ -38,6 +39,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions): const s = state.selection; if (s?.kind === 'object') removeObjects(state, s.sceneId, s.objectIds); else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId)); + else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId)); else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId); return s !== null; } diff --git a/src/editor/state.ts b/src/editor/state.ts index a0cad990..329278c6 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -14,6 +14,8 @@ export type Selection = | { kind: 'object'; sceneId: string; objectId: string; objectIds: string[] } /** A node inside a behavior's graph. */ | { kind: 'node'; assetId: string; nodeId: string } + /** A frame rectangle inside a texture asset (the Texture tab). */ + | { kind: 'frame'; assetId: string; frameId: string } | null; /** Builds an object selection; the last id is the primary one unless given explicitly. */ @@ -235,6 +237,22 @@ export class EditorState { else if (!asset.graph.nodes.some((n) => n.id === s.nodeId)) this.select({ kind: 'asset', assetId: s.assetId }); } + if (s.kind === 'frame') { + const asset = findAsset(this.project, s.assetId); + // Same fallback as a graph node: the texture stays open when its frame vanished. + if (!asset || asset.type !== 'texture') this.select(null); + else if (!asset.frames.some((f) => f.id === s.frameId)) + this.select({ kind: 'asset', assetId: s.assetId }); + } + } + + /** The texture asset the current selection refers to (the asset itself or one of its frames). */ + get selectedTextureId(): string | null { + const s = this.selection; + if (!s) return null; + if (s.kind !== 'asset' && s.kind !== 'frame') return null; + const asset = findAsset(this.project, s.assetId); + return asset && asset.type === 'texture' ? asset.id : null; } /** The behavior asset the current selection refers to (asset, placed object or node), if any. */ @@ -242,6 +260,9 @@ export class EditorState { const s = this.selection; if (!s) return null; if (s.kind === 'node') return s.assetId; + // A frame names a texture and can never lead to a behavior. Answered here rather than + // fallen through, so what remains below is the asset/object pair the lookup assumes. + if (s.kind === 'frame') return null; const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined; const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId; const asset = assetId ? findAsset(this.project, assetId) : undefined; @@ -276,5 +297,6 @@ export function sameSelection(a: Selection, b: Selection): boolean { ); } if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId; + if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId; return false; } diff --git a/src/editor/texture/SliceDialog.ts b/src/editor/texture/SliceDialog.ts new file mode 100644 index 00000000..a3db8228 --- /dev/null +++ b/src/editor/texture/SliceDialog.ts @@ -0,0 +1,115 @@ +import type { TextureAsset } from '../../core/model/Asset'; +import { DEFAULT_SLICE, type SliceOptions, sliceFrames } from '../../core/model/texture'; +import { Button, Checkbox, HStack, Label, NumberField, Popup, Spacer, Theme, type Widget } from '../../ui'; + +/** Width of the label column, matching the inspector's so the two read as one language. */ +const LABEL_WIDTH = 76; +const FIELD_WIDTH = 76; + +/** + * Cuts a sheet into a grid. Asks for the number of columns and rows rather than a cell size: + * everyone knows how many tiles their sheet has, and deriving the cell size from the picture + * cannot disagree with it. + * + * The dialog states, live, how many frames the current numbers would produce and how big each + * one is. That readout is the whole point – it turns "did I mean 8×4 or 4×8" from something + * you find out by undoing into something you see before committing. + */ +export class SliceDialog extends Popup { + private readonly options: SliceOptions = { ...DEFAULT_SLICE }; + private replace = true; + private readonly summary: Label; + private readonly confirm: Button; + + constructor( + private readonly asset: TextureAsset, + onSlice: (options: SliceOptions, replace: boolean) => void, + ) { + super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); + this.minWidth = 340; + this.content.add(new Label(`Slice "${asset.name}" into a grid`, { style: 'bodyBold' })); + this.content.add(new Label(`Picture is ${asset.width} × ${asset.height} px`, { style: 'muted' })); + + this.content.add(this.pair('Columns', 'columns', 1, 'Rows', 'rows', 1)); + this.content.add(this.pair('Offset X', 'offsetX', 0, 'Offset Y', 'offsetY', 0)); + this.content.add(this.pair('Spacing X', 'spacingX', 0, 'Spacing Y', 'spacingY', 0)); + + const replaceRow = new HStack({ gap: Theme.gap, align: 'center' }); + replaceRow.minHeight = Theme.rowHeight; + const box = new Checkbox(this.replace); + box.onChange = (value) => { + this.replace = Boolean(value); + }; + replaceRow.add(box); + replaceRow.add(new Label('Replace the frames this texture already has', { style: 'muted' })); + this.content.add(replaceRow); + + this.summary = new Label('', { style: 'muted' }); + this.content.add(this.summary); + + const buttons = new HStack({ gap: Theme.gap, align: 'center' }); + buttons.add(new Spacer(), 1); + const cancel = new Button('Cancel'); + cancel.onClick = this.closeThen(); + buttons.add(cancel); + this.confirm = new Button('Slice', { variant: 'primary' }); + this.confirm.onClick = this.closeThen(() => onSlice({ ...this.options }, this.replace)); + buttons.add(this.confirm); + this.content.add(buttons); + + this.refreshSummary(); + } + + /** Two numeric fields on one row – the grid's parameters come in x/y pairs. */ + private pair( + labelA: string, + keyA: keyof SliceOptions, + minA: number, + labelB: string, + keyB: keyof SliceOptions, + minB: number, + ): Widget { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + row.add(this.labelFor(labelA)); + row.add(this.fieldFor(keyA, minA)); + row.add(new Spacer(Theme.padding)); + row.add(this.labelFor(labelB)); + row.add(this.fieldFor(keyB, minB)); + row.add(new Spacer(), 1); + return row; + } + + private labelFor(text: string): Label { + const label = new Label(text, { style: 'muted' }); + label.minWidth = LABEL_WIDTH; + return label; + } + + private fieldFor(key: keyof SliceOptions, min: number): NumberField { + const field = new NumberField(this.options[key], { + min, + step: 1, + onChange: (value) => { + this.options[key] = Math.round(Number(value)); + this.refreshSummary(); + }, + }); + field.minWidth = FIELD_WIDTH; + return field; + } + + /** Live readout of what the current numbers would do, including the refusal case. */ + private refreshSummary(): void { + const rects = sliceFrames(this.asset.width, this.asset.height, this.options); + const first = rects[0]; + this.summary.setText( + first + ? `${rects.length} frames of ${first.w} × ${first.h} px` + : 'These numbers do not fit the picture – no frames.', + ); + this.summary.setStyle(first ? 'muted' : 'body'); + this.confirm.setEnabled(rects.length > 0); + this.refreshLayout(); + } +} diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts new file mode 100644 index 00000000..a6ab5762 --- /dev/null +++ b/src/editor/texture/TextureCanvas.ts @@ -0,0 +1,498 @@ +import { + Container, + type FederatedPointerEvent, + type FederatedWheelEvent, + Graphics, + Sprite, + Text, +} from 'pixi.js'; +import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands'; +import type { TextureAsset, TextureFrame } from '../../core/model/Asset'; +import type { Vec2 } from '../../core/model/Properties'; +import { findAsset } from '../../core/model/Project'; +import { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/model/texture'; +import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; +import { textureLibrary } from '../../viewport/TextureLibrary'; +import type { EditorState } from '../state'; +import { + dragFrame, + fitView, + type FrameHandle, + frameHandles, + handleAt, + handleCursor, + handlePoint, + imageToLocal, + localToImage, + rectBetween, + type SheetView, + zoomAbout, +} from './sheetView'; + +/** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */ +const HANDLE_SIZE = 8; +/** Pixels the pointer must travel before a press becomes a drag rather than a click. */ +const DRAG_THRESHOLD = 3; +/** Side of one square of the transparency checkerboard, in screen pixels. */ +const CHECKER_SIZE = 8; +const CHECKER_DARK = 0x1a1c21; +const CHECKER_LIGHT = 0x24272e; +/** Below this on-screen frame size the name is illegible clutter and is left out. */ +const MIN_LABEL_SIZE = 28; +/** Inset of a frame's name plate from the frame's top-left corner, and its own padding. */ +const LABEL_INSET = 3; +const LABEL_PAD = 2; + +/** A drag of an existing frame: which one, from what, and by which handle. */ +interface FrameDrag { + frameId: string; + handle: FrameHandle; + start: FrameRect; + pointer: Vec2; + moved: boolean; +} + +/** A new frame being spanned on empty space (image pixels; nothing is in the model yet). */ +interface NewFrame { + start: Vec2; + current: Vec2; + active: boolean; +} + +/** + * The Texture tab's canvas: one texture's picture with its frames drawn on top, where frames + * are created by dragging on empty space and moved or resized by their handles. + * + * Everything but the picture is drawn in widget-local pixels rather than in a zoomed + * container: outlines, handles and names have to keep a constant size on screen, which a + * scaled container would defeat. The picture itself is a Sprite whose scale is the zoom, so + * the GPU does the sampling – and `scaleMode` follows the texture's own Filter property, so a + * pixel-art sheet is inspected as crisp pixels rather than as the mush a smoothed preview + * would show. + */ +export class TextureCanvas extends Widget { + private readonly bg = new Graphics(); + private readonly checker = new Graphics(); + private readonly imageLayer = new Container(); + private readonly image = new Sprite(); + private readonly overlay = new Graphics(); + private readonly labels = new Map(); + private readonly labelLayer = new Container(); + private readonly hint = new Text({ text: '', style: textStyle('muted') }); + private readonly clip = new Graphics(); + private view: SheetView = { panX: 0, panY: 0, zoom: 1 }; + private assetId = ''; + /** Fit the picture once the widget has a real size and a picture to fit (it starts hidden). */ + private needsFit = true; + private panning: { pointer: Vec2; panX: number; panY: number } | null = null; + private dragging: FrameDrag | null = null; + private spanning: NewFrame | null = null; + + constructor(private readonly state: EditorState) { + super(); + this.imageLayer.addChild(this.image); + this.addChild( + this.bg, + this.checker, + this.imageLayer, + this.overlay, + this.labelLayer, + this.hint, + this.clip, + ); + this.imageLayer.mask = this.clip; + this.imageLayer.eventMode = 'none'; + this.overlay.eventMode = 'none'; + this.labelLayer.eventMode = 'none'; + this.hint.eventMode = 'none'; + this.focusable = true; + + this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); + this.on('pointerup', () => this.onPointerUp()); + this.on('pointerupoutside', () => this.onPointerUp()); + this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); + + state.on('project', () => this.redraw()); + state.on('selection', () => this.redraw()); + state.on('load', () => { + this.needsFit = true; + this.redraw(); + }); + // A picture arrives asynchronously; until then there is nothing to fit the view to. + textureLibrary.onChange(() => this.redraw()); + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; + } + + /** Which texture is shown. Switching resets the view, since the next picture is a new subject. */ + show(assetId: string): void { + if (this.assetId === assetId) return; + this.assetId = assetId; + this.needsFit = true; + this.redraw(); + } + + get currentAssetId(): string { + return this.assetId; + } + + /** Refits the picture into the widget – the "reset view" the toolbar offers. */ + fit(): void { + this.needsFit = true; + this.redraw(); + } + + /** + * Where an image pixel currently sits on screen. Only the check scripts need this: dragging + * a frame is done in image coordinates, so a test that cannot convert them can drag + * somewhere but cannot aim at a known part of the picture. + */ + imageToScreen(x: number, y: number): Vec2 { + const local = imageToLocal(this.view, x, y); + const origin = this.getGlobalPosition(); + return { x: origin.x + local.x, y: origin.y + local.y }; + } + + // --- model ------------------------------------------------------------------------------ + + private asset(): TextureAsset | null { + const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined; + return asset && asset.type === 'texture' ? asset : null; + } + + private selectedFrameId(): string { + const s = this.state.selection; + return s?.kind === 'frame' && s.assetId === this.assetId ? s.frameId : ''; + } + + private frame(frameId: string): TextureFrame | undefined { + return this.asset()?.frames.find((f) => f.id === frameId); + } + + // --- pointer ---------------------------------------------------------------------------- + + private onPointerDown(e: FederatedPointerEvent): void { + const asset = this.asset(); + // Nothing is cut from a picture whose size is unknown: `normalizeFrameRect` has no bounds + // to hold such a frame inside, so it would keep a rectangle that lies off the sheet for + // good. An unmeasured texture is a failed upload, and the hint says so. + if (!asset || asset.width <= 0 || asset.height <= 0) return; + const local = this.toLocal(e.global); + if (e.button === 1 || (e.button === 0 && e.shiftKey)) { + this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY }; + e.stopPropagation(); + return; + } + if (e.button !== 0) return; + ui().focus.focus(this); + const point = localToImage(this.view, local.x, local.y); + const tolerance = HANDLE_SIZE / this.view.zoom; + + // The selected frame is tested first and by its handles: it is the one being worked on, + // and its handles must stay reachable even where a frame drawn later overlaps them. + const selected = this.frame(this.selectedFrameId()); + const onSelected = selected ? handleAt(selected, point, tolerance) : null; + if (selected && onSelected) { + this.startDrag(selected, onSelected, local); + e.stopPropagation(); + return; + } + // Last first: later frames are drawn on top, so they are what a click on an overlap means. + for (let i = asset.frames.length - 1; i >= 0; i--) { + const frame = asset.frames[i]!; + if (handleAt(frame, point, tolerance) === null) continue; + this.state.select({ kind: 'frame', assetId: asset.id, frameId: frame.id }); + this.startDrag(frame, 'move', local); + e.stopPropagation(); + return; + } + this.spanning = { start: point, current: point, active: false }; + e.stopPropagation(); + } + + private startDrag(frame: TextureFrame, handle: FrameHandle, local: Vec2): void { + this.dragging = { + frameId: frame.id, + handle, + start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h }, + pointer: { x: local.x, y: local.y }, + moved: false, + }; + } + + private onPointerMove(e: FederatedPointerEvent): void { + const local = this.toLocal(e.global); + if (this.panning) { + this.view = { + ...this.view, + panX: this.panning.panX + (local.x - this.panning.pointer.x), + panY: this.panning.panY + (local.y - this.panning.pointer.y), + }; + this.redraw(); + return; + } + if (this.dragging) { + const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; + const dy = (local.y - this.dragging.pointer.y) / this.view.zoom; + if (!this.dragging.moved && Math.hypot(dx, dy) * this.view.zoom < DRAG_THRESHOLD) return; + this.dragging.moved = true; + this.previewDrag(dx, dy); + return; + } + if (this.spanning) { + this.spanning.current = localToImage(this.view, local.x, local.y); + const size = rectBetween(this.spanning.start, this.spanning.current); + if (!this.spanning.active && Math.max(size.w, size.h) * this.view.zoom < DRAG_THRESHOLD) return; + this.spanning.active = true; + this.redraw(); + return; + } + this.updateCursor(local); + } + + /** Applies a drag to the model directly, as a preview: no undo entry until the pointer is up. */ + private previewDrag(dx: number, dy: number): void { + const drag = this.dragging; + const asset = this.asset(); + const frame = drag && this.frame(drag.frameId); + if (!drag || !asset || !frame) return; + Object.assign( + frame, + normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height), + ); + this.state.preview(); + } + + private onPointerUp(): void { + this.panning = null; + const drag = this.dragging; + const span = this.spanning; + this.dragging = null; + this.spanning = null; + + if (drag) { + const asset = this.asset(); + const frame = this.frame(drag.frameId); + if (drag.moved && asset && frame) { + // The preview moved it already; put it back so the command records the real start. + const next: FrameRect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h }; + Object.assign(frame, drag.start); + this.state.execute(new SetFrameRectCommand(asset.id, drag.frameId, next)); + } + return; + } + if (!span) return; + if (!span.active) { + // A click on empty space clears the selection, like everywhere else in the editor. + this.state.select({ kind: 'asset', assetId: this.assetId }); + this.redraw(); + return; + } + const asset = this.asset(); + if (!asset) return; + const rect = normalizeFrameRect(rectBetween(span.start, span.current), asset.width, asset.height); + if (rect.w < MIN_FRAME_SIZE || rect.h < MIN_FRAME_SIZE) return; + const command = new AddFrameCommand(asset.id, rect); + if (this.state.execute(command)) + this.state.select({ kind: 'frame', assetId: asset.id, frameId: command.frameId }); + this.redraw(); + } + + private onWheel(e: FederatedWheelEvent): void { + const local = this.toLocal(e.global); + this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y); + this.needsFit = false; + this.redraw(); + e.preventDefault(); + } + + /** Announces what a press would do before it happens (resize arrows, move, crosshair). */ + private updateCursor(local: Vec2): void { + const asset = this.asset(); + if (!asset) { + this.cursor = 'default'; + return; + } + const point = localToImage(this.view, local.x, local.y); + const tolerance = HANDLE_SIZE / this.view.zoom; + const selected = this.frame(this.selectedFrameId()); + const handle = selected ? handleAt(selected, point, tolerance) : null; + if (handle) { + this.cursor = handleCursor(handle); + return; + } + const over = asset.frames.some((f) => handleAt(f, point, tolerance) !== null); + this.cursor = over ? 'move' : 'crosshair'; + } + + // --- drawing ---------------------------------------------------------------------------- + + override redraw(): void { + this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport); + this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); + const asset = this.asset(); + const texture = asset && asset.file ? this.pictureOf(asset) : null; + + if (asset && texture && this.needsFit && this.w > 0 && this.h > 0) { + this.view = fitView(asset.width, asset.height, this.w, this.h); + this.needsFit = false; + } + + this.image.visible = Boolean(texture); + if (asset && texture) { + this.image.texture = texture; + this.image.position.set(this.view.panX, this.view.panY); + // The sprite is drawn at the picture's own pixel size, so the zoom is the whole scale. + this.image.width = asset.width * this.view.zoom; + this.image.height = asset.height * this.view.zoom; + } + + this.drawChecker(asset, Boolean(texture)); + this.drawFrames(asset); + this.syncHint(asset, Boolean(texture)); + } + + private pictureOf(asset: TextureAsset) { + return textureLibrary.get({ + file: asset.file, + smooth: String(asset.props.filter ?? 'linear') !== 'nearest', + rect: null, + sourceWidth: asset.width, + sourceHeight: asset.height, + }); + } + + /** + * The checkerboard behind the picture. It is clipped to the picture rather than filling the + * widget: its job is to make transparent pixels of *this image* visible, and a board that + * ran past the edges would hide where the image actually ends. + */ + private drawChecker(asset: TextureAsset | null, hasPicture: boolean): void { + this.checker.clear(); + if (!asset || !hasPicture || asset.width <= 0) return; + const origin = imageToLocal(this.view, 0, 0); + const w = asset.width * this.view.zoom; + const h = asset.height * this.view.zoom; + this.checker.rect(origin.x, origin.y, w, h).fill(CHECKER_DARK); + // Only the squares that land inside the widget are worth emitting; a 4000 px sheet at a + // high zoom would otherwise cost tens of thousands of rectangles per redraw. + const from = (start: number, limit: number) => Math.max(0, Math.floor((limit - start) / CHECKER_SIZE)); + const x0 = from(origin.x, 0); + const y0 = from(origin.y, 0); + const x1 = Math.min(Math.ceil(w / CHECKER_SIZE), from(origin.x, this.w) + 1); + const y1 = Math.min(Math.ceil(h / CHECKER_SIZE), from(origin.y, this.h) + 1); + for (let cy = y0; cy < y1; cy++) { + for (let cx = x0; cx < x1; cx++) { + if ((cx + cy) % 2 === 0) continue; + const px = origin.x + cx * CHECKER_SIZE; + const py = origin.y + cy * CHECKER_SIZE; + this.checker.rect( + px, + py, + Math.min(CHECKER_SIZE, w - cx * CHECKER_SIZE), + Math.min(CHECKER_SIZE, h - cy * CHECKER_SIZE), + ); + } + } + this.checker.fill(CHECKER_LIGHT); + } + + private drawFrames(asset: TextureAsset | null): void { + this.overlay.clear(); + const alive = new Set(); + if (asset) { + const selectedId = this.selectedFrameId(); + for (const frame of asset.frames) { + alive.add(frame.id); + this.drawFrame(frame, frame.id === selectedId); + } + const selected = this.frame(selectedId); + if (selected) this.drawHandles(selected); + } + if (this.spanning?.active) { + const rect = rectBetween(this.spanning.start, this.spanning.current); + const a = imageToLocal(this.view, rect.x, rect.y); + this.overlay + .rect(a.x, a.y, rect.w * this.view.zoom, rect.h * this.view.zoom) + .fill({ color: Theme.colors.marquee, alpha: 0.15 }) + .stroke({ width: 1, color: Theme.colors.marquee }); + } + for (const [id, label] of this.labels) { + if (alive.has(id)) continue; + label.destroy(); + this.labels.delete(id); + } + } + + private drawFrame(frame: TextureFrame, selected: boolean): void { + const a = imageToLocal(this.view, frame.x, frame.y); + const w = frame.w * this.view.zoom; + const h = frame.h * this.view.zoom; + const color = selected ? Theme.colors.accent : Theme.colors.collider; + this.overlay.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color }); + this.syncLabel(frame, a, w, h, selected); + } + + private drawHandles(frame: TextureFrame): void { + const half = HANDLE_SIZE / 2; + for (const handle of frameHandles()) { + const p = handlePoint(frame, handle); + const local = imageToLocal(this.view, p.x, p.y); + this.overlay + .rect(local.x - half, local.y - half, HANDLE_SIZE, HANDLE_SIZE) + .fill(Theme.colors.accentText) + .stroke({ width: 1, color: Theme.colors.accent }); + } + } + + private syncLabel(frame: TextureFrame, at: Vec2, w: number, h: number, selected: boolean): void { + let label = this.labels.get(frame.id); + // A name that does not fit inside its frame would be read as belonging to the neighbour. + const show = Math.min(w, h) >= MIN_LABEL_SIZE && at.x < this.w && at.y < this.h && at.x + w > 0; + if (!show) { + if (label) label.visible = false; + return; + } + if (!label) { + label = new Text({ style: textStyle('body') }); + this.labels.set(frame.id, label); + this.labelLayer.addChild(label); + } + label.visible = true; + if (label.text !== frame.name) label.text = frame.name; + label.style.fill = Theme.colors.text; + const x = at.x + LABEL_INSET; + const y = at.y + LABEL_INSET; + label.position.set(x + LABEL_PAD, y + LABEL_PAD); + /* + * A dark plate behind the name, not just a colour choice for the text. + * + * The name is drawn *on the user's picture*, which may be any colour – the grey used at + * first vanished on the light quadrants of a test sheet, and no fixed colour can be safe on + * an arbitrary image. The plate makes the background known, so light text on it always + * reads. Same reasoning as the scene grid, which is drawn white at a low alpha rather than + * in a theme colour so that it survives whatever the scene puts behind it. + */ + this.overlay + .roundRect(x, y, label.width + LABEL_PAD * 2, label.height + LABEL_PAD * 2, 2) + .fill({ color: 0x000000, alpha: selected ? 0.75 : 0.55 }); + } + + private syncHint(asset: TextureAsset | null, hasPicture: boolean): void { + const text = !asset + ? 'No texture selected – pick one in the Assets panel or import an image.' + : !asset.file + ? 'This texture has no picture yet.' + : !hasPicture + ? 'Loading…' + : asset.frames.length === 0 + ? 'Drag on the picture to cut a frame, or use Slice to cut a grid at once.' + : ''; + this.hint.text = text; + this.hint.visible = text !== ''; + this.hint.position.set(Theme.padding * 2, Theme.padding * 2); + } +} diff --git a/src/editor/texture/TextureEditor.ts b/src/editor/texture/TextureEditor.ts new file mode 100644 index 00000000..721bd856 --- /dev/null +++ b/src/editor/texture/TextureEditor.ts @@ -0,0 +1,131 @@ +import { Graphics } from 'pixi.js'; +import { SliceFramesCommand } from '../../core/commands/frameCommands'; +import type { TextureAsset } from '../../core/model/Asset'; +import { findAsset } from '../../core/model/Project'; +import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui'; +import type { EditorState } from '../state'; +import { SliceDialog } from './SliceDialog'; +import { TextureCanvas } from './TextureCanvas'; + +/** Room kept for the "W × H px · N frames" readout, wide enough for a four-digit sheet. */ +const INFO_WIDTH = 190; + +/** + * The Texture tab: a picker for which texture is being worked on, the tools that act on the + * whole sheet, and the canvas the frames are drawn on. + * + * It follows the selection the way the graph editor does – selecting a texture asset (or one + * of its frames) shows it here – but it does not *force* the tab, and it keeps showing the + * last texture when the selection moves elsewhere. Otherwise clicking a sprite to check + * whether it wears the right frame would empty the tab you are working in. + */ +export class TextureEditor extends VStack { + private readonly canvas: TextureCanvas; + private readonly picker: Dropdown; + private readonly info: Label; + private readonly sliceButton: Button; + private readonly headerBg = new Graphics(); + + constructor(private readonly state: EditorState) { + super(); + const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); + header.minHeight = Theme.controlHeight + Theme.padding * 2; + header.addChildAt(this.headerBg, 0); + + header.add(new Label('Texture', { style: 'muted' })); + this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() }); + header.add(this.picker); + this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() }); + header.add(this.sliceButton); + header.add(new Button('Fit', { onClick: () => this.canvas.fit() })); + header.add(new Spacer(), 1); + this.info = new Label('', { style: 'small', align: 'right' }); + // Reserved rather than measured: the label is built empty, and a stack sizes it once from + // the text it had at the time – so without a floor the readout is clipped from then on. + this.info.minWidth = INFO_WIDTH; + header.add(this.info); + + this.canvas = new TextureCanvas(state); + this.add(header); + this.add(this.canvas, 1); + + state.on('selection', () => this.followSelection()); + state.on('project', () => this.refresh()); + state.on('load', () => { + this.canvas.show(''); + this.followSelection(); + }); + this.followSelection(); + } + + /** The texture currently shown – the check scripts read this. */ + get currentAssetId(): string { + return this.canvas.currentAssetId; + } + + /** Shows a texture explicitly (opening the tab for a freshly imported image). */ + show(assetId: string): void { + this.canvas.show(assetId); + this.refresh(); + } + + /** Where an image pixel of the shown sheet sits on screen – for the check scripts. */ + imageToScreen(x: number, y: number): { x: number; y: number } { + return this.canvas.imageToScreen(x, y); + } + + private followSelection(): void { + const id = this.state.selectedTextureId; + // Null merely means "the selection is about something else"; the tab keeps its subject. + if (id) this.canvas.show(id); + else if (!this.asset()) this.canvas.show(this.textures()[0]?.id ?? ''); + this.refresh(); + } + + private asset(): TextureAsset | null { + const asset = findAsset(this.state.project, this.canvas.currentAssetId); + return asset && asset.type === 'texture' ? asset : null; + } + + private textures(): TextureAsset[] { + return this.state.project.assets + .filter((a): a is TextureAsset => a.type === 'texture') + .sort((a, b) => a.name.localeCompare(b.name)); + } + + private openPicker(): void { + const items: MenuItem[] = this.textures().map((asset) => ({ + label: asset.id === this.canvas.currentAssetId ? `● ${asset.name}` : asset.name, + onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }), + })); + if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined }); + new Menu(items, { searchable: true }).openBelow(this.picker); + } + + private openSlice(): void { + const asset = this.asset(); + if (!asset) return; + new SliceDialog(asset, (options, replace) => { + this.state.execute(new SliceFramesCommand(asset.id, options, replace)); + }).openCentered(); + } + + private refresh(): void { + const asset = this.asset(); + this.picker.setText(asset ? asset.name : 'No texture'); + const count = asset?.frames.length ?? 0; + this.info.setText( + asset ? `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}` : '', + ); + // Slicing needs a known picture size; without one it could only fail with a notice. + this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0)); + } + + override redraw(): void { + super.redraw(); + this.headerBg.clear(); + const h = Theme.controlHeight + Theme.padding * 2; + this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader); + this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border); + } +} diff --git a/src/editor/texture/sheetView.ts b/src/editor/texture/sheetView.ts new file mode 100644 index 00000000..4ca58289 --- /dev/null +++ b/src/editor/texture/sheetView.ts @@ -0,0 +1,135 @@ +/** + * How the Texture tab maps image pixels onto the widget, and what a drag on a frame means. + * Pure geometry – no Pixi, no editor state – so the fiddly part of the editor (which handle + * did I grab, what does dragging it do) is unit-testable instead of only clickable. + */ +import type { FrameRect } from '../../core/model/texture'; +import type { Vec2 } from '../../core/model/Properties'; + +/** Image pixel `(0, 0)` sits at (`panX`, `panY`) in widget-local pixels, at `zoom` per pixel. */ +export interface SheetView { + panX: number; + panY: number; + zoom: number; +} + +export const MIN_SHEET_ZOOM = 0.05; +export const MAX_SHEET_ZOOM = 64; + +/** Fraction of the widget the picture is fitted into, leaving a margin to grab and pan by. */ +const FIT_MARGIN = 0.9; + +export function imageToLocal(view: SheetView, x: number, y: number): Vec2 { + return { x: view.panX + x * view.zoom, y: view.panY + y * view.zoom }; +} + +export function localToImage(view: SheetView, x: number, y: number): Vec2 { + return { x: (x - view.panX) / view.zoom, y: (y - view.panY) / view.zoom }; +} + +/** The view that centres the whole picture in a `w`×`h` widget. */ +export function fitView(imageWidth: number, imageHeight: number, w: number, h: number): SheetView { + if (imageWidth <= 0 || imageHeight <= 0 || w <= 0 || h <= 0) return { panX: 0, panY: 0, zoom: 1 }; + const zoom = clampZoom(Math.min(w / imageWidth, h / imageHeight) * FIT_MARGIN || 1); + return { + panX: (w - imageWidth * zoom) / 2, + panY: (h - imageHeight * zoom) / 2, + zoom, + }; +} + +export function clampZoom(zoom: number): number { + return Math.max(MIN_SHEET_ZOOM, Math.min(MAX_SHEET_ZOOM, zoom)); +} + +/** Zooms about a fixed widget-local point, so the pixel under the cursor stays under it. */ +export function zoomAbout(view: SheetView, factor: number, localX: number, localY: number): SheetView { + const zoom = clampZoom(view.zoom * factor); + const before = localToImage(view, localX, localY); + const after = { panX: localX - before.x * zoom, panY: localY - before.y * zoom, zoom }; + return after; +} + +/** + * Which part of a frame a point grabs: a corner or edge handle (`hx`/`hy` in −1/0/1, naming + * the edge each axis is pinned to), `'move'` anywhere else inside it, or null outside. + */ +export type FrameHandle = { hx: -1 | 0 | 1; hy: -1 | 0 | 1 } | 'move'; + +/** The nine anchor points of a rectangle; (0, 0) is its centre and is not a handle. */ +const HANDLES: { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] = [ + { hx: -1, hy: -1 }, + { hx: 1, hy: -1 }, + { hx: -1, hy: 1 }, + { hx: 1, hy: 1 }, + { hx: 0, hy: -1 }, + { hx: 0, hy: 1 }, + { hx: -1, hy: 0 }, + { hx: 1, hy: 0 }, +]; + +/** Where a handle sits, in image pixels. */ +export function handlePoint(rect: FrameRect, handle: { hx: number; hy: number }): Vec2 { + return { + x: rect.x + ((handle.hx + 1) / 2) * rect.w, + y: rect.y + ((handle.hy + 1) / 2) * rect.h, + }; +} + +export function frameHandles(): readonly { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] { + return HANDLES; +} + +/** + * What a press at `point` (image pixels) does to `rect`. `tolerance` is the grab radius in + * image pixels – the caller converts a constant screen distance, so handles stay equally easy + * to hit at every zoom. + * + * Corners are tested before edges so that at a small frame, where all handles overlap, the + * corner wins: a corner resizes both axes and is the one worth reaching for. + */ +export function handleAt(rect: FrameRect, point: Vec2, tolerance: number): FrameHandle | null { + for (const handle of HANDLES) { + const p = handlePoint(rect, handle); + if (Math.abs(point.x - p.x) <= tolerance && Math.abs(point.y - p.y) <= tolerance) return handle; + } + const inside = + point.x >= rect.x && point.x <= rect.x + rect.w && point.y >= rect.y && point.y <= rect.y + rect.h; + return inside ? 'move' : null; +} + +/** + * The rectangle a drag produces: `handle` decides which edges follow the pointer. The result + * may be inverted or stick out of the image – squaring it up is `normalizeFrameRect`'s job, + * so that a drag and a typed-in value are corrected by exactly one piece of code. + */ +export function dragFrame(start: FrameRect, handle: FrameHandle, dx: number, dy: number): FrameRect { + if (handle === 'move') return { ...start, x: start.x + dx, y: start.y + dy }; + const rect = { ...start }; + if (handle.hx === -1) { + rect.x += dx; + rect.w -= dx; + } else if (handle.hx === 1) { + rect.w += dx; + } + if (handle.hy === -1) { + rect.y += dy; + rect.h -= dy; + } else if (handle.hy === 1) { + rect.h += dy; + } + return rect; +} + +/** The rectangle spanned by two points, in either order. */ +export function rectBetween(a: Vec2, b: Vec2): FrameRect { + return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) }; +} + +/** The mouse cursor a handle asks for, so a resize edge announces itself before it is grabbed. */ +export function handleCursor(handle: FrameHandle): string { + if (handle === 'move') return 'move'; + if (handle.hx === 0) return 'ns-resize'; + if (handle.hy === 0) return 'ew-resize'; + return handle.hx === handle.hy ? 'nwse-resize' : 'nesw-resize'; +} diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 31eb5d4c..594a3eb2 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -3,11 +3,13 @@ * separate Pixi Text), and the editor symbols for cameras, behaviors, containers and * colliders. Everything here draws in world units at the item's origin. */ -import { type Graphics, Rectangle } from 'pixi.js'; +import { type Graphics, Matrix, Rectangle, type Texture } from 'pixi.js'; +import type { TextureRef } from '../core/model/texture'; import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds'; import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider'; import { Theme } from '../ui'; import type { DrawItem } from './items'; +import { frameFillMatrix } from './textureFill'; import type { TextureLibrary } from './TextureLibrary'; /** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */ @@ -32,10 +34,9 @@ export function drawItem( // Null while the picture is still loading, so the box shows its plain colour until then – // which is exactly what a sprite looked like before textures existed. No flicker, no // placeholder. With a picture the colour tints it (verified: white x 0x66aaff reads back - // as rgb(98,163,245)), and Pixi fits an unmatrixed texture fill to the shape's bounds, - // so nothing here has to compute a texture matrix. + // as rgb(98,163,245)). const texture = item.texture ? textures.get(item.texture) : null; - node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(texture ? { texture, color } : color); + node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(spriteFill(texture, color, item.texture)); return; } case 'camera': @@ -101,6 +102,26 @@ export function drawItem( } } +/** + * The fill a sprite's box takes: the plain colour while there is no picture, the picture + * tinted by that colour once there is one. + * + * Only a frame gets a matrix. An unmatrixed fill spreads the whole image over the shape, which + * is precisely what a whole-image sprite wants, and skipping the matrix there saves Pixi the + * inversion on every redraw of every such sprite. See `textureFill.ts` for why a frame cannot + * be drawn as a Pixi sub-texture instead. + */ +function spriteFill( + texture: Texture | null, + color: number, + ref: TextureRef | null, +): number | { texture: Texture; color: number; matrix?: Matrix } { + if (!texture || !ref) return color; + const m = ref.rect && frameFillMatrix(ref.rect, ref.sourceWidth, ref.sourceHeight); + if (!m) return { texture, color }; + return { texture, color, matrix: new Matrix(m.a, m.b, m.c, m.d, m.tx, m.ty) }; +} + /** Hit area covering only the outline band of a collider shape (local coordinates, world units). */ function colliderOutlineHitArea( shape: ColliderShape, diff --git a/src/viewport/textureFill.ts b/src/viewport/textureFill.ts new file mode 100644 index 00000000..02fbe938 --- /dev/null +++ b/src/viewport/textureFill.ts @@ -0,0 +1,48 @@ +import type { FrameRect } from '../core/model/texture'; + +/** The six numbers of a 2D affine matrix, in Pixi's `Matrix(a, b, c, d, tx, ty)` order. */ +export interface MatrixValues { + a: number; + b: number; + c: number; + d: number; + tx: number; + ty: number; +} + +/** + * The `matrix` a Graphics fill needs so that only `rect` of a `sourceWidth`×`sourceHeight` + * image covers the filled shape – how a sprite wears one frame of a sheet. + * + * Measured, not assumed. Two things about Pixi v8 make this the answer: + * + * - A `Texture` built with a `frame` reports the frame's size, but a Graphics **fill ignores + * it** and draws the whole source. Verified by reading back pixels: a fill with a 32×32 + * frame of a four-quadrant sheet showed all four quadrants. Sub-textures are therefore not + * a way to draw a frame. + * - `generateTextureMatrix` computes `uv = inverse(style.matrix) ∘ normalise(shapeBounds)`, + * where the normaliser maps the shape's bounding box onto the unit square. So an absent + * matrix spreads the whole image over the shape (which is why a whole-image sprite needs + * none), and to spread a sub-rectangle instead, `inverse(style.matrix)` has to be the map + * from the unit square onto that rectangle in UV space – making the matrix its inverse. + * + * Because the normaliser works off the shape's own bounds, this is independent of where the + * shape sits and how big it is; verified with an off-origin, non-square shape. + */ +export function frameFillMatrix( + rect: FrameRect, + sourceWidth: number, + sourceHeight: number, +): MatrixValues | null { + // Without a known image size the rectangle has no reference frame; drawing the whole + // picture is the honest answer, and it is what the sprite showed a moment ago anyway. + if (sourceWidth <= 0 || sourceHeight <= 0 || rect.w <= 0 || rect.h <= 0) return null; + return { + a: sourceWidth / rect.w, + b: 0, + c: 0, + d: sourceHeight / rect.h, + tx: -rect.x / rect.w, + ty: -rect.y / rect.h, + }; +} diff --git a/tests/core/frame-commands.test.ts b/tests/core/frame-commands.test.ts new file mode 100644 index 00000000..deaf94db --- /dev/null +++ b/tests/core/frame-commands.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands'; +import { CommandError } from '../../src/core/commands/Command'; +import { + AddFrameCommand, + RemoveFrameCommand, + RenameFrameCommand, + SetFrameRectCommand, + SliceFramesCommand, +} from '../../src/core/commands/frameCommands'; +import type { TextureAsset, TextureFrame } from '../../src/core/model/Asset'; +import { findAsset } from '../../src/core/model/Project'; +import { DEFAULT_SLICE, findFrame, type FrameRect } from '../../src/core/model/texture'; +import { makeProject } from '../helpers'; + +const FILE = `${'ab'.repeat(32)}.png`; +const GRID_2X2 = { ...DEFAULT_SLICE, columns: 2, rows: 2 }; + +/** A project holding one 64×64 texture, with lookups that survive undo replacing the asset. */ +function makeSheet(width = 64, height = 64) { + const fixture = makeProject(); + const create = new CreateTextureCommand('Sheet', { file: FILE, width, height }); + fixture.stack.execute(create); + const asset = () => findAsset(fixture.project, create.asset.id) as TextureAsset; + return { ...fixture, textureId: create.asset.id, asset, frames: () => asset().frames }; +} + +/** Adds a frame through the command stack and returns its id. */ +function addFrame(sheet: ReturnType, rect: FrameRect, name?: string): string { + const command = new AddFrameCommand(sheet.textureId, rect, name); + sheet.stack.execute(command); + return command.frameId; +} + +/** A detached copy of the frame list, to compare a state against after an undo. */ +function snapshot(frames: readonly TextureFrame[]): TextureFrame[] { + return frames.map((f) => ({ ...f })); +} + +describe('AddFrameCommand', () => { + it('appends a named frame and removes exactly that one again', () => { + const sheet = makeSheet(); + const first = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 }, 'Idle'); + const before = snapshot(sheet.frames()); + const second = addFrame(sheet, { x: 16, y: 0, w: 16, h: 16 }, 'Run'); + + expect(sheet.frames()).toEqual([ + { id: first, name: 'Idle', x: 0, y: 0, w: 16, h: 16 }, + { id: second, name: 'Run', x: 16, y: 0, w: 16, h: 16 }, + ]); + // The id is known before execution, so the editor can select the frame it just made. + expect(findFrame(sheet.project, second)?.frame.name).toBe('Run'); + + sheet.stack.undo(); + expect(sheet.frames()).toEqual(before); + sheet.stack.redo(); + expect(sheet.frames().map((f) => f.id)).toEqual([first, second]); + }); + + it('names frames apart instead of repeating one', () => { + const sheet = makeSheet(); + addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle'); + addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'Idle'); + addFrame(sheet, { x: 16, y: 0, w: 8, h: 8 }); + addFrame(sheet, { x: 24, y: 0, w: 8, h: 8 }); + + // Numbered from 2, like every other generated name in a document (see `nextAssetName`). + expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Idle 2', 'Frame', 'Frame 2']); + }); + + it('stores a fractional, inverted drag as whole pixels with a positive size', () => { + const sheet = makeSheet(); + // Dragging up and to the left in a zoomed view: negative extents and fractions. + addFrame(sheet, { x: 10.4, y: 5, w: -6.2, h: 9.9 }); + + expect(sheet.frames()[0]).toMatchObject({ x: 4, y: 5, w: 6, h: 10 }); + }); + + it('clamps a rectangle dragged past the edge into the image', () => { + const sheet = makeSheet(); + addFrame(sheet, { x: 50, y: 50, w: 40, h: 40 }); + addFrame(sheet, { x: -20, y: -5, w: 30, h: 30 }); + + expect(sheet.frames()[0]).toMatchObject({ x: 50, y: 50, w: 14, h: 14 }); + expect(sheet.frames()[1]).toMatchObject({ x: 0, y: 0, w: 10, h: 25 }); + }); + + it('rejects a texture that is not there, and does so in execute', () => { + const sheet = makeSheet(); + const command = new AddFrameCommand('nope', { x: 0, y: 0, w: 4, h: 4 }); + expect(() => sheet.stack.execute(command)).toThrow(CommandError); + expect(sheet.frames()).toHaveLength(0); + }); +}); + +describe('SetFrameRectCommand', () => { + it('moves a frame and puts the old rectangle back', () => { + const sheet = makeSheet(); + const id = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 }, 'Idle'); + const before = snapshot(sheet.frames()); + + sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, id, { x: 20.6, y: 30, w: 10, h: -8 })); + expect(sheet.frames()[0]).toMatchObject({ name: 'Idle', x: 21, y: 22, w: 10, h: 8 }); + + sheet.stack.undo(); + expect(sheet.frames()).toEqual(before); + sheet.stack.redo(); + expect(sheet.frames()[0]).toMatchObject({ x: 21, y: 22 }); + }); + + it('records the rectangle the drag started from, which the caller restores first', () => { + const sheet = makeSheet(); + const id = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 }); + const frame = sheet.frames()[0]!; + + // What a live preview does: write into the frame, then hand the command the previewed + // rectangle and put the starting one back before executing (see `SceneViewport`). + frame.x = 40; + const previewed = { x: frame.x, y: frame.y, w: frame.w, h: frame.h }; + frame.x = 0; + sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, id, previewed)); + + expect(sheet.frames()[0]).toMatchObject({ x: 40 }); + sheet.stack.undo(); + expect(sheet.frames()[0]).toMatchObject({ x: 0 }); + }); + + it('rejects a frame that is gone', () => { + const sheet = makeSheet(); + expect(() => + sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, 'nope', { x: 0, y: 0, w: 4, h: 4 })), + ).toThrow(CommandError); + }); +}); + +describe('RenameFrameCommand', () => { + it('renames with undo and keeps the name unique', () => { + const sheet = makeSheet(); + addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle'); + const second = addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'Run'); + const before = snapshot(sheet.frames()); + + sheet.stack.execute(new RenameFrameCommand(sheet.textureId, second, 'Walk')); + expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Walk']); + + sheet.stack.execute(new RenameFrameCommand(sheet.textureId, second, 'Idle')); + expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Idle 2']); + + sheet.stack.undo(); + sheet.stack.undo(); + expect(sheet.frames()).toEqual(before); + }); + + it('leaves a frame its own name to rename to', () => { + const sheet = makeSheet(); + const id = addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle'); + sheet.stack.execute(new RenameFrameCommand(sheet.textureId, id, 'Idle')); + expect(sheet.frames()[0]?.name).toBe('Idle'); + }); + + it('refuses a blank name instead of showing a nameless frame', () => { + const sheet = makeSheet(); + const id = addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle'); + expect(() => sheet.stack.execute(new RenameFrameCommand(sheet.textureId, id, ' '))).toThrow( + CommandError, + ); + expect(sheet.frames()[0]?.name).toBe('Idle'); + }); +}); + +describe('RemoveFrameCommand', () => { + it('puts the frame back at its original index, not at the end', () => { + const sheet = makeSheet(); + addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'A'); + const middle = addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'B'); + addFrame(sheet, { x: 16, y: 0, w: 8, h: 8 }, 'C'); + const before = snapshot(sheet.frames()); + + sheet.stack.execute(new RemoveFrameCommand(sheet.textureId, middle)); + expect(sheet.frames().map((f) => f.name)).toEqual(['A', 'C']); + + sheet.stack.undo(); + // The frame order is the order of the sheet, so appending it again would reshuffle it. + expect(sheet.frames()).toEqual(before); + sheet.stack.redo(); + expect(sheet.frames().map((f) => f.name)).toEqual(['A', 'C']); + }); + + it('rejects a frame that is not in the texture', () => { + const sheet = makeSheet(); + expect(() => sheet.stack.execute(new RemoveFrameCommand(sheet.textureId, 'nope'))).toThrow(CommandError); + }); +}); + +describe('SliceFramesCommand', () => { + it('cuts a grid into equal cells in reading order', () => { + const sheet = makeSheet(); + sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false)); + + expect(sheet.frames().map((f) => ({ x: f.x, y: f.y, w: f.w, h: f.h }))).toEqual([ + { x: 0, y: 0, w: 32, h: 32 }, + { x: 32, y: 0, w: 32, h: 32 }, + { x: 0, y: 32, w: 32, h: 32 }, + { x: 32, y: 32, w: 32, h: 32 }, + ]); + expect(sheet.frames().map((f) => f.name)).toEqual(['Frame 1', 'Frame 2', 'Frame 3', 'Frame 4']); + + sheet.stack.undo(); + expect(sheet.frames()).toEqual([]); + sheet.stack.redo(); + expect(sheet.frames()).toHaveLength(4); + }); + + it('keeps existing frames when adding, and replaces them when told to', () => { + const sheet = makeSheet(); + addFrame(sheet, { x: 1, y: 2, w: 8, h: 8 }, 'Hand-made'); + const before = snapshot(sheet.frames()); + + sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false)); + expect(sheet.frames()).toHaveLength(5); + expect(sheet.frames()[0]).toEqual(before[0]); + sheet.stack.undo(); + expect(sheet.frames()).toEqual(before); + + sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, true)); + expect(sheet.frames()).toHaveLength(4); + expect(sheet.frames().some((f) => f.name === 'Hand-made')).toBe(false); + + sheet.stack.undo(); + expect(sheet.frames()).toEqual(before); + sheet.stack.redo(); + expect(sheet.frames().map((f) => f.name)).toEqual(['Frame 1', 'Frame 2', 'Frame 3', 'Frame 4']); + }); + + it('gives a redone slice the same frame ids, so a sprite wearing one keeps its picture', () => { + const sheet = makeSheet(); + sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false)); + const ids = sheet.frames().map((f) => f.id); + + sheet.stack.undo(); + sheet.stack.redo(); + expect(sheet.frames().map((f) => f.id)).toEqual(ids); + }); + + it('numbers generated names around the ones already taken', () => { + const sheet = makeSheet(); + addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Frame 2'); + sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false)); + + expect(new Set(sheet.frames().map((f) => f.name)).size).toBe(5); + }); + + it('rejects a slice of an image whose size is unknown rather than doing nothing', () => { + // Width 0 is a texture whose upload has not been measured yet; slicing it would silently + // produce no frames at all. + const sheet = makeSheet(0, 0); + expect(() => sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false))).toThrow( + CommandError, + ); + expect(sheet.frames()).toEqual([]); + }); + + it('rejects a grid whose cells would be smaller than a pixel', () => { + const sheet = makeSheet(4, 4); + const tooFine = { ...DEFAULT_SLICE, columns: 8, rows: 8 }; + expect(() => sheet.stack.execute(new SliceFramesCommand(sheet.textureId, tooFine, true))).toThrow( + CommandError, + ); + expect(sheet.frames()).toEqual([]); + }); + + it('refuses an asset that is missing, and one that is not a texture', () => { + const sheet = makeSheet(); + const sprite = new CreateAssetCommand('sprite', 'Box'); + sheet.stack.execute(sprite); + + expect(() => sheet.stack.execute(new SliceFramesCommand('nope', GRID_2X2, false))).toThrow(CommandError); + expect(() => sheet.stack.execute(new SliceFramesCommand(sprite.asset.id, GRID_2X2, false))).toThrow( + /not a texture/, + ); + }); +}); diff --git a/tests/core/texture.test.ts b/tests/core/texture.test.ts index 2ec0b970..ec515e09 100644 --- a/tests/core/texture.test.ts +++ b/tests/core/texture.test.ts @@ -2,7 +2,15 @@ import { describe, expect, it } from 'vitest'; import { CreateTextureCommand } from '../../src/core/commands/assetCommands'; import type { TextureAsset } from '../../src/core/model/Asset'; import { findAsset, findPropertyUsages, findUsages } from '../../src/core/model/Project'; -import { isTextureFile, resolveTexture } from '../../src/core/model/texture'; +import { + DEFAULT_SLICE, + isTextureFile, + nextFrameNames, + resolveTexture, + sliceFrames, + textureRefIds, + uniqueFrameName, +} from '../../src/core/model/texture'; import { deserializeProject } from '../../src/core/store/serialize'; import { makeSpriteScene, place, roundTrip } from '../helpers'; @@ -43,9 +51,33 @@ describe('resolveTexture', () => { const sprite = findAsset(fixture.project, fixture.spriteId)!; sprite.props.texture = texture.id; - expect(resolveTexture(fixture.project, sprite.props)).toEqual({ file: FILE, smooth: true }); + // A whole-texture reference carries no rectangle: the renderer then needs no matrix at all. + const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48 }; + expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: true }); texture.props.filter = 'nearest'; - expect(resolveTexture(fixture.project, sprite.props)).toEqual({ file: FILE, smooth: false }); + expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: false }); + }); + + it('resolves a frame id to the rectangle it cuts, not to the whole picture', () => { + const fixture = makeSpriteScene(); + const texture = addTexture(fixture); + texture.frames.push({ id: 'f1', name: 'Idle', x: 16, y: 8, w: 32, h: 24 }); + const sprite = findAsset(fixture.project, fixture.spriteId)!; + + // The same property holds either kind of id, which is what keeps sprites written before + // frames existed meaning exactly what they meant. + sprite.props.texture = 'f1'; + expect(resolveTexture(fixture.project, sprite.props)).toEqual({ + file: FILE, + smooth: true, + rect: { x: 16, y: 8, w: 32, h: 24 }, + sourceWidth: 64, + sourceHeight: 48, + }); + + // A frame that is gone dangles like any other reference: no picture, not a wrong one. + texture.frames.length = 0; + expect(resolveTexture(fixture.project, sprite.props)).toBeNull(); }); it('yields nothing rather than throwing when the reference cannot be used', () => { @@ -127,6 +159,18 @@ describe('findPropertyUsages', () => { expect(findPropertyUsages(fixture.project, texture.id).some((u) => u.on === 'object')).toBe(true); }); + it('counts sprites wearing a frame of the texture, not just the texture itself', () => { + const fixture = makeSpriteScene(); + const texture = addTexture(fixture); + texture.frames.push({ id: 'f1', name: 'Idle', x: 0, y: 0, w: 8, h: 8 }); + findAsset(fixture.project, fixture.spriteId)!.props.texture = 'f1'; + + // Asking by the asset id alone under-reports exactly the sheets that were sliced – the + // ones with the most users – which is why deleting one asks about every id it owns. + expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(0); + expect(findPropertyUsages(fixture.project, textureRefIds(texture))).toHaveLength(1); + }); + it('ignores unrelated ids and an empty reference', () => { const fixture = makeSpriteScene(); const texture = addTexture(fixture); @@ -137,3 +181,67 @@ describe('findPropertyUsages', () => { expect(findPropertyUsages(fixture.project, '')).toHaveLength(0); }); }); + +describe('slicing a sheet', () => { + it('cuts a grid in reading order and derives the cell size from the picture', () => { + const rects = sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 2, rows: 2 }); + expect(rects).toEqual([ + { x: 0, y: 0, w: 32, h: 32 }, + { x: 32, y: 0, w: 32, h: 32 }, + { x: 0, y: 32, w: 32, h: 32 }, + { x: 32, y: 32, w: 32, h: 32 }, + ]); + }); + + it('accounts for an offset and for spacing between cells', () => { + // 2 columns with a 2 px border and 4 px between them: (64 − 2 − 4) / 2 = 29 px cells. + const rects = sliceFrames(64, 64, { + ...DEFAULT_SLICE, + columns: 2, + rows: 1, + offsetX: 2, + spacingX: 4, + }); + expect(rects).toEqual([ + { x: 2, y: 0, w: 29, h: 64 }, + { x: 35, y: 0, w: 29, h: 64 }, + ]); + }); + + it('refuses rather than guessing when the numbers cannot describe a grid', () => { + // An unmeasured picture, more cells than pixels, and the values a form can produce. + expect(sliceFrames(0, 0, DEFAULT_SLICE)).toEqual([]); + expect(sliceFrames(4, 4, { ...DEFAULT_SLICE, columns: 8, rows: 8 })).toEqual([]); + expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 0 })).toEqual([]); + // NaN passes every `<` comparison, so it has to be rejected in its own right. + expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: NaN })).toEqual([]); + expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, spacingX: NaN })).toEqual([]); + }); +}); + +describe('frame names', () => { + const sheet = (...names: string[]): TextureAsset => ({ + id: 't', + type: 'texture', + name: 'Sheet', + props: {}, + file: '', + width: 64, + height: 64, + frames: names.map((name, i) => ({ id: `f${i}`, name, x: 0, y: 0, w: 1, h: 1 })), + }); + + it('numbers a clashing name from 2, the way asset names are numbered', () => { + expect(uniqueFrameName(sheet(), 'Idle')).toBe('Idle'); + expect(uniqueFrameName(sheet('Idle'), 'Idle')).toBe('Idle 2'); + expect(uniqueFrameName(sheet('Idle', 'Idle 2'), 'Idle')).toBe('Idle 3'); + // A frame keeps its own name available, so renaming it to itself is not a clash. + expect(uniqueFrameName(sheet('Idle'), 'Idle', 'f0')).toBe('Idle'); + }); + + it('numbers a whole slice from 1 and steps over names already taken', () => { + expect(nextFrameNames(sheet(), 3)).toEqual(['Frame 1', 'Frame 2', 'Frame 3']); + // Re-slicing a sheet that keeps its frames must not append a number to a number. + expect(nextFrameNames(sheet('Frame 2'), 3)).toEqual(['Frame 1', 'Frame 3', 'Frame 4']); + }); +}); diff --git a/tests/editor/sheet-view.test.ts b/tests/editor/sheet-view.test.ts new file mode 100644 index 00000000..15844df5 --- /dev/null +++ b/tests/editor/sheet-view.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import type { FrameRect } from '../../src/core/model/texture'; +import { + dragFrame, + fitView, + type FrameHandle, + handleAt, + handleCursor, + handlePoint, + imageToLocal, + localToImage, + MAX_SHEET_ZOOM, + MIN_SHEET_ZOOM, + rectBetween, + zoomAbout, +} from '../../src/editor/texture/sheetView'; + +const RECT: FrameRect = { x: 10, y: 20, w: 40, h: 30 }; + +describe('sheet view mapping', () => { + it('maps image pixels to the widget and back', () => { + const view = { panX: 100, panY: 50, zoom: 2 }; + expect(imageToLocal(view, 0, 0)).toEqual({ x: 100, y: 50 }); + expect(imageToLocal(view, 8, 4)).toEqual({ x: 116, y: 58 }); + // Round trip: the two are inverses, which is what lets a drag be computed in either space. + expect(localToImage(view, 116, 58)).toEqual({ x: 8, y: 4 }); + }); + + it('fits a picture centred, with the same margin on both axes', () => { + // A 100x50 picture in a 400x400 widget is limited by its width. + const view = fitView(100, 50, 400, 400); + expect(view.zoom).toBeCloseTo(3.6); + expect(view.panX).toBeCloseTo((400 - 100 * 3.6) / 2); + expect(view.panY).toBeCloseTo((400 - 50 * 3.6) / 2); + // The whole picture is inside the widget, which is the only thing "fit" has to guarantee. + const end = imageToLocal(view, 100, 50); + expect(view.panX).toBeGreaterThanOrEqual(0); + expect(end.x).toBeLessThanOrEqual(400); + expect(end.y).toBeLessThanOrEqual(400); + }); + + it('has a defined answer for a picture that has not been measured', () => { + expect(fitView(0, 0, 400, 400)).toEqual({ panX: 0, panY: 0, zoom: 1 }); + expect(fitView(100, 50, 0, 0)).toEqual({ panX: 0, panY: 0, zoom: 1 }); + }); + + it('keeps the pixel under the cursor under the cursor while zooming', () => { + const view = { panX: 100, panY: 50, zoom: 2 }; + const before = localToImage(view, 300, 260); + const zoomed = zoomAbout(view, 1.5, 300, 260); + expect(zoomed.zoom).toBeCloseTo(3); + expect(localToImage(zoomed, 300, 260).x).toBeCloseTo(before.x); + expect(localToImage(zoomed, 300, 260).y).toBeCloseTo(before.y); + }); + + it('clamps the zoom at both ends', () => { + const view = { panX: 0, panY: 0, zoom: 1 }; + expect(zoomAbout(view, 1e6, 0, 0).zoom).toBe(MAX_SHEET_ZOOM); + expect(zoomAbout(view, 1e-6, 0, 0).zoom).toBe(MIN_SHEET_ZOOM); + }); +}); + +describe('grabbing a frame', () => { + it('finds each of the eight handles at its own corner or edge', () => { + expect(handlePoint(RECT, { hx: -1, hy: -1 })).toEqual({ x: 10, y: 20 }); + expect(handlePoint(RECT, { hx: 1, hy: 1 })).toEqual({ x: 50, y: 50 }); + expect(handlePoint(RECT, { hx: 0, hy: -1 })).toEqual({ x: 30, y: 20 }); + expect(handleAt(RECT, { x: 10, y: 20 }, 2)).toEqual({ hx: -1, hy: -1 }); + expect(handleAt(RECT, { x: 50, y: 20 }, 2)).toEqual({ hx: 1, hy: -1 }); + expect(handleAt(RECT, { x: 30, y: 50 }, 2)).toEqual({ hx: 0, hy: 1 }); + }); + + it('grabs the inside as a move and misses outside entirely', () => { + expect(handleAt(RECT, { x: 30, y: 35 }, 2)).toBe('move'); + expect(handleAt(RECT, { x: 5, y: 35 }, 2)).toBeNull(); + expect(handleAt(RECT, { x: 30, y: 60 }, 2)).toBeNull(); + }); + + it('prefers a corner where handles overlap, so a tiny frame stays resizable', () => { + // A 4x4 frame with a 6 px tolerance: every handle matches the centre of the frame, and + // grabbing it must resize rather than move, or a small frame could never be adjusted. + const tiny: FrameRect = { x: 0, y: 0, w: 4, h: 4 }; + expect(handleAt(tiny, { x: 2, y: 2 }, 6)).toEqual({ hx: -1, hy: -1 }); + }); + + it('names a cursor for every handle', () => { + expect(handleCursor('move')).toBe('move'); + expect(handleCursor({ hx: -1, hy: -1 })).toBe('nwse-resize'); + expect(handleCursor({ hx: 1, hy: -1 })).toBe('nesw-resize'); + expect(handleCursor({ hx: 0, hy: 1 })).toBe('ns-resize'); + expect(handleCursor({ hx: -1, hy: 0 })).toBe('ew-resize'); + }); +}); + +describe('dragging a frame', () => { + it('moves without changing the size', () => { + expect(dragFrame(RECT, 'move', 5, -7)).toEqual({ x: 15, y: 13, w: 40, h: 30 }); + }); + + it('pins the opposite edge when a corner is dragged', () => { + // Dragging the top-left corner right and down shrinks the frame; the bottom-right stays. + const dragged = dragFrame(RECT, { hx: -1, hy: -1 }, 4, 6); + expect(dragged).toEqual({ x: 14, y: 26, w: 36, h: 24 }); + expect(dragged.x + dragged.w).toBe(RECT.x + RECT.w); + expect(dragged.y + dragged.h).toBe(RECT.y + RECT.h); + }); + + it('moves only its own axis when an edge is dragged', () => { + expect(dragFrame(RECT, { hx: 0, hy: 1 }, 9, 5)).toEqual({ x: 10, y: 20, w: 40, h: 35 }); + expect(dragFrame(RECT, { hx: 1, hy: 0 }, 9, 5)).toEqual({ x: 10, y: 20, w: 49, h: 30 }); + }); + + it('is allowed to invert – squaring that up belongs to one place only', () => { + // Pulling the left edge past the right one yields a negative width on purpose: + // `normalizeFrameRect` corrects it, so a drag and a typed value are corrected identically. + const dragged = dragFrame(RECT, { hx: -1, hy: 0 }, 60, 0); + expect(dragged.w).toBeLessThan(0); + }); + + it('spans a rectangle between two points in either order', () => { + const forward = rectBetween({ x: 10, y: 20 }, { x: 30, y: 50 }); + expect(forward).toEqual({ x: 10, y: 20, w: 20, h: 30 }); + expect(rectBetween({ x: 30, y: 50 }, { x: 10, y: 20 })).toEqual(forward); + }); + + it('walks a frame through the eight handles back to where it started', () => { + // Each handle dragged by (d) and then by (−d) is the identity; a sign error in any one of + // the eight branches shows up here without needing eight separate expectations. + const handles: FrameHandle[] = [ + 'move', + { hx: -1, hy: -1 }, + { hx: 1, hy: -1 }, + { hx: -1, hy: 1 }, + { hx: 1, hy: 1 }, + { hx: 0, hy: -1 }, + { hx: 0, hy: 1 }, + { hx: -1, hy: 0 }, + { hx: 1, hy: 0 }, + ]; + for (const handle of handles) + expect(dragFrame(dragFrame(RECT, handle, 3, 4), handle, -3, -4)).toEqual(RECT); + }); +}); diff --git a/tests/viewport/texture-fill.test.ts b/tests/viewport/texture-fill.test.ts new file mode 100644 index 00000000..def83f33 --- /dev/null +++ b/tests/viewport/texture-fill.test.ts @@ -0,0 +1,104 @@ +/** + * The fill matrix is what makes a sprite wear one frame of a sheet, and it is only ever seen + * as pixels on a canvas – so it is pinned here against the identity its doc comment states, + * `uv = inverse(matrix) ∘ normalise(shapeBounds)`, rather than against the formula it uses. + * The checks therefore invert the matrix themselves and ask where the corners of the shape + * land in the image; that is an independent statement of what the renderer must achieve. + */ +import { describe, expect, it } from 'vitest'; +import type { FrameRect } from '../../src/core/model/texture'; +import { frameFillMatrix, type MatrixValues } from '../../src/viewport/textureFill'; + +/** + * Where the shape point `(u, v)` – normalised, so (0,0) is the shape's top-left corner and + * (1,1) its bottom-right – samples the image, in UV coordinates over the whole picture. + * This is Pixi's `inverse(style.matrix)` applied to the normalised point, written out for a + * general affine matrix so the test does not assume the axis-aligned shape of the real one. + */ +function sampleAt(m: MatrixValues, u: number, v: number): { u: number; v: number } { + const det = m.a * m.d - m.b * m.c; + const dx = u - m.tx; + const dy = v - m.ty; + return { u: (m.d * dx - m.c * dy) / det, v: (m.a * dy - m.b * dx) / det }; +} + +/** Asserts that the shape's four corners sample exactly the four corners of `rect`. */ +function expectCovers(m: MatrixValues, rect: FrameRect, sourceWidth: number, sourceHeight: number): void { + const left = rect.x / sourceWidth; + const right = (rect.x + rect.w) / sourceWidth; + const top = rect.y / sourceHeight; + const bottom = (rect.y + rect.h) / sourceHeight; + for (const [u, v, expectedU, expectedV] of [ + [0, 0, left, top], + [1, 0, right, top], + [0, 1, left, bottom], + [1, 1, right, bottom], + ] as const) { + const got = sampleAt(m, u, v); + expect(got.u).toBeCloseTo(expectedU, 12); + expect(got.v).toBeCloseTo(expectedV, 12); + } +} + +/** + * Compares the six numbers one by one instead of the object as a whole: a frame sitting on the + * top or left edge produces a negative zero, which `toEqual` counts as a different value. + */ +function expectMatrix(m: MatrixValues, expected: MatrixValues): void { + for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const) expect(m[key]).toBeCloseTo(expected[key], 12); +} + +describe('frameFillMatrix', () => { + it('declines when the image size is unknown, so the sprite keeps showing the whole picture', () => { + const rect = { x: 0, y: 0, w: 32, h: 32 }; + // What an upload that has not reported its size yet looks like in the model. + expect(frameFillMatrix(rect, 0, 0)).toBeNull(); + expect(frameFillMatrix(rect, 64, 0)).toBeNull(); + expect(frameFillMatrix(rect, -64, 64)).toBeNull(); + }); + + it('declines for a frame with no area, which cannot be spread over anything', () => { + expect(frameFillMatrix({ x: 0, y: 0, w: 0, h: 32 }, 64, 64)).toBeNull(); + expect(frameFillMatrix({ x: 0, y: 0, w: 32, h: 0 }, 64, 64)).toBeNull(); + }); + + it('maps the shape onto the top-right quadrant of a four-quadrant sheet', () => { + const rect = { x: 32, y: 0, w: 32, h: 32 }; + const m = frameFillMatrix(rect, 64, 64)!; + + // Half the sheet across, so the shape has to be stretched twofold in both directions, and + // shifted by one shape-width to start at the sheet's midpoint. + expectMatrix(m, { a: 2, b: 0, c: 0, d: 2, tx: -1, ty: 0 }); + expectCovers(m, rect, 64, 64); + }); + + it('stretches the axes independently for a non-square frame', () => { + // A column strip: an eighth of the sheet wide and its full height. + const rect = { x: 0, y: 0, w: 16, h: 64 }; + const m = frameFillMatrix(rect, 128, 64)!; + + expectMatrix(m, { a: 8, b: 0, c: 0, d: 1, tx: 0, ty: 0 }); + expectCovers(m, rect, 128, 64); + }); + + it('offsets in frame-widths, not in pixels or UV', () => { + // A small frame well inside a large sheet: three frame-widths from the left, two frame + // heights down. This is the case that catches an offset expressed in the wrong unit, + // because pixels, UV and frame-widths all differ here. + const rect = { x: 48, y: 16, w: 16, h: 8 }; + const m = frameFillMatrix(rect, 256, 128)!; + + expectMatrix(m, { a: 16, b: 0, c: 0, d: 16, tx: -3, ty: -2 }); + expectCovers(m, rect, 256, 128); + }); + + it('is the identity for a frame covering the whole sheet', () => { + // Which is the reason a whole-image sprite is drawn with no matrix at all: the matrix it + // would get says nothing, and an absent one is what Pixi is fastest at. + const rect = { x: 0, y: 0, w: 200, h: 100 }; + const m = frameFillMatrix(rect, 200, 100)!; + + expectMatrix(m, { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 }); + expectCovers(m, rect, 200, 100); + }); +}); diff --git a/tools/e2e/13-frames.mjs b/tools/e2e/13-frames.mjs new file mode 100644 index 00000000..77a8923f --- /dev/null +++ b/tools/e2e/13-frames.mjs @@ -0,0 +1,197 @@ +/** + * The Texture tab: cutting frames out of a sheet, and a sprite wearing one. + * + * Runs after the texture step, whose four-quadrant 64x48 picture is what makes this checkable + * at all – a frame over one quadrant has a colour of its own, so "which part of the sheet is + * on screen" is a question `pixelAt` can answer. Nothing here is stubbed: the frame is made by + * dragging on the canvas, and the sprite is read back off the rendered pixels. + */ + +/** How far apart two `#rrggbb` colours are, summed over the channels. */ +function colourDistance(a, b) { + const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + const [ar, ag, ab] = channels(a); + const [br, bg, bb] = channels(b); + return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb); +} + +/** The four quadrants of the sheet the texture step pastes, plus the sprite's own white. */ +const PALETTE = { + red: '#e63c3c', + green: '#3cc85a', + blue: '#466ef0', + white: '#f5f5f5', + /** + * The sprite's own colour for the fallback check, deliberately a colour the sheet does not + * contain. The sheet's fourth quadrant is near-white, so leaving the sprite white would make + * "it fell back to its colour" and "it is showing that quadrant" the same measurement. + */ + plain: '#ff00ff', +}; + +/** + * Which of the known colours a sampled pixel is: the nearest one by channel distance. + * + * Asked this way rather than "is it exactly green" on purpose. The scene carries editor gizmos + * by this point – a collider's fill is drawn over its body at 12 % alpha – so a sample can be + * tinted by a few percent without anything being wrong. That shifts a colour slightly; it + * cannot make one quadrant of the sheet read as another, which is the only question here. + */ +function nearestColour(sample) { + let best = ''; + let bestDistance = Infinity; + for (const [name, hex] of Object.entries(PALETTE)) { + const distance = colourDistance(sample, hex); + if (distance < bestDistance) { + bestDistance = distance; + best = name; + } + } + return best; +} + +export async function frames(t) { + const { check, sleep, json, evaluate, shot, clickText, drag, press, undo } = t; + + // 20. The tab exists and shows the texture the selection points at. + await press('Escape'); + await evaluate('__gm.state.select(null)'); + const texture = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''"); + await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(texture)} })`); + await clickText('Texture'); + await sleep(600); + check('the Texture tab opens', (await json('__gm.tab()')) === 'texture'); + check( + 'and shows the selected sheet', + (await json('__gm.texture.current()')) === texture, + `${await json('__gm.texture.current()')} vs ${texture}`, + ); + await shot('33-texture-tab'); + + // 21. Dragging on the picture cuts a frame. The drag is aimed at the top-right quadrant of + // the sheet (32,0)-(64,24), inset by a pixel so the press cannot land on the edge of a + // neighbouring frame and be read as a resize instead of a new rectangle. + const from = await json('__gm.texture.pointAt(33, 1)'); + const to = await json('__gm.texture.pointAt(63, 23)'); + check('the sheet is on screen', Boolean(from) && Boolean(to), JSON.stringify({ from, to })); + await drag(from.x, from.y, to.x, to.y); + await sleep(500); + + const cut = await json( + `(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []`, + ); + check('dragging on the sheet cuts a frame', cut.length === 1, JSON.stringify(cut)); + const frame = cut[0] ?? {}; + check( + 'the frame lands on the quadrant it was dragged over', + frame.x >= 32 && frame.y >= 0 && frame.x + frame.w <= 64 && frame.y + frame.h <= 48, + JSON.stringify(frame), + ); + check( + 'and is stored in whole pixels', + Number.isInteger(frame.x) && Number.isInteger(frame.y) && Number.isInteger(frame.w), + JSON.stringify(frame), + ); + check( + 'cutting a frame selects it, so the inspector edits what was just made', + (await json('__gm.state.selection?.kind')) === 'frame', + JSON.stringify(await json('__gm.state.selection')), + ); + await shot('34-frame-cut'); + + // 21a. It is one undo step, not one per pointer move. + await undo(); + await sleep(400); + check( + 'undo removes the frame in one step', + (await json( + `((__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []).length`, + )) === 0, + ); + await t.redo(); + await sleep(400); + + // 22. A sprite wearing the frame shows that part of the sheet and nothing else. This is the + // check the whole feature exists for: the model difference between "whole picture" and + // "this frame" is one id, so only the drawn pixels can tell them apart. + // The sprite from the texture step is reused, white and unparented, but its texture + // reference was left dangling there – it is pointed at the frame here. + await evaluate(`(() => { + const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite'); + sprite.props.color = 0xffffff; + sprite.props.texture = ${JSON.stringify(frame.id)}; + __gm.state.preview(); + })()`); + await evaluate("__gm.showTab('scene')"); + await sleep(900); + + const box = await json("__gm.objectRect('otex')"); + check('the textured sprite is on screen again', Boolean(box) && box.w > 8, JSON.stringify(box)); + if (box) { + const at = async (fx, fy) => + json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`); + const corners = [await at(-0.3, -0.3), await at(0.3, -0.3), await at(-0.3, 0.3), await at(0.3, 0.3)]; + const read = corners.map(nearestColour); + // Without the frame these four corners read red / green / blue / white – that is what the + // texture step asserts one screen earlier. Reading green four times is therefore the exact + // statement that the fill was confined to one quadrant of the sheet. + check( + 'the whole sprite shows the one quadrant the frame cuts out', + read.every((c) => c === 'green'), + JSON.stringify({ corners, read }), + ); + await shot('35-sprite-wearing-a-frame'); + } + + // 23. Slicing offers a live count before it commits – the readout is what makes the numbers + // answerable without undoing. + // Repainted magenta before slicing: with the frame gone the sprite falls back to this, and + // magenta is nowhere in the sheet, so the fallback cannot be confused with a part of it. + await evaluate(`(() => { + __gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0xff00ff; + __gm.state.preview(); + })()`); + await evaluate("__gm.showTab('texture')"); + await sleep(400); + await clickText('Slice…'); + await sleep(500); + const dialog = await json('__gm.overlayTexts()'); + check( + 'the slice dialog states what the numbers would produce', + dialog.some((text) => /^\d+ frames of \d+ × \d+ px$/.test(text)), + JSON.stringify(dialog), + ); + check( + 'and names the picture size it is cutting', + dialog.some((text) => text === 'Picture is 64 × 48 px'), + JSON.stringify(dialog), + ); + await clickText('Slice'); + await sleep(700); + + const sliced = await json( + `((__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []).map((f) => f.name + ' ' + f.w + 'x' + f.h)`, + ); + check( + 'slicing 4x4 replaces the frames with sixteen 16x12 cells', + sliced.length === 16 && sliced.every((s) => s.endsWith('16x12')), + JSON.stringify(sliced.slice(0, 3)), + ); + check('and numbers them in reading order', sliced[0]?.startsWith('Frame 1 '), JSON.stringify(sliced[0])); + await shot('36-sliced-sheet'); + + // 23a. The sprite pointed at the frame that slicing removed falls back to its colour rather + // than to a wrong part of the picture. + await evaluate("__gm.showTab('scene')"); + await sleep(700); + if (box) { + const fallback = await json( + `__gm.pixelAt(${Math.round(box.x + 0.3 * box.w)}, ${Math.round(box.y + 0.3 * box.h)})`, + ); + check( + 'a sprite whose frame was sliced away falls back to its plain colour', + nearestColour(fallback) === 'plain', + JSON.stringify({ fallback, read: nearestColour(fallback) }), + ); + } +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 718b45ac..bb33ebdf 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -15,6 +15,7 @@ import { containers } from './09-containers.mjs'; import { textAndEnabled } from './10-text-and-enabled.mjs'; import { menus } from './11-menus.mjs'; import { textures } from './12-textures.mjs'; +import { frames } from './13-frames.mjs'; export const STEPS = [ hierarchy, @@ -29,4 +30,5 @@ export const STEPS = [ textAndEnabled, menus, textures, + frames, ];