Files
feuerwerk/tests/editor/sheet-view.test.ts
T
rootandClaude Opus 5 35a1a23af8 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 <noreply@anthropic.com>
2026-09-08 14:27:23 +02:00

144 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});