/** Geometry for the Texture tab: no Pixi and no editor state, so it stays unit-testable. */ import type { FrameBorders } from '../../core/model/Asset'; import type { FrameRect } from '../../core/model/texture'; import type { Vec2 } from '../../core/model/Properties'; /** Image pixel `(0, 0)` sits at (`panX`, `panY`) in widget-local pixels, at `zoom` per pixel. */ export interface SheetView { panX: number; panY: number; zoom: number; } export const MIN_SHEET_ZOOM = 0.05; export const MAX_SHEET_ZOOM = 64; /** Fraction of the widget the picture is fitted into, leaving a margin to grab and pan by. */ const FIT_MARGIN = 0.9; export function imageToLocal(view: SheetView, x: number, y: number): Vec2 { return { x: view.panX + x * view.zoom, y: view.panY + y * view.zoom }; } export function localToImage(view: SheetView, x: number, y: number): Vec2 { return { x: (x - view.panX) / view.zoom, y: (y - view.panY) / view.zoom }; } export function fitView(imageWidth: number, imageHeight: number, w: number, h: number): SheetView { if (imageWidth <= 0 || imageHeight <= 0 || w <= 0 || h <= 0) return { panX: 0, panY: 0, zoom: 1 }; const zoom = clampZoom(Math.min(w / imageWidth, h / imageHeight) * FIT_MARGIN || 1); return { panX: (w - imageWidth * zoom) / 2, panY: (h - imageHeight * zoom) / 2, zoom, }; } function clampZoom(zoom: number): number { return Math.max(MIN_SHEET_ZOOM, Math.min(MAX_SHEET_ZOOM, zoom)); } /** Zooms about a fixed widget-local point, so the pixel under the cursor stays under it. */ export function zoomAbout(view: SheetView, factor: number, localX: number, localY: number): SheetView { const zoom = clampZoom(view.zoom * factor); const before = localToImage(view, localX, localY); const after = { panX: localX - before.x * zoom, panY: localY - before.y * zoom, zoom }; return after; } /** `hx`/`hy` are −1/0/1, naming the edge each axis is pinned to; `'move'` is anywhere inside. */ export type FrameHandle = { hx: -1 | 0 | 1; hy: -1 | 0 | 1 } | 'move'; /** The nine anchor points of a rectangle; (0, 0) is its centre and is not a handle. */ const HANDLES: { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] = [ { hx: -1, hy: -1 }, { hx: 1, hy: -1 }, { hx: -1, hy: 1 }, { hx: 1, hy: 1 }, { hx: 0, hy: -1 }, { hx: 0, hy: 1 }, { hx: -1, hy: 0 }, { hx: 1, hy: 0 }, ]; /** Where a handle sits, in image pixels. */ export function handlePoint(rect: FrameRect, handle: { hx: number; hy: number }): Vec2 { return { x: rect.x + ((handle.hx + 1) / 2) * rect.w, y: rect.y + ((handle.hy + 1) / 2) * rect.h, }; } export function frameHandles(): readonly { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] { return HANDLES; } /** * What a press at `point` does to `rect`; `tolerance` is the grab radius in image pixels. * Corners are tested before edges, so the corner wins on a frame small enough for both to overlap. */ export function handleAt(rect: FrameRect, point: Vec2, tolerance: number): FrameHandle | null { for (const handle of HANDLES) { const p = handlePoint(rect, handle); if (Math.abs(point.x - p.x) <= tolerance && Math.abs(point.y - p.y) <= tolerance) return handle; } const inside = point.x >= rect.x && point.x <= rect.x + rect.w && point.y >= rect.y && point.y <= rect.y + rect.h; return inside ? 'move' : null; } /** * The rectangle a drag produces. It may come out inverted or outside the image: squaring it up * is `normalizeFrameRect`'s job, shared with typed-in values. */ export function dragFrame(start: FrameRect, handle: FrameHandle, dx: number, dy: number): FrameRect { if (handle === 'move') return { ...start, x: start.x + dx, y: start.y + dy }; const rect = { ...start }; if (handle.hx === -1) { rect.x += dx; rect.w -= dx; } else if (handle.hx === 1) { rect.w += dx; } if (handle.hy === -1) { rect.y += dy; rect.h -= dy; } else if (handle.hy === 1) { rect.h += dy; } return rect; } /** The four nine-slice border lines of a frame (#41), named by the frame edge each runs beside. */ export type BorderEdge = 'left' | 'top' | 'right' | 'bottom'; export const BORDER_EDGES: readonly BorderEdge[] = ['left', 'top', 'right', 'bottom']; /** Where a border line runs, in image pixels; vertical for left/right, horizontal for top/bottom. */ export function borderLine( rect: FrameRect, border: FrameBorders, edge: BorderEdge, ): { x1: number; y1: number; x2: number; y2: number } { if (edge === 'left' || edge === 'right') { const x = edge === 'left' ? rect.x + border.left : rect.x + rect.w - border.right; return { x1: x, y1: rect.y, x2: x, y2: rect.y + rect.h }; } const y = edge === 'top' ? rect.y + border.top : rect.y + rect.h - border.bottom; return { x1: rect.x, y1: y, x2: rect.x + rect.w, y2: y }; } /** * Which border line a press at `point` grabs, or null. Tested only within the frame, and after the * frame's own handles: a border still sitting on the frame's edge would otherwise take the corner * that resizes the frame itself. */ export function borderAt( rect: FrameRect, border: FrameBorders, point: Vec2, tolerance: number, ): BorderEdge | null { if ( point.x < rect.x - tolerance || point.x > rect.x + rect.w + tolerance || point.y < rect.y - tolerance || point.y > rect.y + rect.h + tolerance ) return null; for (const edge of BORDER_EDGES) { const line = borderLine(rect, border, edge); const distance = line.x1 === line.x2 ? Math.abs(point.x - line.x1) : Math.abs(point.y - line.y1); if (distance <= tolerance) return edge; } return null; } /** * The borders a drag of one line produces. Raw: keeping them inside the frame is * `normalizeBorders`' job, shared with the numbers typed into the inspector. */ export function dragBorder(border: FrameBorders, edge: BorderEdge, dx: number, dy: number): FrameBorders { const moved = { ...border }; if (edge === 'left') moved.left += dx; else if (edge === 'right') moved.right -= dx; else if (edge === 'top') moved.top += dy; else moved.bottom -= dy; return moved; } /** The Y a frame's sort line (#228) runs at, in image pixels. */ export function sortLineY(rect: FrameRect, line: number): number { return rect.y + rect.h - line; } /** * What a press grabs of the sort line: its knob left of the frame, reachable wherever the line sits, * or the line itself inside the frame. The knob spans from `gap` to `reach` left of the frame, in * image pixels. The press tests the knob before the frame's handles and the line after its border * lines, which at 0 all lie on the bottom edge with it. So the knob takes its whole drawn width, * even where the corner handle's tolerance reaches over it – bounding it by that tolerance instead * left its very middle to the corner handle. */ export function sortLineAt( rect: FrameRect, line: number, point: Vec2, tolerance: number, gap: number, reach: number, ): 'knob' | 'line' | null { if (Math.abs(point.y - sortLineY(rect, line)) > tolerance) return null; if (point.x >= rect.x - reach && point.x < rect.x - gap) return 'knob'; return point.x >= rect.x && point.x <= rect.x + rect.w ? 'line' : null; } export function borderCursor(edge: BorderEdge): string { return edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize'; } /** The rectangle spanned by two points, in either order. */ export function rectBetween(a: Vec2, b: Vec2): FrameRect { return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) }; } export function handleCursor(handle: FrameHandle): string { if (handle === 'move') return 'move'; if (handle.hx === 0) return 'ns-resize'; if (handle.hy === 0) return 'ew-resize'; return handle.hx === handle.hy ? 'nwse-resize' : 'nesw-resize'; }