Files
adminandClaude Opus 5 be8f2331de
game-maker / suite (push) Successful in 6m21s
A frame that keeps its corners can be stretched to any size (#41)
TextureFrame carries optional `border` (left/right/top/bottom) and
`fill` (stretch | repeat); absent keys mean "not sliced", so no format
bump and undo restores the plain frame instead of four zeroes.

The sliced sprite builds its own geometry instead of the shared
UNIT_QUAD: a 4x4 grid, and for `repeat` one quad per tile carrying the
frame cell's real UVs -- tiling in the geometry, not in the sampler, so
a frame out of a sheet cannot pull its neighbour in. Above 64 tiles per
axis it stretches, to bound the buffer.

A sprite narrower than left+right scales both borders down together
until the middle is exactly 0, so the corners meet at a seam and never
overlap.

The texture editor gets four draggable border lines plus the same four
numbers in the inspector. Hit order is resize handles, then the lines,
then the frame body -- handleAt reports 'move' anywhere inside a frame,
so lines placed after it were unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:04:09 +02:00

174 lines
7.2 KiB
TypeScript
Raw Permalink 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 {
borderAt,
borderLine,
dragBorder,
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 });
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);
// Fitting only has to guarantee that the whole picture is inside the widget.
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 its centre, and a grab there
// must resize, or the 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', () => {
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', () => {
// The negative width is on purpose: `normalizeFrameRect` corrects a drag and a typed
// value 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', () => {
// A sign error in any of the eight branches breaks the drag-and-back identity.
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);
});
});
describe('nine-slice border lines', () => {
const BORDER = { left: 4, top: 6, right: 8, bottom: 10 };
it('runs each line beside its own edge, the far ones measured from the far side', () => {
expect(borderLine(RECT, BORDER, 'left')).toEqual({ x1: 14, y1: 20, x2: 14, y2: 50 });
expect(borderLine(RECT, BORDER, 'right')).toEqual({ x1: 42, y1: 20, x2: 42, y2: 50 });
expect(borderLine(RECT, BORDER, 'top')).toEqual({ x1: 10, y1: 26, x2: 50, y2: 26 });
expect(borderLine(RECT, BORDER, 'bottom')).toEqual({ x1: 10, y1: 40, x2: 50, y2: 40 });
});
it('grabs a line only near it and only within the frame', () => {
expect(borderAt(RECT, BORDER, { x: 14, y: 35 }, 2)).toBe('left');
expect(borderAt(RECT, BORDER, { x: 30, y: 40 }, 2)).toBe('bottom');
expect(borderAt(RECT, BORDER, { x: 30, y: 35 }, 2)).toBeNull();
// Far outside the frame the lines are not offered, however close the axis would be.
expect(borderAt(RECT, BORDER, { x: 14, y: 200 }, 2)).toBeNull();
});
it('moves the near border with the pointer and the far one against it', () => {
// Both grow when the pointer goes towards the middle, which is what the eye expects of a line
// that is dragged inwards.
expect(dragBorder(BORDER, 'left', 3, 0).left).toBe(7);
expect(dragBorder(BORDER, 'right', -3, 0).right).toBe(11);
expect(dragBorder(BORDER, 'top', 0, 3).top).toBe(9);
expect(dragBorder(BORDER, 'bottom', 0, -3).bottom).toBe(13);
// And each line moves only its own border.
expect(dragBorder(BORDER, 'left', 3, 3)).toEqual({ ...BORDER, left: 7 });
});
});