A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets frames be drawn on it: drag to create, move, resize via eight handles, edit name and rect in the inspector, Delete to remove. Every change is a command, so one gesture is one undo step. "Slice..." cuts a grid from columns and rows with optional offset and spacing, previewing the resulting frame count and cell size live. Sprites pick a frame instead of the whole texture through the same `texture` property: it now holds either a texture id or a frame id. Frame ids come from the same generator and are document-wide unique, so one picker, one property and one set of instance overrides cover both cases, and every sprite written before this still means the same thing. A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image, because it derives its uvs from the shape bounds and the inverted style matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which keeps a sheet at a single GPU upload no matter how many of its frames are on screen at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
6.0 KiB
TypeScript
144 lines
6.0 KiB
TypeScript
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);
|
||
});
|
||
});
|