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>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 14:27:23 +02:00
1 parent 5a6af74e44
commit 35a1a23af8
29 files changed
+2650 -53

No files matched your search

+135
View File
@@ -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';
}