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