Files
feuerwerk/src/editor/texture/TextureCanvas.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

499 lines
19 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 {
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);
}
}