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(); 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(); 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); } }