Files
feuerwerk/src/editor/texture/TextureCanvas.ts
T
rootandClaude Opus 5 4f240a50fd Texture tab: one masked container for everything the view moves (#82)
Only the picture was masked, so the checkerboard, the frame outlines, the
handles and the name plates were drawn over the panel next door once the
sheet was zoomed past the tab. The dark block in the report is the
checkerboard's base: checker.ts fills the whole image rect and clips only
the light squares to the widget.

Everything that pans and zooms now sits in one masked container, the
pattern WorldViewport and AnimationCanvas already use. A check samples
pixels outside the tab and names the frames drawn past it; a guard in
front of it asserts that zooming really pushes the sheet over every edge,
or the pixel probes would measure nothing and stay green forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:17:21 +02:00

431 lines
16 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 { textOf, 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 { drawChecker } from './checker';
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;
/** 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;
interface FrameDrag {
frameId: string;
handle: FrameHandle;
start: FrameRect;
pointer: Vec2;
moved: boolean;
}
/** A new frame being spanned, in image pixels; nothing is in the model yet. */
interface NewFrame {
start: Vec2;
current: Vec2;
active: boolean;
}
/**
* The Texture tab's canvas. Everything but the picture is drawn in widget-local pixels rather
* than in a zoomed container: outlines, handles and names must keep a constant size on screen.
*/
export class TextureCanvas extends Widget {
private readonly bg = new Graphics();
/** Everything the view can move: panned or zoomed, all of it reaches past the widget. */
private readonly content = new Container();
private readonly checker = new Graphics();
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.content.addChild(this.checker, this.image, this.overlay, this.labelLayer);
this.addChild(this.bg, this.content, this.hint, this.clip);
this.content.mask = this.clip;
this.content.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) };
}
/** 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;
}
fit(): void {
this.needsFit = true;
this.redraw();
}
/** Where an image pixel sits on screen; only the check scripts need it, to aim drags. */
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 };
}
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);
}
private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset();
// Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame
// that lies off the sheet.
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, so its handles stay reachable under a frame drawn later.
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();
}
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';
}
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);
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: textOf(asset.props.filter ?? 'linear') !== 'nearest',
rect: null,
sourceWidth: asset.width,
sourceHeight: asset.height,
});
}
/** Clipped to the picture rather than filling the widget, which would hide where it 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;
drawChecker(this.checker, origin.x, origin.y, w, h, this.w, this.h);
}
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('viewportLabel') });
this.labels.set(frame.id, label);
this.labelLayer.addChild(label);
}
label.visible = true;
if (label.text !== frame.name) label.text = frame.name;
const x = at.x + LABEL_INSET;
const y = at.y + LABEL_INSET;
label.position.set(x + LABEL_PAD, y + LABEL_PAD);
// A dark plate, not just a text colour: the name is drawn on the user's picture, where no
// fixed colour is legible against every background.
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);
}
}