Files
feuerwerk/src/editor/texture/TextureCanvas.ts
T
adminandClaude Opus 5.5 89048413f1 Let a texture frame set where a sprite stands for Y sorting (#228)
A sprite sorted by Y stood on the bottom of its box. With a shadow or
empty rows under the feet that is too low, so a figure slipped in
front of a tree it should stand behind. The line belongs to the frame,
not the texture, because one texture can hold several sprite sheets.

- New field `sortLine` on a frame: whole image pixels up from the
  frame's bottom edge, 0 to the frame height. 0 is stored as nothing,
  so old projects stay the same. Shrinking a frame keeps the line
  inside it.
- `feetOf` in drawOrder.ts lifts a sprite by the same share of its
  box. Scene view, game view, play and picking all use it. In an
  animation the line of the frame shown right now counts.
- The texture tab draws the selected frame's line dashed in yellow
  with a knob left of the frame. At 0 the line shares the bottom edge
  with the nine-slice border and a resize handle, so the knob is
  tested first. A line inside the frame can be dragged directly,
  tested after the nine-slice lines.
- The inspector has a new "Sorting" section with a "Sort line"
  field. Dragging or typing is one undo step each.

Limit: frames of one walk cycle need the same value, set by hand.
Otherwise the figure changes its place in the order while walking.

Tests:
- ./check.sh: 160 files, 2356 tests, CHECK EXIT 0 (before 2350).
- 6 new unit tests in tests/viewport/sort-by-y.test.ts.
- e2e 37 and 39-43: ALL PASSED, 162 checks. New check 37b-ii drags
  the knob up 6 px and undoes it, plus the inspector row.
- Counter checks: if feetOf ignores the line, exactly the 3 order
  tests fail. If the knob cannot be grabbed, 37b-ii fails.
- The e2e sum in the README is 1380, counted, not yet seen in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:34:37 +02:00

881 lines
33 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, Graphics, Sprite, Text } from 'pixi.js';
import {
AddFrameCommand,
SetFrameCollisionCommand,
SetFrameRectCommand,
SetFrameSliceCommand,
SetFrameSortLineCommand,
} from '../../core/commands/frameCommands';
import { polygonProblem } from '../../core/model/polygon';
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
import { textOf, type Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import {
frameBorders,
frameCollision,
frameFill,
type FrameRect,
frameSortLine,
MIN_FRAME_SIZE,
normalizeFrameRect,
normalizeSortLine,
rawFrameSlice,
restoreFrameSlice,
setFrameCollision,
setFrameSlice,
setFrameSortLine,
} from '../../core/model/texture';
import { pressCount, Theme, textStyle, type Widget } from '../../ui';
import {
gripAt,
insideOutline,
MIN_CORNERS,
type PolygonGrip,
withCornerAfter,
withCornerMoved,
withoutCorner,
} from '../polygonEdit';
import { COLLIDER_FILL_ALPHA, dashedLine } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { FramePreview } from '../framePreview';
import type { EditorState } from '../state';
import { drawChecker } from './checker';
import { SheetCanvas } from './SheetCanvas';
import {
borderAt,
borderCursor,
type BorderEdge,
BORDER_EDGES,
borderLine,
dragBorder,
dragFrame,
fitView,
type FrameHandle,
frameHandles,
handleAt,
handleCursor,
handlePoint,
imageToLocal,
localToImage,
rectBetween,
type SheetView,
sortLineAt,
sortLineY,
} 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;
/** The sort line's knob, left of the frame: its gap to the frame and its size, in screen pixels. */
const SORT_KNOB_GAP = 4;
const SORT_KNOB_SIZE = 8;
/** A handle of the frame's rectangle, one of its nine-slice border lines, or its sort line. */
interface FrameDrag {
frameId: string;
handle: FrameHandle | null;
edge: BorderEdge | null;
sortLine: boolean;
/** The line as it was, raw, so the command records the state that was really there. */
startSortLine: number | undefined;
start: FrameRect;
startBorder: FrameBorders;
/** The slice keys as they were, so the command records the state that was really there. */
startSlice: Pick<TextureFrame, 'border' | 'fill'>;
pointer: Vec2;
moved: boolean;
}
/**
* A corner of one of a frame's collision outlines being dragged (#141) – also one just put down. The
* model holds the preview; `before` is put back on release so the command records one undo step.
*/
interface CornerDrag {
frameId: string;
before: Vec2[][];
outlines: Vec2[][];
/** Which outline, and which corner of it. */
outline: number;
index: number;
pointer: Vec2;
added: boolean;
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 SheetCanvas<TextureAsset> {
private readonly image = new Sprite();
/**
* Outlines and handles, drawn with the pan left out so that a pan only moves them (#220). `sheet`
* holds every frame but the selected one and is only redrawn when one of those changes; `overlay`
* holds what a gesture changes.
*/
private readonly marks = new Container();
private readonly sheet = new Graphics();
private readonly overlay = new Graphics();
private sheetKey: unknown[] = [];
private sheetBuilds = 0;
private sheetOutlines = 0;
/** Outlines drawn tinted, on the sheet and on the overlay – only the selected frame's should be. */
private sheetFilled = 0;
private overlayFilled = 0;
private readonly labels = new Map<string, Text>();
private readonly labelLayer = new Container();
private readonly plates = new Graphics();
private dragging: FrameDrag | null = null;
private spanning: NewFrame | null = null;
private corner: CornerDrag | null = null;
/** Whether a press edits the selected frame's collision outlines instead of the frames (#141). */
private collision = false;
/** The outline a click adds corners to; for another frame, or one gone, the frame's last one. */
private active = { frameId: '', index: -1 };
private readonly stopPictures: () => void;
private readonly perFrame: FramePreview;
constructor(private readonly state: EditorState) {
super();
this.perFrame = new FramePreview(state, 'texture-drag');
this.marks.addChild(this.sheet, this.overlay);
// Plates under the outlines, as before the split: a corner handle must not sink under a name.
this.content.addChild(this.image, this.plates, this.marks, this.labelLayer);
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.
this.stopPictures = textureLibrary.onChange(() => this.redraw());
}
/** The library outlives a signed-out editor; a stale listener would redraw destroyed graphics. */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.stopPictures();
super.destroy(options);
}
/** 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();
}
setCollisionMode(on: boolean): void {
if (this.collision === on) return;
this.collision = on;
this.redraw();
}
get collisionMode(): boolean {
return this.collision;
}
/** How the marks were drawn: the check scripts prove with it that a pan or a drag rebuilds nothing. */
get drawInfo() {
const origin = this.marks.getGlobalPosition();
return {
sheetBuilds: this.sheetBuilds,
sheetOutlines: this.sheetOutlines,
filled: { sheet: this.sheetFilled, overlay: this.overlayFilled },
origin: { x: origin.x, y: origin.y },
sortKnob: this.sortKnob(origin),
labels: [...this.labels.values()]
.filter((label) => label.visible)
.map((label) => ({ text: label.text, ...label.getGlobalPosition() })),
};
}
/** The middle of the selected frame's sort-line knob on screen, or null without one to grab. */
private sortKnob(origin: Vec2): Vec2 | null {
const frame = this.frame(this.selectedFrameId());
if (!frame || this.collision) return null;
const at = imageToLocal(
{ panX: 0, panY: 0, zoom: this.view.zoom },
frame.x,
sortLineY(frame, frameSortLine(frame)),
);
return { x: origin.x + at.x - SORT_KNOB_GAP - SORT_KNOB_SIZE / 2, y: origin.y + at.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 activeOutline(frame: TextureFrame, outlines: readonly Vec2[][]): number {
const { frameId, index } = this.active;
return frameId === frame.id && index < outlines.length ? index : outlines.length - 1;
}
/** Delete takes the active outline away whole, in collision mode – not the frame it belongs to. */
override onKeyDown(event: KeyboardEvent): boolean {
if (!this.collision || (event.key !== 'Delete' && event.key !== 'Backspace'))
return super.onKeyDown(event);
const frame = this.frame(this.selectedFrameId());
if (!frame) return super.onKeyDown(event);
const outlines = frameCollision(frame);
const index = this.activeOutline(frame, outlines);
// Nothing active is nothing to delete – and still not the frame, which Delete would take otherwise.
if (index < 0) return true;
this.state.execute(
new SetFrameCollisionCommand(
this.assetId,
frame.id,
outlines.filter((_, i) => i !== index),
),
);
this.active = { frameId: frame.id, index: -1 };
return true;
}
/**
* Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame that
* lies off the sheet.
*/
protected pressTarget(): TextureAsset | null {
const asset = this.asset();
return asset && asset.width > 0 && asset.height > 0 ? asset : null;
}
protected press(asset: TextureAsset, e: FederatedPointerEvent, local: Vec2): void {
const point = localToImage(this.view, local.x, local.y);
const tolerance = HANDLE_SIZE / this.view.zoom;
if (this.collision) {
this.pressOutline(asset, point, local, tolerance, e);
e.stopPropagation();
return;
}
// The selected frame is tested first, so its handles stay reachable under a frame drawn later.
// Its resize handles before its border lines – a border still sitting on the frame's edge must
// not take the grip that resizes the frame – but both before "move", which is the whole inside
// of the frame and would otherwise swallow every line it holds.
const selected = this.frame(this.selectedFrameId());
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
const onBorder = selected ? borderAt(selected, frameBorders(selected), point, tolerance) : null;
const onSortLine = selected ? this.sortLineAt(selected, point, tolerance) : null;
if (selected && onSortLine === 'knob') {
this.startDrag(selected, null, null, local, true);
e.stopPropagation();
return;
}
if (selected && onSelected && onSelected !== 'move') {
this.startDrag(selected, onSelected, null, local);
e.stopPropagation();
return;
}
if (selected && onBorder) {
this.startDrag(selected, null, onBorder, local);
e.stopPropagation();
return;
}
if (selected && onSortLine) {
this.startDrag(selected, null, null, local, true);
e.stopPropagation();
return;
}
if (selected && onSelected) {
this.startDrag(selected, onSelected, null, 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', null, local);
e.stopPropagation();
return;
}
this.spanning = { start: point, current: point, active: false };
e.stopPropagation();
}
/**
* A press in collision mode. On the selected frame, on any of its outlines: a double-click on a
* corner takes it away, a press on a corner drags it, on an edge puts a corner there – and makes
* that outline the active one. Anywhere else inside the frame adds a corner after the active
* outline's last, which is how an outline is drawn from nothing; with Ctrl (Cmd) held it starts a
* new outline, and a press inside another finished outline only makes it the active one. Another
* frame is only selected, and no frame is moved or spanned while the mode is on.
*/
private pressOutline(
asset: TextureAsset,
point: Vec2,
local: Vec2,
tolerance: number,
e: FederatedPointerEvent,
): void {
const selected = this.frame(this.selectedFrameId());
if (selected) {
const at = { x: point.x - selected.x, y: point.y - selected.y };
const outlines = frameCollision(selected);
const active = this.activeOutline(selected, outlines);
// The active outline first, so its corners stay reachable where two outlines meet.
const order = outlines.map((_, i) => i).sort((a, b) => Number(b === active) - Number(a === active));
let target = active;
let grip: PolygonGrip | null = null;
for (const i of order) {
grip = gripAt(outlines[i]!, at, tolerance);
if (!grip) continue;
target = i;
break;
}
const activate = (index: number) => (this.active = { frameId: selected.id, index });
if (grip?.kind === 'corner' && pressCount(e, `outline ${selected.id} ${target} ${grip.index}`) === 2) {
const next = outlines.map((o, i) => (i === target ? withoutCorner(o, grip.index, 0)! : o));
activate(next[target]!.length > 0 ? target : -1);
this.state.execute(new SetFrameCollisionCommand(asset.id, selected.id, next));
return;
}
// A corner often belongs on the frame's edge: a press just beside it still counts as in it.
const inside =
at.x >= -tolerance &&
at.y >= -tolerance &&
at.x <= selected.w + tolerance &&
at.y <= selected.h + tolerance;
const fresh = e.ctrlKey || e.metaKey || target < 0;
if (!grip && inside && !fresh && (outlines[active]?.length ?? 0) >= MIN_CORNERS) {
const other = outlines.findIndex((o, i) => i !== active && insideOutline(o, at));
if (other >= 0) {
activate(other);
this.redraw();
return;
}
}
if (grip || inside) {
const corner = inFrame(selected, grip?.kind === 'edge' ? grip.at : at);
if (!grip && fresh) target = outlines.length;
const points = outlines[target] ?? [];
const changed =
grip?.kind === 'corner'
? points
: grip?.kind === 'edge'
? withCornerAfter(points, grip.index, corner)
: [...points, corner];
const next = [...outlines];
next[target] = changed;
activate(target);
this.corner = {
frameId: selected.id,
before: outlines,
outlines: next,
outline: target,
index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : changed.length - 1,
pointer: { x: local.x, y: local.y },
added: grip?.kind !== 'corner',
moved: false,
};
if (this.corner.added) this.previewOutline();
else this.redraw();
return;
}
}
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 });
return;
}
this.state.select({ kind: 'asset', assetId: this.assetId });
}
private previewOutline(): void {
const corner = this.corner;
const frame = corner && this.frame(corner.frameId);
if (!corner || !frame) return;
setFrameCollision(frame, corner.outlines);
this.perFrame.request();
}
private startDrag(
frame: TextureFrame,
handle: FrameHandle | null,
edge: BorderEdge | null,
local: Vec2,
sortLine = false,
): void {
this.dragging = {
frameId: frame.id,
handle,
edge,
sortLine,
startSortLine: frame.sortLine,
start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h },
startBorder: frameBorders(frame),
startSlice: rawFrameSlice(frame),
pointer: { x: local.x, y: local.y },
moved: false,
};
}
protected move(local: Vec2): void {
if (this.corner) {
const corner = this.corner;
const frame = this.frame(corner.frameId);
if (!frame) return;
if (
!corner.moved &&
Math.hypot(local.x - corner.pointer.x, local.y - corner.pointer.y) < DRAG_THRESHOLD
)
return;
corner.moved = true;
const point = localToImage(this.view, local.x, local.y);
corner.outlines = corner.outlines.map((outline, i) =>
i === corner.outline
? withCornerMoved(
outline,
corner.index,
inFrame(frame, { x: point.x - frame.x, y: point.y - frame.y }),
)
: outline,
);
this.previewOutline();
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;
if (drag.sortLine) setFrameSortLine(frame, normalizeSortLine(drag.startSortLine ?? 0, drag.start.h) - dy);
else if (drag.edge)
setFrameSlice(frame, {
border: dragBorder(drag.startBorder, drag.edge, dx, dy),
fill: frameFill(frame),
});
else if (drag.handle)
Object.assign(
frame,
normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height),
);
this.perFrame.request();
}
protected release(): void {
this.perFrame.cancel();
const corner = this.corner;
this.corner = null;
const frame = corner && this.frame(corner.frameId);
if (corner && frame) {
setFrameCollision(frame, corner.before);
if (corner.added || corner.moved)
this.state.execute(new SetFrameCollisionCommand(this.assetId, corner.frameId, corner.outlines));
else this.state.preview();
return;
}
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.
if (drag.sortLine) {
const next = frameSortLine(frame);
if (drag.startSortLine === undefined) delete frame.sortLine;
else frame.sortLine = drag.startSortLine;
this.state.execute(new SetFrameSortLineCommand(asset.id, drag.frameId, next));
} else if (drag.edge) {
const next = { border: frameBorders(frame), fill: frameFill(frame) };
restoreFrameSlice(frame, drag.startSlice);
this.state.execute(new SetFrameSliceCommand(asset.id, drag.frameId, next));
} else {
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 updateCursor(local: Vec2): void {
const asset = this.asset();
if (!asset) {
this.cursor = 'default';
return;
}
if (this.collision) {
this.cursor = 'crosshair';
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;
const onSortLine = selected ? this.sortLineAt(selected, point, tolerance) : null;
if (onSortLine === 'knob') {
this.cursor = 'ns-resize';
return;
}
if (handle && handle !== 'move') {
this.cursor = handleCursor(handle);
return;
}
// Same order as the press: a line inside the frame is a line, not the frame's own body.
const edge = selected ? borderAt(selected, frameBorders(selected), point, tolerance) : null;
if (edge) {
this.cursor = borderCursor(edge);
return;
}
if (onSortLine) {
this.cursor = 'ns-resize';
return;
}
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.drawGround();
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,
slice: null,
});
}
/** 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);
}
/** A pan leaves every outline where it is on the sheet: move them, and place the names anew. */
protected override panned(): void {
const asset = this.asset();
this.image.position.set(this.view.panX, this.view.panY);
this.drawChecker(asset, this.image.visible);
this.marks.position.set(this.view.panX, this.view.panY);
this.syncLabels(asset?.frames ?? [], this.selectedFrameId());
}
private drawFrames(asset: TextureAsset | null): void {
const view: SheetView = { panX: 0, panY: 0, zoom: this.view.zoom };
this.marks.position.set(this.view.panX, this.view.panY);
const frames = asset?.frames ?? [];
const selectedId = this.selectedFrameId();
const key = sheetKey(frames, selectedId, view.zoom);
if (!sameKey(key, this.sheetKey)) {
this.sheetKey = key;
this.sheet.clear();
const others = frames.filter((f) => f.id !== selectedId);
for (const frame of others) this.drawFrame(this.sheet, view, frame, false);
this.sheetFilled = 0;
for (const frame of others)
this.sheetFilled += this.drawOutlines(this.sheet, view, frame, false, false);
this.sheetBuilds += 1;
this.sheetOutlines = others.reduce((n, f) => n + (f.collision?.length ?? 0), 0);
}
this.overlay.clear();
this.overlayFilled = 0;
const selected = frames.find((f) => f.id === selectedId);
if (selected) {
this.drawFrame(this.overlay, view, selected, true);
this.overlayFilled = this.drawOutlines(this.overlay, view, selected, this.collision, true);
if (!this.collision) {
this.drawBorders(view, selected);
this.drawSortLine(view, selected);
this.drawHandles(view, selected);
}
}
if (this.spanning?.active) {
const rect = rectBetween(this.spanning.start, this.spanning.current);
const a = imageToLocal(view, rect.x, rect.y);
this.overlay
.rect(a.x, a.y, rect.w * view.zoom, rect.h * view.zoom)
.fill({ color: Theme.colors.marquee, alpha: 0.15 })
.stroke({ width: 1, color: Theme.colors.marquee });
}
this.syncLabels(frames, selectedId);
}
private drawFrame(g: Graphics, view: SheetView, frame: TextureFrame, selected: boolean): void {
const a = imageToLocal(view, frame.x, frame.y);
const w = frame.w * view.zoom;
const h = frame.h * view.zoom;
const color = selected ? Theme.colors.accent : Theme.colors.collider;
g.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color });
}
/**
* The four nine-slice lines of the selected frame (#41). Drawn even when they all sit on its
* edges: a frame nobody sliced still has them, and a line one cannot see is one nobody drags.
*/
private drawBorders(view: SheetView, frame: TextureFrame): void {
const border = frameBorders(frame);
for (const edge of BORDER_EDGES) {
const line = borderLine(frame, border, edge);
const a = imageToLocal(view, line.x1, line.y1);
const b = imageToLocal(view, line.x2, line.y2);
dashedLine(this.overlay, a.x, a.y, b.x, b.y, 4, 3);
}
this.overlay.stroke({ width: 1, color: Theme.colors.marquee });
}
/** The sort line (#228) across the selected frame, with its knob left of it: a line at 0 is on the edge. */
private drawSortLine(view: SheetView, frame: TextureFrame): void {
const y = sortLineY(frame, frameSortLine(frame));
const a = imageToLocal(view, frame.x, y);
const b = imageToLocal(view, frame.x + frame.w, y);
dashedLine(this.overlay, a.x, a.y, b.x, b.y, 6, 3);
this.overlay.stroke({ width: 1, color: Theme.colors.hot });
const tip = a.x - SORT_KNOB_GAP;
const half = SORT_KNOB_SIZE / 2;
this.overlay
.poly([tip, a.y, tip - SORT_KNOB_SIZE, a.y - half, tip - SORT_KNOB_SIZE, a.y + half], true)
.fill({ color: Theme.colors.hot })
.stroke({ width: 1, color: Theme.colors.symbolOutline });
}
private sortLineAt(frame: TextureFrame, point: Vec2, tolerance: number): 'knob' | 'line' | null {
const reach = (SORT_KNOB_GAP + SORT_KNOB_SIZE + 2) / this.view.zoom;
return sortLineAt(frame, frameSortLine(frame), point, tolerance, reach);
}
/**
* A frame's collision outlines (#141), always shown so one sees which frames block with them; on
* the edited frame the active outline is drawn heavier and every corner of every outline gets a
* handle. An outline that is no polygon is drawn in red. Only the selected frame's outlines are
* `filled`, so the tint does not lie over the art of every other frame. Returns how many were tinted.
*/
private drawOutlines(
g: Graphics,
view: SheetView,
frame: TextureFrame,
edited: boolean,
filled: boolean,
): number {
const outlines = frame.collision ?? [];
const active = edited ? this.activeOutline(frame, outlines) : -1;
let tinted = 0;
for (const [index, points] of outlines.entries()) {
const screen = points.map((p) => imageToLocal(view, frame.x + p.x, frame.y + p.y));
const color = isPolygon(points) ? Theme.colors.collider : Theme.colors.danger;
const width = index === active ? 2 : 1;
if (screen.length >= 3) {
g.poly(screen, true);
if (filled) {
g.fill({ color, alpha: COLLIDER_FILL_ALPHA });
tinted += 1;
}
g.stroke({ width, color });
} else if (screen.length === 2)
g.moveTo(screen[0]!.x, screen[0]!.y).lineTo(screen[1]!.x, screen[1]!.y).stroke({ width: 2, color });
if (!edited) continue;
const half = HANDLE_SIZE / 2;
for (const c of screen)
g.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE)
.fill(index === active ? Theme.colors.accentText : Theme.colors.viewport)
.stroke({ width: 1, color });
}
return tinted;
}
private drawHandles(view: SheetView, frame: TextureFrame): void {
const half = HANDLE_SIZE / 2;
for (const handle of frameHandles()) {
const p = handlePoint(frame, handle);
const local = imageToLocal(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 });
}
}
/** Names stay in screen space and only on frames in view, so a pan places them anew. */
private syncLabels(frames: readonly TextureFrame[], selectedId: string): void {
this.plates.clear();
const alive = new Set<string>();
for (const frame of frames) {
alive.add(frame.id);
const at = imageToLocal(this.view, frame.x, frame.y);
this.syncLabel(frame, at, frame.w * this.view.zoom, frame.h * this.view.zoom, frame.id === selectedId);
}
for (const [id, label] of this.labels) {
if (alive.has(id)) continue;
label.destroy();
this.labels.delete(id);
}
}
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.plates
.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.'
: this.collision && asset.file && hasPicture
? this.selectedFrameId()
? 'Collision: click adds a corner, Ctrl+click starts a new outline, Delete removes one.'
: 'Collision: select a frame to draw the outlines its tiles block with.'
: !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);
}
}
/**
* What the unselected frames are drawn from. The model replaces a frame's outline list when it
* changes rather than editing it, so the list itself stands for its corners.
*/
function sheetKey(frames: readonly TextureFrame[], selectedId: string, zoom: number): unknown[] {
const key: unknown[] = [zoom, selectedId];
for (const f of frames) if (f.id !== selectedId) key.push(f, f.x, f.y, f.w, f.h, f.collision);
return key;
}
function sameKey(a: readonly unknown[], b: readonly unknown[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
/**
* Whether an outline is a polygon, remembered per outline for the same reason. Working it out on
* every redraw was half of what a sheet with many outlines cost per pan (#220).
*/
const polygonOutlines = new WeakMap<readonly Vec2[], boolean>();
function isPolygon(points: readonly Vec2[]): boolean {
let valid = polygonOutlines.get(points);
if (valid === undefined) polygonOutlines.set(points, (valid = polygonProblem(points) === ''));
return valid;
}
/** A corner of an outline: on whole pixels, and inside its frame. */
function inFrame(frame: TextureFrame, p: Vec2): Vec2 {
return {
x: Math.min(frame.w, Math.max(0, Math.round(p.x))),
y: Math.min(frame.h, Math.max(0, Math.round(p.y))),
};
}