objectSection.ts had grown to 559 lines around three separable parts: the queries, the vector rows and the parent row. Four object commands repeated the same capture/restore preamble – applyCommands stays out, its five carry extra undo state whose restore order matters. 58 dashed banner comments are gone. The nine labels in nodes/index.ts stay: they are the only structure a flat 90-entry array has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
464 lines
17 KiB
TypeScript
464 lines
17 KiB
TypeScript
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;
|
||
|
||
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();
|
||
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) };
|
||
}
|
||
|
||
/** 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: String(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;
|
||
this.checker.rect(origin.x, origin.y, w, h).fill(CHECKER_DARK);
|
||
// Only the squares inside the widget: a 4000 px sheet zoomed in 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('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);
|
||
}
|
||
}
|