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,115 @@
|
||||
import type { TextureAsset } from '../../core/model/Asset';
|
||||
import { DEFAULT_SLICE, type SliceOptions, sliceFrames } from '../../core/model/texture';
|
||||
import { Button, Checkbox, HStack, Label, NumberField, Popup, Spacer, Theme, type Widget } from '../../ui';
|
||||
|
||||
/** Width of the label column, matching the inspector's so the two read as one language. */
|
||||
const LABEL_WIDTH = 76;
|
||||
const FIELD_WIDTH = 76;
|
||||
|
||||
/**
|
||||
* Cuts a sheet into a grid. Asks for the number of columns and rows rather than a cell size:
|
||||
* everyone knows how many tiles their sheet has, and deriving the cell size from the picture
|
||||
* cannot disagree with it.
|
||||
*
|
||||
* The dialog states, live, how many frames the current numbers would produce and how big each
|
||||
* one is. That readout is the whole point – it turns "did I mean 8×4 or 4×8" from something
|
||||
* you find out by undoing into something you see before committing.
|
||||
*/
|
||||
export class SliceDialog extends Popup {
|
||||
private readonly options: SliceOptions = { ...DEFAULT_SLICE };
|
||||
private replace = true;
|
||||
private readonly summary: Label;
|
||||
private readonly confirm: Button;
|
||||
|
||||
constructor(
|
||||
private readonly asset: TextureAsset,
|
||||
onSlice: (options: SliceOptions, replace: boolean) => void,
|
||||
) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.minWidth = 340;
|
||||
this.content.add(new Label(`Slice "${asset.name}" into a grid`, { style: 'bodyBold' }));
|
||||
this.content.add(new Label(`Picture is ${asset.width} × ${asset.height} px`, { style: 'muted' }));
|
||||
|
||||
this.content.add(this.pair('Columns', 'columns', 1, 'Rows', 'rows', 1));
|
||||
this.content.add(this.pair('Offset X', 'offsetX', 0, 'Offset Y', 'offsetY', 0));
|
||||
this.content.add(this.pair('Spacing X', 'spacingX', 0, 'Spacing Y', 'spacingY', 0));
|
||||
|
||||
const replaceRow = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
replaceRow.minHeight = Theme.rowHeight;
|
||||
const box = new Checkbox(this.replace);
|
||||
box.onChange = (value) => {
|
||||
this.replace = Boolean(value);
|
||||
};
|
||||
replaceRow.add(box);
|
||||
replaceRow.add(new Label('Replace the frames this texture already has', { style: 'muted' }));
|
||||
this.content.add(replaceRow);
|
||||
|
||||
this.summary = new Label('', { style: 'muted' });
|
||||
this.content.add(this.summary);
|
||||
|
||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
buttons.add(new Spacer(), 1);
|
||||
const cancel = new Button('Cancel');
|
||||
cancel.onClick = this.closeThen();
|
||||
buttons.add(cancel);
|
||||
this.confirm = new Button('Slice', { variant: 'primary' });
|
||||
this.confirm.onClick = this.closeThen(() => onSlice({ ...this.options }, this.replace));
|
||||
buttons.add(this.confirm);
|
||||
this.content.add(buttons);
|
||||
|
||||
this.refreshSummary();
|
||||
}
|
||||
|
||||
/** Two numeric fields on one row – the grid's parameters come in x/y pairs. */
|
||||
private pair(
|
||||
labelA: string,
|
||||
keyA: keyof SliceOptions,
|
||||
minA: number,
|
||||
labelB: string,
|
||||
keyB: keyof SliceOptions,
|
||||
minB: number,
|
||||
): Widget {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(this.labelFor(labelA));
|
||||
row.add(this.fieldFor(keyA, minA));
|
||||
row.add(new Spacer(Theme.padding));
|
||||
row.add(this.labelFor(labelB));
|
||||
row.add(this.fieldFor(keyB, minB));
|
||||
row.add(new Spacer(), 1);
|
||||
return row;
|
||||
}
|
||||
|
||||
private labelFor(text: string): Label {
|
||||
const label = new Label(text, { style: 'muted' });
|
||||
label.minWidth = LABEL_WIDTH;
|
||||
return label;
|
||||
}
|
||||
|
||||
private fieldFor(key: keyof SliceOptions, min: number): NumberField {
|
||||
const field = new NumberField(this.options[key], {
|
||||
min,
|
||||
step: 1,
|
||||
onChange: (value) => {
|
||||
this.options[key] = Math.round(Number(value));
|
||||
this.refreshSummary();
|
||||
},
|
||||
});
|
||||
field.minWidth = FIELD_WIDTH;
|
||||
return field;
|
||||
}
|
||||
|
||||
/** Live readout of what the current numbers would do, including the refusal case. */
|
||||
private refreshSummary(): void {
|
||||
const rects = sliceFrames(this.asset.width, this.asset.height, this.options);
|
||||
const first = rects[0];
|
||||
this.summary.setText(
|
||||
first
|
||||
? `${rects.length} frames of ${first.w} × ${first.h} px`
|
||||
: 'These numbers do not fit the picture – no frames.',
|
||||
);
|
||||
this.summary.setStyle(first ? 'muted' : 'body');
|
||||
this.confirm.setEnabled(rects.length > 0);
|
||||
this.refreshLayout();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import {
|
||||
Container,
|
||||
type FederatedPointerEvent,
|
||||
type FederatedWheelEvent,
|
||||
Graphics,
|
||||
Sprite,
|
||||
Text,
|
||||
} from 'pixi.js';
|
||||
import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands';
|
||||
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/model/texture';
|
||||
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import type { EditorState } from '../state';
|
||||
import {
|
||||
dragFrame,
|
||||
fitView,
|
||||
type FrameHandle,
|
||||
frameHandles,
|
||||
handleAt,
|
||||
handleCursor,
|
||||
handlePoint,
|
||||
imageToLocal,
|
||||
localToImage,
|
||||
rectBetween,
|
||||
type SheetView,
|
||||
zoomAbout,
|
||||
} from './sheetView';
|
||||
|
||||
/** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */
|
||||
const HANDLE_SIZE = 8;
|
||||
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
|
||||
const DRAG_THRESHOLD = 3;
|
||||
/** Side of one square of the transparency checkerboard, in screen pixels. */
|
||||
const CHECKER_SIZE = 8;
|
||||
const CHECKER_DARK = 0x1a1c21;
|
||||
const CHECKER_LIGHT = 0x24272e;
|
||||
/** Below this on-screen frame size the name is illegible clutter and is left out. */
|
||||
const MIN_LABEL_SIZE = 28;
|
||||
/** Inset of a frame's name plate from the frame's top-left corner, and its own padding. */
|
||||
const LABEL_INSET = 3;
|
||||
const LABEL_PAD = 2;
|
||||
|
||||
/** A drag of an existing frame: which one, from what, and by which handle. */
|
||||
interface FrameDrag {
|
||||
frameId: string;
|
||||
handle: FrameHandle;
|
||||
start: FrameRect;
|
||||
pointer: Vec2;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** A new frame being spanned on empty space (image pixels; nothing is in the model yet). */
|
||||
interface NewFrame {
|
||||
start: Vec2;
|
||||
current: Vec2;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Texture tab's canvas: one texture's picture with its frames drawn on top, where frames
|
||||
* are created by dragging on empty space and moved or resized by their handles.
|
||||
*
|
||||
* Everything but the picture is drawn in widget-local pixels rather than in a zoomed
|
||||
* container: outlines, handles and names have to keep a constant size on screen, which a
|
||||
* scaled container would defeat. The picture itself is a Sprite whose scale is the zoom, so
|
||||
* the GPU does the sampling – and `scaleMode` follows the texture's own Filter property, so a
|
||||
* pixel-art sheet is inspected as crisp pixels rather than as the mush a smoothed preview
|
||||
* would show.
|
||||
*/
|
||||
export class TextureCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly checker = new Graphics();
|
||||
private readonly imageLayer = new Container();
|
||||
private readonly image = new Sprite();
|
||||
private readonly overlay = new Graphics();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
private readonly labelLayer = new Container();
|
||||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly clip = new Graphics();
|
||||
private view: SheetView = { panX: 0, panY: 0, zoom: 1 };
|
||||
private assetId = '';
|
||||
/** Fit the picture once the widget has a real size and a picture to fit (it starts hidden). */
|
||||
private needsFit = true;
|
||||
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
|
||||
private dragging: FrameDrag | null = null;
|
||||
private spanning: NewFrame | null = null;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
this.imageLayer.addChild(this.image);
|
||||
this.addChild(
|
||||
this.bg,
|
||||
this.checker,
|
||||
this.imageLayer,
|
||||
this.overlay,
|
||||
this.labelLayer,
|
||||
this.hint,
|
||||
this.clip,
|
||||
);
|
||||
this.imageLayer.mask = this.clip;
|
||||
this.imageLayer.eventMode = 'none';
|
||||
this.overlay.eventMode = 'none';
|
||||
this.labelLayer.eventMode = 'none';
|
||||
this.hint.eventMode = 'none';
|
||||
this.focusable = true;
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', () => this.onPointerUp());
|
||||
this.on('pointerupoutside', () => this.onPointerUp());
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||
|
||||
state.on('project', () => this.redraw());
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('load', () => {
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
});
|
||||
// A picture arrives asynchronously; until then there is nothing to fit the view to.
|
||||
textureLibrary.onChange(() => this.redraw());
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
/** Which texture is shown. Switching resets the view, since the next picture is a new subject. */
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
get currentAssetId(): string {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
/** Refits the picture into the widget – the "reset view" the toolbar offers. */
|
||||
fit(): void {
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an image pixel currently sits on screen. Only the check scripts need this: dragging
|
||||
* a frame is done in image coordinates, so a test that cannot convert them can drag
|
||||
* somewhere but cannot aim at a known part of the picture.
|
||||
*/
|
||||
imageToScreen(x: number, y: number): Vec2 {
|
||||
const local = imageToLocal(this.view, x, y);
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
// --- model ------------------------------------------------------------------------------
|
||||
|
||||
private asset(): TextureAsset | null {
|
||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||
return asset && asset.type === 'texture' ? asset : null;
|
||||
}
|
||||
|
||||
private selectedFrameId(): string {
|
||||
const s = this.state.selection;
|
||||
return s?.kind === 'frame' && s.assetId === this.assetId ? s.frameId : '';
|
||||
}
|
||||
|
||||
private frame(frameId: string): TextureFrame | undefined {
|
||||
return this.asset()?.frames.find((f) => f.id === frameId);
|
||||
}
|
||||
|
||||
// --- pointer ----------------------------------------------------------------------------
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
const asset = this.asset();
|
||||
// Nothing is cut from a picture whose size is unknown: `normalizeFrameRect` has no bounds
|
||||
// to hold such a frame inside, so it would keep a rectangle that lies off the sheet for
|
||||
// good. An unmeasured texture is a failed upload, and the hint says so.
|
||||
if (!asset || asset.width <= 0 || asset.height <= 0) return;
|
||||
const local = this.toLocal(e.global);
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||||
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
ui().focus.focus(this);
|
||||
const point = localToImage(this.view, local.x, local.y);
|
||||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||||
|
||||
// The selected frame is tested first and by its handles: it is the one being worked on,
|
||||
// and its handles must stay reachable even where a frame drawn later overlaps them.
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
|
||||
if (selected && onSelected) {
|
||||
this.startDrag(selected, onSelected, local);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
// Last first: later frames are drawn on top, so they are what a click on an overlap means.
|
||||
for (let i = asset.frames.length - 1; i >= 0; i--) {
|
||||
const frame = asset.frames[i]!;
|
||||
if (handleAt(frame, point, tolerance) === null) continue;
|
||||
this.state.select({ kind: 'frame', assetId: asset.id, frameId: frame.id });
|
||||
this.startDrag(frame, 'move', local);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
this.spanning = { start: point, current: point, active: false };
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
private startDrag(frame: TextureFrame, handle: FrameHandle, local: Vec2): void {
|
||||
this.dragging = {
|
||||
frameId: frame.id,
|
||||
handle,
|
||||
start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h },
|
||||
pointer: { x: local.x, y: local.y },
|
||||
moved: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
if (this.panning) {
|
||||
this.view = {
|
||||
...this.view,
|
||||
panX: this.panning.panX + (local.x - this.panning.pointer.x),
|
||||
panY: this.panning.panY + (local.y - this.panning.pointer.y),
|
||||
};
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
if (this.dragging) {
|
||||
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
|
||||
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
|
||||
if (!this.dragging.moved && Math.hypot(dx, dy) * this.view.zoom < DRAG_THRESHOLD) return;
|
||||
this.dragging.moved = true;
|
||||
this.previewDrag(dx, dy);
|
||||
return;
|
||||
}
|
||||
if (this.spanning) {
|
||||
this.spanning.current = localToImage(this.view, local.x, local.y);
|
||||
const size = rectBetween(this.spanning.start, this.spanning.current);
|
||||
if (!this.spanning.active && Math.max(size.w, size.h) * this.view.zoom < DRAG_THRESHOLD) return;
|
||||
this.spanning.active = true;
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
this.updateCursor(local);
|
||||
}
|
||||
|
||||
/** Applies a drag to the model directly, as a preview: no undo entry until the pointer is up. */
|
||||
private previewDrag(dx: number, dy: number): void {
|
||||
const drag = this.dragging;
|
||||
const asset = this.asset();
|
||||
const frame = drag && this.frame(drag.frameId);
|
||||
if (!drag || !asset || !frame) return;
|
||||
Object.assign(
|
||||
frame,
|
||||
normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height),
|
||||
);
|
||||
this.state.preview();
|
||||
}
|
||||
|
||||
private onPointerUp(): void {
|
||||
this.panning = null;
|
||||
const drag = this.dragging;
|
||||
const span = this.spanning;
|
||||
this.dragging = null;
|
||||
this.spanning = null;
|
||||
|
||||
if (drag) {
|
||||
const asset = this.asset();
|
||||
const frame = this.frame(drag.frameId);
|
||||
if (drag.moved && asset && frame) {
|
||||
// The preview moved it already; put it back so the command records the real start.
|
||||
const next: FrameRect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
|
||||
Object.assign(frame, drag.start);
|
||||
this.state.execute(new SetFrameRectCommand(asset.id, drag.frameId, next));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!span) return;
|
||||
if (!span.active) {
|
||||
// A click on empty space clears the selection, like everywhere else in the editor.
|
||||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const rect = normalizeFrameRect(rectBetween(span.start, span.current), asset.width, asset.height);
|
||||
if (rect.w < MIN_FRAME_SIZE || rect.h < MIN_FRAME_SIZE) return;
|
||||
const command = new AddFrameCommand(asset.id, rect);
|
||||
if (this.state.execute(command))
|
||||
this.state.select({ kind: 'frame', assetId: asset.id, frameId: command.frameId });
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/** Announces what a press would do before it happens (resize arrows, move, crosshair). */
|
||||
private updateCursor(local: Vec2): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) {
|
||||
this.cursor = 'default';
|
||||
return;
|
||||
}
|
||||
const point = localToImage(this.view, local.x, local.y);
|
||||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
const handle = selected ? handleAt(selected, point, tolerance) : null;
|
||||
if (handle) {
|
||||
this.cursor = handleCursor(handle);
|
||||
return;
|
||||
}
|
||||
const over = asset.frames.some((f) => handleAt(f, point, tolerance) !== null);
|
||||
this.cursor = over ? 'move' : 'crosshair';
|
||||
}
|
||||
|
||||
// --- drawing ----------------------------------------------------------------------------
|
||||
|
||||
override redraw(): void {
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
const asset = this.asset();
|
||||
const texture = asset && asset.file ? this.pictureOf(asset) : null;
|
||||
|
||||
if (asset && texture && this.needsFit && this.w > 0 && this.h > 0) {
|
||||
this.view = fitView(asset.width, asset.height, this.w, this.h);
|
||||
this.needsFit = false;
|
||||
}
|
||||
|
||||
this.image.visible = Boolean(texture);
|
||||
if (asset && texture) {
|
||||
this.image.texture = texture;
|
||||
this.image.position.set(this.view.panX, this.view.panY);
|
||||
// The sprite is drawn at the picture's own pixel size, so the zoom is the whole scale.
|
||||
this.image.width = asset.width * this.view.zoom;
|
||||
this.image.height = asset.height * this.view.zoom;
|
||||
}
|
||||
|
||||
this.drawChecker(asset, Boolean(texture));
|
||||
this.drawFrames(asset);
|
||||
this.syncHint(asset, Boolean(texture));
|
||||
}
|
||||
|
||||
private pictureOf(asset: TextureAsset) {
|
||||
return textureLibrary.get({
|
||||
file: asset.file,
|
||||
smooth: String(asset.props.filter ?? 'linear') !== 'nearest',
|
||||
rect: null,
|
||||
sourceWidth: asset.width,
|
||||
sourceHeight: asset.height,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The checkerboard behind the picture. It is clipped to the picture rather than filling the
|
||||
* widget: its job is to make transparent pixels of *this image* visible, and a board that
|
||||
* ran past the edges would hide where the image actually ends.
|
||||
*/
|
||||
private drawChecker(asset: TextureAsset | null, hasPicture: boolean): void {
|
||||
this.checker.clear();
|
||||
if (!asset || !hasPicture || asset.width <= 0) return;
|
||||
const origin = imageToLocal(this.view, 0, 0);
|
||||
const w = asset.width * this.view.zoom;
|
||||
const h = asset.height * this.view.zoom;
|
||||
this.checker.rect(origin.x, origin.y, w, h).fill(CHECKER_DARK);
|
||||
// Only the squares that land inside the widget are worth emitting; a 4000 px sheet at a
|
||||
// high zoom would otherwise cost tens of thousands of rectangles per redraw.
|
||||
const from = (start: number, limit: number) => Math.max(0, Math.floor((limit - start) / CHECKER_SIZE));
|
||||
const x0 = from(origin.x, 0);
|
||||
const y0 = from(origin.y, 0);
|
||||
const x1 = Math.min(Math.ceil(w / CHECKER_SIZE), from(origin.x, this.w) + 1);
|
||||
const y1 = Math.min(Math.ceil(h / CHECKER_SIZE), from(origin.y, this.h) + 1);
|
||||
for (let cy = y0; cy < y1; cy++) {
|
||||
for (let cx = x0; cx < x1; cx++) {
|
||||
if ((cx + cy) % 2 === 0) continue;
|
||||
const px = origin.x + cx * CHECKER_SIZE;
|
||||
const py = origin.y + cy * CHECKER_SIZE;
|
||||
this.checker.rect(
|
||||
px,
|
||||
py,
|
||||
Math.min(CHECKER_SIZE, w - cx * CHECKER_SIZE),
|
||||
Math.min(CHECKER_SIZE, h - cy * CHECKER_SIZE),
|
||||
);
|
||||
}
|
||||
}
|
||||
this.checker.fill(CHECKER_LIGHT);
|
||||
}
|
||||
|
||||
private drawFrames(asset: TextureAsset | null): void {
|
||||
this.overlay.clear();
|
||||
const alive = new Set<string>();
|
||||
if (asset) {
|
||||
const selectedId = this.selectedFrameId();
|
||||
for (const frame of asset.frames) {
|
||||
alive.add(frame.id);
|
||||
this.drawFrame(frame, frame.id === selectedId);
|
||||
}
|
||||
const selected = this.frame(selectedId);
|
||||
if (selected) this.drawHandles(selected);
|
||||
}
|
||||
if (this.spanning?.active) {
|
||||
const rect = rectBetween(this.spanning.start, this.spanning.current);
|
||||
const a = imageToLocal(this.view, rect.x, rect.y);
|
||||
this.overlay
|
||||
.rect(a.x, a.y, rect.w * this.view.zoom, rect.h * this.view.zoom)
|
||||
.fill({ color: Theme.colors.marquee, alpha: 0.15 })
|
||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
}
|
||||
for (const [id, label] of this.labels) {
|
||||
if (alive.has(id)) continue;
|
||||
label.destroy();
|
||||
this.labels.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private drawFrame(frame: TextureFrame, selected: boolean): void {
|
||||
const a = imageToLocal(this.view, frame.x, frame.y);
|
||||
const w = frame.w * this.view.zoom;
|
||||
const h = frame.h * this.view.zoom;
|
||||
const color = selected ? Theme.colors.accent : Theme.colors.collider;
|
||||
this.overlay.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color });
|
||||
this.syncLabel(frame, a, w, h, selected);
|
||||
}
|
||||
|
||||
private drawHandles(frame: TextureFrame): void {
|
||||
const half = HANDLE_SIZE / 2;
|
||||
for (const handle of frameHandles()) {
|
||||
const p = handlePoint(frame, handle);
|
||||
const local = imageToLocal(this.view, p.x, p.y);
|
||||
this.overlay
|
||||
.rect(local.x - half, local.y - half, HANDLE_SIZE, HANDLE_SIZE)
|
||||
.fill(Theme.colors.accentText)
|
||||
.stroke({ width: 1, color: Theme.colors.accent });
|
||||
}
|
||||
}
|
||||
|
||||
private syncLabel(frame: TextureFrame, at: Vec2, w: number, h: number, selected: boolean): void {
|
||||
let label = this.labels.get(frame.id);
|
||||
// A name that does not fit inside its frame would be read as belonging to the neighbour.
|
||||
const show = Math.min(w, h) >= MIN_LABEL_SIZE && at.x < this.w && at.y < this.h && at.x + w > 0;
|
||||
if (!show) {
|
||||
if (label) label.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!label) {
|
||||
label = new Text({ style: textStyle('body') });
|
||||
this.labels.set(frame.id, label);
|
||||
this.labelLayer.addChild(label);
|
||||
}
|
||||
label.visible = true;
|
||||
if (label.text !== frame.name) label.text = frame.name;
|
||||
label.style.fill = Theme.colors.text;
|
||||
const x = at.x + LABEL_INSET;
|
||||
const y = at.y + LABEL_INSET;
|
||||
label.position.set(x + LABEL_PAD, y + LABEL_PAD);
|
||||
/*
|
||||
* A dark plate behind the name, not just a colour choice for the text.
|
||||
*
|
||||
* The name is drawn *on the user's picture*, which may be any colour – the grey used at
|
||||
* first vanished on the light quadrants of a test sheet, and no fixed colour can be safe on
|
||||
* an arbitrary image. The plate makes the background known, so light text on it always
|
||||
* reads. Same reasoning as the scene grid, which is drawn white at a low alpha rather than
|
||||
* in a theme colour so that it survives whatever the scene puts behind it.
|
||||
*/
|
||||
this.overlay
|
||||
.roundRect(x, y, label.width + LABEL_PAD * 2, label.height + LABEL_PAD * 2, 2)
|
||||
.fill({ color: 0x000000, alpha: selected ? 0.75 : 0.55 });
|
||||
}
|
||||
|
||||
private syncHint(asset: TextureAsset | null, hasPicture: boolean): void {
|
||||
const text = !asset
|
||||
? 'No texture selected – pick one in the Assets panel or import an image.'
|
||||
: !asset.file
|
||||
? 'This texture has no picture yet.'
|
||||
: !hasPicture
|
||||
? 'Loading…'
|
||||
: asset.frames.length === 0
|
||||
? 'Drag on the picture to cut a frame, or use Slice to cut a grid at once.'
|
||||
: '';
|
||||
this.hint.text = text;
|
||||
this.hint.visible = text !== '';
|
||||
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { SliceFramesCommand } from '../../core/commands/frameCommands';
|
||||
import type { TextureAsset } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { SliceDialog } from './SliceDialog';
|
||||
import { TextureCanvas } from './TextureCanvas';
|
||||
|
||||
/** Room kept for the "W × H px · N frames" readout, wide enough for a four-digit sheet. */
|
||||
const INFO_WIDTH = 190;
|
||||
|
||||
/**
|
||||
* The Texture tab: a picker for which texture is being worked on, the tools that act on the
|
||||
* whole sheet, and the canvas the frames are drawn on.
|
||||
*
|
||||
* It follows the selection the way the graph editor does – selecting a texture asset (or one
|
||||
* of its frames) shows it here – but it does not *force* the tab, and it keeps showing the
|
||||
* last texture when the selection moves elsewhere. Otherwise clicking a sprite to check
|
||||
* whether it wears the right frame would empty the tab you are working in.
|
||||
*/
|
||||
export class TextureEditor extends VStack {
|
||||
private readonly canvas: TextureCanvas;
|
||||
private readonly picker: Dropdown;
|
||||
private readonly info: Label;
|
||||
private readonly sliceButton: Button;
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
|
||||
header.add(new Label('Texture', { style: 'muted' }));
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
header.add(this.picker);
|
||||
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
|
||||
header.add(this.sliceButton);
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved rather than measured: the label is built empty, and a stack sizes it once from
|
||||
// the text it had at the time – so without a floor the readout is clipped from then on.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
this.canvas = new TextureCanvas(state);
|
||||
this.add(header);
|
||||
this.add(this.canvas, 1);
|
||||
|
||||
state.on('selection', () => this.followSelection());
|
||||
state.on('project', () => this.refresh());
|
||||
state.on('load', () => {
|
||||
this.canvas.show('');
|
||||
this.followSelection();
|
||||
});
|
||||
this.followSelection();
|
||||
}
|
||||
|
||||
/** The texture currently shown – the check scripts read this. */
|
||||
get currentAssetId(): string {
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
/** Shows a texture explicitly (opening the tab for a freshly imported image). */
|
||||
show(assetId: string): void {
|
||||
this.canvas.show(assetId);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** Where an image pixel of the shown sheet sits on screen – for the check scripts. */
|
||||
imageToScreen(x: number, y: number): { x: number; y: number } {
|
||||
return this.canvas.imageToScreen(x, y);
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const id = this.state.selectedTextureId;
|
||||
// Null merely means "the selection is about something else"; the tab keeps its subject.
|
||||
if (id) this.canvas.show(id);
|
||||
else if (!this.asset()) this.canvas.show(this.textures()[0]?.id ?? '');
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private asset(): TextureAsset | null {
|
||||
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
|
||||
return asset && asset.type === 'texture' ? asset : null;
|
||||
}
|
||||
|
||||
private textures(): TextureAsset[] {
|
||||
return this.state.project.assets
|
||||
.filter((a): a is TextureAsset => a.type === 'texture')
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const items: MenuItem[] = this.textures().map((asset) => ({
|
||||
label: asset.id === this.canvas.currentAssetId ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
}
|
||||
|
||||
private openSlice(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
new SliceDialog(asset, (options, replace) => {
|
||||
this.state.execute(new SliceFramesCommand(asset.id, options, replace));
|
||||
}).openCentered();
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : 'No texture');
|
||||
const count = asset?.frames.length ?? 0;
|
||||
this.info.setText(
|
||||
asset ? `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}` : '',
|
||||
);
|
||||
// Slicing needs a known picture size; without one it could only fail with a notice.
|
||||
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
this.headerBg.clear();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
@@ -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