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:
1 parent
5a6af74e44
commit
35a1a23af8
29 files changed
+2650
-53
No files matched your search
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user