diff --git a/src/core/commands/frameCommands.ts b/src/core/commands/frameCommands.ts index 8b722815..679c6494 100644 --- a/src/core/commands/frameCommands.ts +++ b/src/core/commands/frameCommands.ts @@ -8,9 +8,13 @@ import type { Project } from '../model/Project'; import { frameRect, type FrameRect, + type FrameSlice, nextFrameNames, normalizeFrameRect, + rawFrameSlice, + restoreFrameSlice, setFrameRect, + setFrameSlice, sliceFrames, type SliceOptions, uniqueFrameName, @@ -88,6 +92,32 @@ export class SetFrameRectCommand implements Command { } } +/** + * The four borders and the fill of one frame (#41), in one command: dragging a border line and + * picking a fill are the same edit to the reader of an undo list, and splitting them would make + * "undo" take back half of what the eye saw change. + */ +export class SetFrameSliceCommand implements Command { + readonly label = 'Nine-slice'; + private previous: Pick = {}; + + constructor( + private readonly assetId: string, + private readonly frameId: string, + private readonly slice: FrameSlice, + ) {} + + execute(project: Project): void { + const frame = requireFrame(requireTexture(project, this.assetId), this.frameId); + this.previous = rawFrameSlice(frame); + setFrameSlice(frame, this.slice); + } + + undo(project: Project): void { + restoreFrameSlice(requireFrame(requireTexture(project, this.assetId), this.frameId), this.previous); + } +} + export class RenameFrameCommand implements Command { readonly label = 'Rename frame'; private previous = ''; diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 57c86203..bfeecc69 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -61,7 +61,22 @@ export interface ContainerAsset extends AssetBase { objects: SceneObject[]; } -/** `id` shares the generator with asset ids and is unique across the document (see `model/texture.ts`). */ +/** How the parts between the corners of a nine-slice frame fill the space they are given (#41). */ +export type FrameFill = 'stretch' | 'repeat'; + +/** Distances from the frame's own edges, in image pixels; all zero means the frame is not sliced. */ +export interface FrameBorders { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * `id` shares the generator with asset ids and is unique across the document (see `model/texture.ts`). + * `border` and `fill` are absent on a frame nobody sliced, which is what every document written + * before #41 says – read them through `frameBorders`/`frameFill`, never off the object. + */ export interface TextureFrame { id: string; name: string; @@ -69,6 +84,8 @@ export interface TextureFrame { y: number; w: number; h: number; + border?: FrameBorders; + fill?: FrameFill; } /** diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 29ab0698..bf956d6f 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -1,5 +1,5 @@ import { animationFrameAt } from './animation'; -import type { TextureAsset, TextureFrame } from './Asset'; +import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset'; import { type PropertyBag, textOf } from './Properties'; import { findAsset, type Project } from './Project'; @@ -30,6 +30,21 @@ export interface TextureRef { rect: FrameRect | null; sourceWidth: number; sourceHeight: number; + /** Set when the frame this ref names is sliced (#41); null means "draw it over the whole box". */ + slice: NineSlice | null; +} + +/** + * A sliced frame as the renderer needs it: the borders, how the parts between them fill, and the + * size they are measured in. Carried on the ref rather than looked up while drawing, for the same + * reason the rectangle is – drawing has no access to the project. + */ +export interface NineSlice { + border: FrameBorders; + fill: FrameFill; + /** Size of the frame in image pixels; the borders are distances inside it. */ + sourceW: number; + sourceH: number; } export interface FrameRef { @@ -87,9 +102,96 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n rect: frame ? { x: frame.x, y: frame.y, w: frame.w, h: frame.h } : null, sourceWidth: asset.width, sourceHeight: asset.height, + slice: frame ? nineSliceOf(frame) : null, }; } +export const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 }; +export const DEFAULT_FILL: FrameFill = 'stretch'; + +/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */ +export const MIN_SLICE_MIDDLE = 1; + +export function frameBorders(frame: TextureFrame): FrameBorders { + return frame.border ? { ...frame.border } : { ...NO_BORDERS }; +} + +export function frameFill(frame: TextureFrame): FrameFill { + return frame.fill === 'repeat' ? 'repeat' : DEFAULT_FILL; +} + +export function hasBorders(border: FrameBorders): boolean { + return border.left > 0 || border.top > 0 || border.right > 0 || border.bottom > 0; +} + +/** What a frame is sliced into, or null when nobody sliced it – then a sprite draws it as one piece. */ +export function nineSliceOf(frame: TextureFrame): NineSlice | null { + const border = normalizeBorders(frameBorders(frame), frame.w, frame.h); + if (!hasBorders(border)) return null; + return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h }; +} + +/** + * Whole pixels, never negative, and never eating the whole frame: two opposite borders are scaled + * down together until at least one pixel is left between them. A middle of no pixels has nothing + * to draw the space between the corners with, which would show as a hole rather than as an error. + */ +export function normalizeBorders(border: FrameBorders, w: number, h: number): FrameBorders { + const [left, right] = fitPair(border.left, border.right, w); + const [top, bottom] = fitPair(border.top, border.bottom, h); + return { left, top, right, bottom }; +} + +function fitPair(near: number, far: number, size: number): [number, number] { + const a = Math.max(0, Math.round(Number.isFinite(near) ? near : 0)); + const b = Math.max(0, Math.round(Number.isFinite(far) ? far : 0)); + const room = Math.max(0, Math.floor(size) - MIN_SLICE_MIDDLE); + if (a + b <= room) return [a, b]; + if (room <= 0) return [0, 0]; + // Proportional, so a frame that is dragged small keeps the shape of its corners. + const scaled = Math.round((a / (a + b)) * room); + return [scaled, room - scaled]; +} + +/** The slice settings of a frame, as one value – what an inspector row and a command pass around. */ +export interface FrameSlice { + border: FrameBorders; + fill: FrameFill; +} + +export function frameSlice(frame: TextureFrame): FrameSlice { + return { border: frameBorders(frame), fill: frameFill(frame) }; +} + +/** + * Writes the slice back, dropping the keys again once they mean nothing: a frame nobody sliced + * reads and saves exactly as it did before #41, so undo of the first slice restores the old shape. + */ +export function setFrameSlice(frame: TextureFrame, slice: FrameSlice): void { + const border = normalizeBorders(slice.border, frame.w, frame.h); + if (hasBorders(border)) frame.border = border; + else delete frame.border; + // Kept even without borders: a fill chosen before the borders are dragged would otherwise + // snap back to "stretch" under the hand that just set it. + if (slice.fill === 'repeat') frame.fill = 'repeat'; + else delete frame.fill; +} + +/** The slice keys exactly as the frame carries them – absence included, which undo owes back. */ +export function rawFrameSlice(frame: TextureFrame): Pick { + const raw: Pick = {}; + if (frame.border) raw.border = { ...frame.border }; + if (frame.fill) raw.fill = frame.fill; + return raw; +} + +export function restoreFrameSlice(frame: TextureFrame, raw: Pick): void { + if (raw.border) frame.border = { ...raw.border }; + else delete frame.border; + if (raw.fill) frame.fill = raw.fill; + else delete frame.fill; +} + /** * Rounds to whole pixels and forces the rectangle inside the image: a fractional frame samples * half of a neighbouring pixel along every edge, the classic sprite-sheet seam. diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 0c96a8bf..fe61b151 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -33,7 +33,7 @@ import { import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import { readScope } from '../model/Scope'; import type { SceneObject } from '../model/SceneObject'; -import { isTextureFile, normalizeFrameRect } from '../model/texture'; +import { isTextureFile, normalizeBorders, normalizeFrameRect, setFrameSlice } from '../model/texture'; import { hasParentCycle } from '../model/walk'; import { DECLARED_PORT_KINDS, @@ -433,7 +433,24 @@ function normalizeTexture( width, height, ); - frames.push({ id, name: typeof f.name === 'string' ? f.name : '', ...rect }); + const frame: TextureFrame = { id, name: typeof f.name === 'string' ? f.name : '', ...rect }; + // Against the frame, not the image: the borders are distances inside the cut-out. A frame + // without them is one nobody sliced, and keeps saying so. + const border = asRecord(f.border); + setFrameSlice(frame, { + border: normalizeBorders( + { + left: toNumber(border.left), + top: toNumber(border.top), + right: toNumber(border.right), + bottom: toNumber(border.bottom), + }, + rect.w, + rect.h, + ), + fill: f.fill === 'repeat' ? 'repeat' : 'stretch', + }); + frames.push(frame); } return { file, width, height, frames }; } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 103ad59c..42c8fe17 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -227,6 +227,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const holder = findObjectHolder(state.project, holderId); return holder ? resolveObjects(state.project, holder) : []; }; + /** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */ + const pixelAt = (x: number, y: number): string => { + const { pixels } = app.renderer.extract.pixels({ + target: app.stage, + frame: new Rectangle(x, y, 1, 1), + }); + const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0'); + return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`; + }; (window as unknown as { __gm: unknown }).__gm = { state, /** The Pixi application, for probes that need the renderer or the stage. */ @@ -490,14 +499,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E hierarchyRowColor: (id: string) => hierarchy.rowColor(id), hierarchyExpandAll: () => hierarchy.expandAll(), hierarchyCollapseAll: () => hierarchy.collapseAll(), - /** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */ - pixelAt: (x: number, y: number) => { - const { pixels } = app.renderer.extract.pixels({ - target: app.stage, - frame: new Rectangle(x, y, 1, 1), - }); - const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0'); - return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`; + pixelAt, + /** + * A horizontal run of pixels in one read. A check that has to tell a repeated pattern from a + * stretched one needs dozens of samples, and dozens of round trips would cost more than the + * step it belongs to. + */ + pixelRow: (x: number, y: number, count: number) => { + const width = Math.max(1, Math.min(1024, Math.round(count))); + const row: string[] = []; + // One extract per pixel, not one strip: a wider frame comes back resampled, and a row that + // blends its neighbours cannot tell a hard border from a soft one – which is what it is for. + for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y)); + return row; }, /** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */ textureFilter: (file: string, rect: FrameRect | null = null) => { diff --git a/src/editor/inspector/frameSection.ts b/src/editor/inspector/frameSection.ts index 3f7e6957..31150c43 100644 --- a/src/editor/inspector/frameSection.ts +++ b/src/editor/inspector/frameSection.ts @@ -2,10 +2,22 @@ import { RemoveFrameCommand, RenameFrameCommand, SetFrameRectCommand, + SetFrameSliceCommand, } from '../../core/commands/frameCommands'; -import type { TextureAsset, TextureFrame } from '../../core/model/Asset'; +import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset'; +import { type PropertyValue, textOf } from '../../core/model/Properties'; import { findAssetOf, type Project } from '../../core/model/Project'; -import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture'; +import { + frameBorders, + frameFill, + frameRect, + type FrameRect, + frameSlice, + type FrameSlice, + normalizeFrameRect, + setFrameRect, + setFrameSlice, +} from '../../core/model/texture'; import { Button } from '../../ui'; import { createFieldRow, type FieldHandlers } from './fieldFactory'; import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; @@ -18,6 +30,13 @@ const RECT_COMPONENTS = [ { key: 'h', label: 'Height' }, ] as const; +const BORDER_COMPONENTS = [ + { key: 'left', label: 'Left' }, + { key: 'top', label: 'Top' }, + { key: 'right', label: 'Right' }, + { key: 'bottom', label: 'Bottom' }, +] as const; + /** Structure-defining facts of a frame selection: none – only the frame disappearing rebuilds. */ export function frameSignature(project: Project, assetId: string, frameId: string): string { return currentFrame(project, assetId, frameId) ? 'frame' : 'missing'; @@ -44,6 +63,12 @@ export function buildFrameSection(host: InspectorHost, assetId: string, frameId: for (const component of RECT_COMPONENTS) rectRow(host, assetId, frameId, component.key, component.label, frame[component.key]); + addTitle(host, 'NINE-SLICE (IMAGE PIXELS)'); + for (const component of BORDER_COMPONENTS) + borderRow(host, assetId, frameId, component.key, component.label); + fillRow(host, assetId, frameId); + addNote(host, 'All four at zero: the picture is drawn over the whole box, as it always was.'); + addGap(host); host.body.add( new Button('Delete frame', { @@ -59,9 +84,78 @@ export function refreshFrameSection(host: InspectorHost, assetId: string, frameI if (!frame) return false; host.nameField?.setValue(frame.name); for (const component of RECT_COMPONENTS) host.fields.get(component.key)?.setValue(frame[component.key]); + const border = frameBorders(frame); + for (const component of BORDER_COMPONENTS) + host.fields.get(`border-${component.key}`)?.setValue(border[component.key]); + host.fields.get('fill')?.setValue(frameFill(frame)); return true; } +/** + * One border of the nine-slice, in pixels of the frame. The row keeps the other three and the fill + * as they are at that moment, so two rows edited one after the other do not undo each other. + */ +function borderRow( + host: InspectorHost, + assetId: string, + frameId: string, + key: keyof FrameBorders, + label: string, +): void { + const frame = currentFrame(host.state.project, assetId, frameId); + const row = createFieldRow( + host, + { key: `border-${key}`, label, kind: 'number', default: 0, min: 0, step: 1, decimals: 0 }, + frame ? frameBorders(frame)[key] : 0, + sliceHandlers(host, assetId, frameId, (input, slice) => ({ + ...slice, + border: { ...slice.border, [key]: Number(input) }, + })), + ); + host.fields.set(`border-${key}`, row); + host.body.add(row.row); +} + +/** How the parts between the corners fill their space – one choice for the edges and the middle. */ +function fillRow(host: InspectorHost, assetId: string, frameId: string): void { + const frame = currentFrame(host.state.project, assetId, frameId); + const row = createFieldRow( + host, + { + key: 'fill', + label: 'Fill', + kind: 'enum', + default: 'stretch', + options: [ + { value: 'stretch', label: 'Stretch' }, + { value: 'repeat', label: 'Repeat' }, + ], + }, + frame ? frameFill(frame) : 'stretch', + sliceHandlers(host, assetId, frameId, (input, slice) => ({ + ...slice, + fill: textOf(input) === 'repeat' ? 'repeat' : 'stretch', + })), + ); + host.fields.set('fill', row); + host.body.add(row.row); +} + +function sliceHandlers( + host: InspectorHost, + assetId: string, + frameId: string, + apply: (input: PropertyValue, slice: FrameSlice) => FrameSlice, +): FieldHandlers { + return previewingHandlers(host, { + target: () => currentFrame(host.state.project, assetId, frameId), + read: frameSlice, + write: setFrameSlice, + toModel: (input, frame) => apply(input, frameSlice(frame)), + commit: (slice) => new SetFrameSliceCommand(assetId, frameId, slice), + }); +} + /** A number row editing one component of the rectangle. Whole pixels: a frame indexes a bitmap. */ function rectRow( host: InspectorHost, diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 92ace0bc..18eb0c39 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -6,16 +6,36 @@ import { Sprite, Text, } from 'pixi.js'; -import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands'; -import type { TextureAsset, TextureFrame } from '../../core/model/Asset'; +import { + AddFrameCommand, + SetFrameRectCommand, + SetFrameSliceCommand, +} from '../../core/commands/frameCommands'; +import type { FrameBorders, 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 { + frameBorders, + frameFill, + type FrameRect, + MIN_FRAME_SIZE, + normalizeFrameRect, + rawFrameSlice, + restoreFrameSlice, + setFrameSlice, +} from '../../core/model/texture'; import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; +import { dashedLine } from '../../viewport/symbols'; import { textureLibrary } from '../../viewport/TextureLibrary'; import type { EditorState } from '../state'; import { drawChecker } from './checker'; import { + borderAt, + borderCursor, + type BorderEdge, + BORDER_EDGES, + borderLine, + dragBorder, dragFrame, fitView, type FrameHandle, @@ -40,10 +60,15 @@ const MIN_LABEL_SIZE = 28; const LABEL_INSET = 3; const LABEL_PAD = 2; +/** Either a handle of the frame's rectangle or one of its nine-slice border lines – never both. */ interface FrameDrag { frameId: string; - handle: FrameHandle; + handle: FrameHandle | null; + edge: BorderEdge | null; 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; } @@ -162,10 +187,24 @@ export class TextureCanvas extends Widget { const tolerance = HANDLE_SIZE / this.view.zoom; // 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; + 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 && onSelected) { - this.startDrag(selected, onSelected, local); + this.startDrag(selected, onSelected, null, local); e.stopPropagation(); return; } @@ -174,7 +213,7 @@ export class TextureCanvas extends Widget { 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); + this.startDrag(frame, 'move', null, local); e.stopPropagation(); return; } @@ -182,11 +221,19 @@ export class TextureCanvas extends Widget { e.stopPropagation(); } - private startDrag(frame: TextureFrame, handle: FrameHandle, local: Vec2): void { + private startDrag( + frame: TextureFrame, + handle: FrameHandle | null, + edge: BorderEdge | null, + local: Vec2, + ): void { this.dragging = { frameId: frame.id, handle, + edge, 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, }; @@ -228,10 +275,16 @@ export class TextureCanvas extends Widget { 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), - ); + 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.state.preview(); } @@ -247,9 +300,15 @@ export class TextureCanvas extends Widget { 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)); + 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; } @@ -288,6 +347,16 @@ export class TextureCanvas extends Widget { const tolerance = HANDLE_SIZE / this.view.zoom; const selected = this.frame(this.selectedFrameId()); const handle = selected ? handleAt(selected, point, tolerance) : null; + 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 (handle) { this.cursor = handleCursor(handle); return; @@ -327,6 +396,7 @@ export class TextureCanvas extends Widget { rect: null, sourceWidth: asset.width, sourceHeight: asset.height, + slice: null, }); } @@ -350,7 +420,10 @@ export class TextureCanvas extends Widget { this.drawFrame(frame, frame.id === selectedId); } const selected = this.frame(selectedId); - if (selected) this.drawHandles(selected); + if (selected) { + this.drawBorders(selected); + this.drawHandles(selected); + } } if (this.spanning?.active) { const rect = rectBetween(this.spanning.start, this.spanning.current); @@ -376,6 +449,21 @@ export class TextureCanvas extends Widget { this.syncLabel(frame, a, w, h, selected); } + /** + * 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(frame: TextureFrame): void { + const border = frameBorders(frame); + for (const edge of BORDER_EDGES) { + const line = borderLine(frame, border, edge); + const a = imageToLocal(this.view, line.x1, line.y1); + const b = imageToLocal(this.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 }); + } + private drawHandles(frame: TextureFrame): void { const half = HANDLE_SIZE / 2; for (const handle of frameHandles()) { diff --git a/src/editor/texture/sheetView.ts b/src/editor/texture/sheetView.ts index 7c339090..b1b920c9 100644 --- a/src/editor/texture/sheetView.ts +++ b/src/editor/texture/sheetView.ts @@ -1,4 +1,5 @@ /** 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'; @@ -108,6 +109,68 @@ export function dragFrame(start: FrameRect, handle: FrameHandle, dx: number, 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; +} + +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) }; diff --git a/src/viewport/nineSlice.ts b/src/viewport/nineSlice.ts new file mode 100644 index 00000000..2bf30186 --- /dev/null +++ b/src/viewport/nineSlice.ts @@ -0,0 +1,112 @@ +/** + * The geometry a nine-sliced frame draws with (#41): no Pixi and no project, so it stays + * unit-testable. A sprite is one quad per part rather than a shared 4×4 vertex grid, because a + * repeated part needs its own UVs per tile – a grid can only stretch. + */ +import type { FrameFill } from '../core/model/Asset'; +import type { NineSlice } from '../core/model/texture'; + +/** How often a part may repeat before it stretches instead; a guard, not a design (see `sliceSpans`). */ +export const MAX_TILES = 64; + +/** One part along an axis: `p0`..`p1` in the sprite's units, `t0`..`t1` in the frame's 0..1 texture space. */ +export interface Span { + p0: number; + p1: number; + t0: number; + t1: number; +} + +/** + * The parts one axis is cut into. `near`/`far` are the two borders in source pixels, and they keep + * that size in the sprite's units – that is the whole point of slicing. A sprite too small to hold + * both is not left with overlapping corners: they are scaled down together until the middle is + * exactly zero, so the seam sits where the two corners meet. + */ +export function sliceSpans( + target: number, + source: number, + near: number, + far: number, + fill: FrameFill, +): Span[] { + if (!(target > 0) || !(source > 0)) return []; + const shrink = near + far > target ? target / (near + far) : 1; + const n = near * shrink; + const f = far * shrink; + const middle = Math.max(0, target - n - f); + const sourceMiddle = source - near - far; + const spans: Span[] = []; + if (n > 0) spans.push({ p0: 0, p1: n, t0: 0, t1: near / source }); + if (middle > 0 && sourceMiddle > 0) { + const t0 = near / source; + const t1 = (source - far) / source; + // Whole tiles of the frame's own size, the last one cut off where the middle ends. Sampling + // with UVs past 1 would do it in one quad, but a frame is a cut-out of a sheet: past its edge + // lies the neighbouring cell, not the frame again. + const tiles = fill === 'repeat' ? Math.ceil(middle / sourceMiddle) : 1; + if (fill === 'repeat' && tiles <= MAX_TILES) { + for (let i = 0; i < tiles; i++) { + const p0 = n + i * sourceMiddle; + const p1 = Math.min(n + middle, p0 + sourceMiddle); + spans.push({ p0, p1, t0, t1: t0 + ((p1 - p0) / sourceMiddle) * (t1 - t0) }); + } + } else { + spans.push({ p0: n, p1: n + middle, t0, t1 }); + } + } + if (f > 0) spans.push({ p0: target - f, p1: target, t0: (source - far) / source, t1: 1 }); + return spans; +} + +/** + * Positions in 0..1 of the sprite's box, as the unit quad they replace: the mesh carries the + * sprite's size in its scale, so the geometry must not. + */ +export function sliceGeometry( + slice: NineSlice, + w: number, + h: number, +): { positions: Float32Array; uvs: Float32Array; indices: Uint32Array } { + const columns = sliceSpans(w, slice.sourceW, slice.border.left, slice.border.right, slice.fill); + const rows = sliceSpans(h, slice.sourceH, slice.border.top, slice.border.bottom, slice.fill); + const positions: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + for (const row of rows) { + for (const column of columns) { + const base = positions.length / 2; + positions.push( + column.p0 / w, + row.p0 / h, + column.p1 / w, + row.p0 / h, + column.p1 / w, + row.p1 / h, + column.p0 / w, + row.p1 / h, + ); + uvs.push(column.t0, row.t0, column.t1, row.t0, column.t1, row.t1, column.t0, row.t1); + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + } + } + return { + positions: new Float32Array(positions), + uvs: new Float32Array(uvs), + indices: new Uint32Array(indices), + }; +} + +/** + * What the geometry depends on. Rebuilding on every frame would throw away a buffer the GPU + * already has; a sprite that only moves keeps the one it was given. + */ +export function sliceKey(slice: NineSlice, w: number, h: number): string { + const b = slice.border; + return `${b.left},${b.top},${b.right},${b.bottom}|${slice.fill}|${slice.sourceW}×${slice.sourceH}|${round(w)}×${round(h)}`; +} + +/** Sub-pixel changes of size do not change the picture, and would rebuild on every mouse move. */ +function round(value: number): number { + return Math.round(value * 4) / 4; +} diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 03c0cb4d..4d31b766 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -10,8 +10,10 @@ import { } from '../core/properties/bounds'; import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider'; import { SHEET_EDGE } from '../core/shader/nodes'; +import type { NineSlice } from '../core/model/texture'; import { Theme } from '../ui'; import type { DrawItem } from './items'; +import { sliceGeometry, sliceKey } from './nineSlice'; import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders'; import type { TextureLibrary } from './TextureLibrary'; @@ -65,15 +67,39 @@ export function syncSpriteMesh( const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE; const texture = item.texture ? textures.get(item.texture, edge) : null; placeNode(mesh, item.m, size.w, size.h); + // Only once there is a picture: slicing a placeholder would cut up the plain colour for nothing. + syncGeometry(mesh, texture ? (item.texture?.slice ?? null) : null, size); mesh.texture = texture ?? Texture.WHITE; mesh.tint = Number(item.props.color ?? 0xffffff); syncShader(mesh, item.props, shaders); } -/** `Mesh.destroy` leaves the shared geometry alone and only lets go of its shader. */ +/** + * The geometry a sliced sprite needs, built only when what it depends on changed. Keyed outside + * the mesh so nothing of this leaks into Pixi's own object, and so a mesh that loses its slice + * goes back to the shared quad rather than keeping a private copy of it. + */ +const sliceKeys = new WeakMap(); + +function syncGeometry(mesh: Mesh, slice: NineSlice | null, size: { w: number; h: number }): void { + const key = slice ? sliceKey(slice, size.w, size.h) : ''; + if ((sliceKeys.get(mesh) ?? '') === key) return; + const previous = mesh.geometry; + mesh.geometry = slice ? new MeshGeometry(sliceGeometry(slice, size.w, size.h)) : UNIT_QUAD; + if (previous !== UNIT_QUAD) previous.destroy(); + if (key) sliceKeys.set(mesh, key); + else sliceKeys.delete(mesh); +} + +/** + * `Mesh.destroy` never destroys its geometry, shared or not – so the shared quad survives, and a + * sliced sprite's own buffers have to be let go of here. + */ export function disposeSpriteMesh(mesh: Mesh): void { disposeShader(mesh); + const geometry = mesh.geometry; mesh.destroy(); + if (geometry !== UNIT_QUAD) geometry.destroy(); } /** diff --git a/tests/core/frame-commands.test.ts b/tests/core/frame-commands.test.ts index 2667c4e9..c6312fea 100644 --- a/tests/core/frame-commands.test.ts +++ b/tests/core/frame-commands.test.ts @@ -6,6 +6,7 @@ import { RemoveFrameCommand, RenameFrameCommand, SetFrameRectCommand, + SetFrameSliceCommand, SliceFramesCommand, } from '../../src/core/commands/frameCommands'; import type { TextureAsset, TextureFrame } from '../../src/core/model/Asset'; @@ -266,6 +267,39 @@ describe('SliceFramesCommand', () => { expect(sheet.frames()).toEqual([]); }); + it('slices a frame, and undo takes the keys away again rather than zeroing them', () => { + const sheet = makeSheet(); + const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 }); + const frame = () => sheet.frames().find((f) => f.id === frameId)!; + + sheet.stack.execute( + new SetFrameSliceCommand(sheet.textureId, frameId, { + border: { left: 8, top: 8, right: 8, bottom: 8 }, + fill: 'repeat', + }), + ); + expect(frame().border).toEqual({ left: 8, top: 8, right: 8, bottom: 8 }); + expect(frame().fill).toBe('repeat'); + + sheet.stack.undo(); + // Absent, not zero: a frame nobody sliced saves exactly as it did before #41. + expect(frame().border).toBeUndefined(); + expect(frame().fill).toBeUndefined(); + }); + + it('keeps two opposite borders from eating the whole frame', () => { + const sheet = makeSheet(); + const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 }); + sheet.stack.execute( + new SetFrameSliceCommand(sheet.textureId, frameId, { + border: { left: 40, top: -5, right: 40, bottom: 2.6 }, + fill: 'stretch', + }), + ); + // Scaled down together to leave one pixel between them; a negative one is none. + expect(sheet.frames()[0]?.border).toEqual({ left: 12, top: 0, right: 11, bottom: 3 }); + }); + it('refuses an asset that is missing, and one that is not a texture', () => { const sheet = makeSheet(); const sprite = new CreateAssetCommand('sprite', 'Box'); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index b45b1a94..83f5adf3 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -54,6 +54,57 @@ describe('serialize', () => { expect(machine.scope).toBeUndefined(); }); + /** #41: the borders are part of the frame, and a frame nobody sliced still saves without them. */ + it('round-trips the nine-slice of a frame and repairs an impossible one', () => { + const file = `${'ab'.repeat(32)}.png`; + const loaded = deserializeProject({ + assets: [ + { + id: 't1', + type: 'texture', + name: 'Sheet', + file, + width: 64, + height: 64, + frames: [ + { + id: 'f1', + name: 'Button', + x: 0, + y: 0, + w: 24, + h: 24, + border: { left: 8, right: 8 }, + fill: 'repeat', + }, + { id: 'f2', name: 'Plain', x: 24, y: 0, w: 24, h: 24 }, + { + id: 'f3', + name: 'Greedy', + x: 0, + y: 24, + w: 8, + h: 8, + border: { top: 99, bottom: 99 }, + fill: 'wat', + }, + ], + }, + ], + }); + const texture = loaded.assets[0]!; + if (texture.type !== 'texture') throw new Error('wrong types'); + const [sliced, plain, greedy] = texture.frames; + expect(sliced?.border).toEqual({ left: 8, top: 0, right: 8, bottom: 0 }); + expect(sliced?.fill).toBe('repeat'); + expect(plain?.border).toBeUndefined(); + expect(plain?.fill).toBeUndefined(); + // Measured against the frame, not the image, and never leaving it without a middle. + expect(greedy?.border).toEqual({ left: 0, top: 4, right: 0, bottom: 3 }); + expect(greedy?.fill).toBeUndefined(); + expect(roundTrip(loaded)).toEqual(loaded); + }); + it('repairs broken documents', () => { const project = deserializeProject( { diff --git a/tests/core/texture.test.ts b/tests/core/texture.test.ts index df3ba46a..dd0a04a7 100644 --- a/tests/core/texture.test.ts +++ b/tests/core/texture.test.ts @@ -52,7 +52,7 @@ describe('resolveTexture', () => { sprite.props.texture = texture.id; // A whole-texture reference carries no rectangle. - const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48 }; + const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48, slice: null }; expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: true }); texture.props.filter = 'nearest'; expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: false }); @@ -71,8 +71,22 @@ describe('resolveTexture', () => { rect: { x: 16, y: 8, w: 32, h: 24 }, sourceWidth: 64, sourceHeight: 48, + // An unsliced frame carries no slice: what every document written before #41 says. + slice: null, }); + // Sliced, it carries the borders and the fill the renderer needs, measured in the frame. + texture.frames[0]!.border = { left: 4, top: 3, right: 4, bottom: 3 }; + texture.frames[0]!.fill = 'repeat'; + expect(resolveTexture(fixture.project, sprite.props)?.slice).toEqual({ + border: { left: 4, top: 3, right: 4, bottom: 3 }, + fill: 'repeat', + sourceW: 32, + sourceH: 24, + }); + delete texture.frames[0]!.border; + delete texture.frames[0]!.fill; + // A frame that is gone dangles like any other reference: no picture, not a wrong one. texture.frames.length = 0; expect(resolveTexture(fixture.project, sprite.props)).toBeNull(); diff --git a/tests/editor/sheet-view.test.ts b/tests/editor/sheet-view.test.ts index 03cbda35..51c1abc1 100644 --- a/tests/editor/sheet-view.test.ts +++ b/tests/editor/sheet-view.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { FrameRect } from '../../src/core/model/texture'; import { + borderAt, + borderLine, + dragBorder, dragFrame, fitView, type FrameHandle, @@ -138,3 +141,33 @@ describe('dragging a frame', () => { expect(dragFrame(dragFrame(RECT, handle, 3, 4), handle, -3, -4)).toEqual(RECT); }); }); + +describe('nine-slice border lines', () => { + const BORDER = { left: 4, top: 6, right: 8, bottom: 10 }; + + it('runs each line beside its own edge, the far ones measured from the far side', () => { + expect(borderLine(RECT, BORDER, 'left')).toEqual({ x1: 14, y1: 20, x2: 14, y2: 50 }); + expect(borderLine(RECT, BORDER, 'right')).toEqual({ x1: 42, y1: 20, x2: 42, y2: 50 }); + expect(borderLine(RECT, BORDER, 'top')).toEqual({ x1: 10, y1: 26, x2: 50, y2: 26 }); + expect(borderLine(RECT, BORDER, 'bottom')).toEqual({ x1: 10, y1: 40, x2: 50, y2: 40 }); + }); + + it('grabs a line only near it and only within the frame', () => { + expect(borderAt(RECT, BORDER, { x: 14, y: 35 }, 2)).toBe('left'); + expect(borderAt(RECT, BORDER, { x: 30, y: 40 }, 2)).toBe('bottom'); + expect(borderAt(RECT, BORDER, { x: 30, y: 35 }, 2)).toBeNull(); + // Far outside the frame the lines are not offered, however close the axis would be. + expect(borderAt(RECT, BORDER, { x: 14, y: 200 }, 2)).toBeNull(); + }); + + it('moves the near border with the pointer and the far one against it', () => { + // Both grow when the pointer goes towards the middle, which is what the eye expects of a line + // that is dragged inwards. + expect(dragBorder(BORDER, 'left', 3, 0).left).toBe(7); + expect(dragBorder(BORDER, 'right', -3, 0).right).toBe(11); + expect(dragBorder(BORDER, 'top', 0, 3).top).toBe(9); + expect(dragBorder(BORDER, 'bottom', 0, -3).bottom).toBe(13); + // And each line moves only its own border. + expect(dragBorder(BORDER, 'left', 3, 3)).toEqual({ ...BORDER, left: 7 }); + }); +}); diff --git a/tests/viewport/nine-slice.test.ts b/tests/viewport/nine-slice.test.ts new file mode 100644 index 00000000..f0cc536c --- /dev/null +++ b/tests/viewport/nine-slice.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import type { NineSlice } from '../../src/core/model/texture'; +import { MAX_TILES, sliceGeometry, sliceKey, sliceSpans } from '../../src/viewport/nineSlice'; + +const slice = (border: Partial, fill: NineSlice['fill'] = 'stretch'): NineSlice => ({ + border: { left: 0, top: 0, right: 0, bottom: 0, ...border }, + fill, + sourceW: 24, + sourceH: 24, +}); + +describe('sliceSpans', () => { + it('keeps the corners at their source size and gives the rest to the middle', () => { + const spans = sliceSpans(200, 24, 8, 8, 'stretch'); + expect(spans.map((s) => [s.p0, s.p1])).toEqual([ + [0, 8], + [8, 192], + [192, 200], + ]); + // The UVs cut the frame at the same pixels, so the corner shows the corner. + expect(spans.map((s) => [s.t0, s.t1])).toEqual([ + [0, 1 / 3], + [1 / 3, 2 / 3], + [2 / 3, 1], + ]); + }); + + it('leaves out a part that has no room: a border of zero is no quad at all', () => { + expect(sliceSpans(200, 24, 0, 8, 'stretch').map((s) => [s.p0, s.p1])).toEqual([ + [0, 192], + [192, 200], + ]); + // Exactly as wide as its two borders: middle gone, the two corners meet. + expect(sliceSpans(16, 24, 8, 8, 'stretch').map((s) => [s.p0, s.p1])).toEqual([ + [0, 8], + [8, 16], + ]); + }); + + it('scales both borders down together when the sprite is smaller than they are', () => { + const spans = sliceSpans(8, 24, 8, 8, 'stretch'); + // Never overlapping corners: 4 + 4 rather than 8 + 8 on a box of 8. + expect(spans.map((s) => [s.p0, s.p1])).toEqual([ + [0, 4], + [4, 8], + ]); + // The UVs are untouched by the squeeze: each corner still shows its own eight pixels. + expect(spans.map((s) => [s.t0, s.t1])).toEqual([ + [0, 1 / 3], + [2 / 3, 1], + ]); + }); + + it('repeats the middle in whole tiles of the frame and cuts the last one', () => { + const spans = sliceSpans(50, 24, 8, 8, 'repeat'); + const middle = spans.slice(1, -1); + expect(middle.map((s) => [s.p0, s.p1])).toEqual([ + [8, 16], + [16, 24], + [24, 32], + [32, 40], + [40, 42], + ]); + // The cut tile shows only as much of the picture as it covers – a quarter of the eight pixels. + expect(middle.at(-1)?.t1).toBeCloseTo(1 / 3 + (2 / 8) * (1 / 3), 10); + // Every tile starts at the middle's own first pixel; none samples past the frame. + expect(middle.every((s) => s.t0 === 1 / 3 && s.t1 <= 2 / 3)).toBe(true); + }); + + it('stretches rather than building a tile for every pixel of a huge sprite', () => { + // A middle of 14 source pixels: exactly `MAX_TILES` of them still tile, one more stretches. + const many = sliceSpans(10 + MAX_TILES * 14, 24, 5, 5, 'repeat'); + expect(many.length).toBe(2 + MAX_TILES); + const tooMany = sliceSpans(10 + (MAX_TILES + 1) * 14, 24, 5, 5, 'repeat'); + expect(tooMany.length).toBe(3); + }); + + it('answers nothing for a box or a picture of no size', () => { + expect(sliceSpans(0, 24, 8, 8, 'stretch')).toEqual([]); + expect(sliceSpans(200, 0, 8, 8, 'stretch')).toEqual([]); + }); +}); + +describe('sliceGeometry', () => { + it('builds one quad per part, in the 0..1 box the unit quad covers', () => { + const { positions, uvs, indices } = sliceGeometry( + slice({ left: 8, top: 8, right: 8, bottom: 8 }), + 200, + 200, + ); + expect(indices.length).toBe(9 * 6); + expect(positions.length).toBe(9 * 8); + expect(uvs.length).toBe(positions.length); + // Fractions, not units: the sprite's size lives in the mesh's scale. + expect(Math.min(...positions)).toBe(0); + expect(Math.max(...positions)).toBe(1); + // The first quad is the top-left corner: 8 of 200 units, showing 8 of 24 pixels. + // Close, not equal: the buffers are Float32, where neither 0.04 nor a third is exact. + expect(positions[2]).toBeCloseTo(0.04, 6); + expect(uvs[2]).toBeCloseTo(1 / 3, 6); + expect([positions[0], positions[1], positions[3]]).toEqual([0, 0, 0]); + }); + + it('drops the rows and columns that have no room, keeping the quads rectangular', () => { + const { indices } = sliceGeometry(slice({ left: 8, right: 8 }), 200, 200); + // Three columns, one row – nothing is cut horizontally. + expect(indices.length).toBe(3 * 6); + }); +}); + +describe('sliceKey', () => { + it('changes with anything the geometry is built from, and not with a sub-pixel of size', () => { + const base = slice({ left: 8, right: 8 }); + expect(sliceKey(base, 200, 100)).toBe(sliceKey(base, 200.1, 100)); + expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(base, 201, 100)); + expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(slice({ left: 8, right: 7 }), 200, 100)); + expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(slice({ left: 8, right: 8 }, 'repeat'), 200, 100)); + }); +}); diff --git a/tools/e2e/37-nine-slice.mjs b/tools/e2e/37-nine-slice.mjs new file mode 100644 index 00000000..c7778507 --- /dev/null +++ b/tools/e2e/37-nine-slice.mjs @@ -0,0 +1,244 @@ +import { colourDistance } from '../lib/harness.mjs'; + +/** + * Nine-slice frames (#41). The borders live on the frame of a texture, and what only a running + * editor can answer is in here: that a corner keeps its pixel size while the box grows, that the + * same picture without borders does not, and that "repeat" really tiles the middle instead of + * stretching it. All three are read off the screen, because all three are about the geometry a + * sprite is drawn with – no unit test can see that. + */ + +/** + * A 24×24 button: an 8-pixel red ring around a blue middle, and a 2-pixel white stripe down the + * left of that middle. The stripe is what tells a tiled middle from a stretched one – tiled it + * comes back every 8 units, stretched there is exactly one of it. + */ +const PASTE_BUTTON = `(async () => { + const canvas = document.createElement('canvas'); + canvas.width = 24; + canvas.height = 24; + const g = canvas.getContext('2d'); + g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 24, 24); + g.fillStyle = '#466ef0'; g.fillRect(8, 8, 8, 8); + g.fillStyle = '#ffffff'; g.fillRect(8, 8, 2, 8); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + const transfer = new DataTransfer(); + transfer.items.add(new File([blob], 'button.png', { type: 'image/png' })); + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); +})()`; + +const RED = '#e63c3c'; +const BLUE = '#466ef0'; +const WHITE = '#ffffff'; +const SIZE = 200; + +export async function nineSlice(t) { + const { check, sleep, json, evaluate, mutate, clickText, drag, press, shot, showTab, settle } = t; + const { undo, redo } = t; + + await press('Escape'); + await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); + await evaluate('__gm.state.select(null)'); + + // 37a. A picture of its own, and one frame over the whole of it: the borders are a property of + // the frame, so a texture nobody cut has nothing to slice. + await evaluate(PASTE_BUTTON); + await sleep(2500); + const texture = await json( + "(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" + + ' return a ? { id: a.id, w: a.width, h: a.height } : null; })()', + ); + check( + 'the button picture is imported at its own size', + texture?.w === 24 && texture?.h === 24, + JSON.stringify(texture), + ); + await mutate(` + const texture = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}); + texture.props.filter = 'nearest'; + texture.frames.push({ id: 'ns-frame', name: 'Button', x: 0, y: 0, w: 24, h: 24 });`); + await evaluate( + `__gm.state.select({ kind: 'frame', assetId: ${JSON.stringify(texture.id)}, frameId: 'ns-frame' })`, + ); + await showTab('texture'); + await settle(); + + const frame = () => + json( + `(() => { const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})` + + `.frames.find((f) => f.id === 'ns-frame');` + + ' return { x: f.x, y: f.y, w: f.w, h: f.h, border: f.border ?? null, fill: f.fill ?? null }; })()', + ); + check('a frame starts unsliced, with no borders at all', (await frame()).border === null); + + // 37b. Dragging the border lines in the Texture tab. Grabbed away from the frame's own handles: + // at zero the line sits on the frame's edge, and the handle there resizes the frame itself. + const before = await frame(); + const from = await json('__gm.texture.pointAt(0, 6)'); + const to = await json('__gm.texture.pointAt(8, 6)'); + await drag(from.x, from.y, to.x, to.y); + await settle(); + const topFrom = await json('__gm.texture.pointAt(6, 0)'); + const topTo = await json('__gm.texture.pointAt(6, 8)'); + await drag(topFrom.x, topFrom.y, topTo.x, topTo.y); + await settle(); + const dragged = await frame(); + check( + 'dragging the two lines sets the borders and leaves the frame where it was', + dragged.border?.left === 8 && + dragged.border?.top === 8 && + dragged.x === before.x && + dragged.y === before.y && + dragged.w === before.w && + dragged.h === before.h, + JSON.stringify(dragged), + ); + await shot('37-frame-borders'); + + // One drag, one entry – and the last one takes the keys away again rather than writing zeros: + // a frame nobody sliced must read exactly as it did before, or every document would grow four + // numbers it never asked for. + await undo(); + const halfUndone = await frame(); + await undo(); + const undone = await frame(); + check( + 'each border drag is one undo step, and the last one leaves no slice at all', + halfUndone.border?.left === 8 && halfUndone.border?.top === 0 && undone.border === null, + JSON.stringify({ halfUndone, undone }), + ); + await redo(); + await redo(); + await mutate(` + const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}) + .frames.find((f) => f.id === 'ns-frame'); + f.border = { left: 8, top: 8, right: 8, bottom: 8 };`); + + // 37c. A sprite wearing the frame, far bigger than the picture. Cloned from one that exists, + // because a literal misses the fields the viewport needs (see step 12). + await mutate(` + const project = __gm.state.project; + const sprite = structuredClone(project.assets.find((a) => a.type === 'sprite')); + sprite.id = 'ns-sprite'; + sprite.name = 'Button'; + sprite.props = { ...sprite.props, width: ${SIZE}, height: ${SIZE}, color: 0xffffff, texture: 'ns-frame' }; + project.assets.push(sprite); + + const scene = __gm.state.activeScene; + const object = structuredClone(scene.objects.find((o) => o.id === 'otex') ?? scene.objects[0]); + object.id = 'ons'; + object.name = 'Button'; + object.assetId = 'ns-sprite'; + object.overrides = {}; + object.position = { ...object.position, z: 950 }; + scene.objects.push(object); + // The stage to itself: the fixture's other objects draw gizmos over everything – a collider + // outline, the behavior icons – and a pixel reading cannot tell those from the picture. + window.__nsOthers = scene.objects.filter((o) => o.id !== 'ons'); + scene.objects = scene.objects.filter((o) => o.id === 'ons'); + __gm.state.select(null)`); + await showTab('scene'); + await sleep(600); + + const box = await json("__gm.objectRect('ons')"); + check('the nine-sliced sprite is on screen', Boolean(box) && box.w > 100, JSON.stringify(box)); + if (!box) return; + const scale = box.w / SIZE; + // A run of pixels from just inside the left edge: one read rather than a hundred round trips. + // Across the middle of the sprite, which is where the parts between the corners are. + const rowY = Math.round(box.y); + const rowAcross = (count) => json(`__gm.pixelRow(${Math.round(box.x - box.w / 2 + 1)}, ${rowY}, ${count})`); + const runOf = (row, colour) => { + let n = 0; + while (n < row.length && colourDistance(row[n], colour) < 60) n++; + return n; + }; + + const sliced = await rowAcross(120); + const slicedEdge = runOf(sliced, RED); + check( + 'the border keeps its own pixel size while the sprite is eight times the picture', + Math.abs(slicedEdge - 8 * scale) <= 2 && colourDistance(sliced[Math.round(100 * scale)], BLUE) < 60, + JSON.stringify({ run: slicedEdge, expected: 8 * scale, row: sliced.slice(0, 16) }), + ); + await shot('38-nine-sliced-sprite'); + + // The counter-proof: the very same picture without borders. Without it, "the edge is 8 wide" + // could just as well mean the measurement is wrong. + await mutate(` + const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}) + .frames.find((f) => f.id === 'ns-frame'); + delete f.border;`); + await sleep(400); + const stretched = await rowAcross(120); + const stretchedEdge = runOf(stretched, RED); + check( + 'and without the borders the very same picture scales its edge with the box', + stretchedEdge > 8 * scale * 4 && Math.abs(stretchedEdge - (8 / 24) * SIZE * scale) <= 4, + JSON.stringify({ sliced: slicedEdge, stretched: stretchedEdge }), + ); + + // 37d. Stretch against repeat, picked in the inspector. The white stripe down the left of the + // middle is the witness: stretched there is one of it, tiled there is one per 8 units. + await mutate(` + const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}) + .frames.find((f) => f.id === 'ns-frame'); + f.border = { left: 8, top: 8, right: 8, bottom: 8 };`); + const stripes = async () => { + const row = await rowAcross(Math.round(SIZE * scale) - 2); + let runs = 0; + for (let i = 0; i < row.length; i++) { + const white = colourDistance(row[i], WHITE) < 60; + const wasWhite = i > 0 && colourDistance(row[i - 1], WHITE) < 60; + if (white && !wasWhite) runs++; + } + return runs; + }; + const stretchedStripes = await stripes(); + + await evaluate( + `__gm.state.select({ kind: 'frame', assetId: ${JSON.stringify(texture.id)}, frameId: 'ns-frame' })`, + ); + await showTab('texture'); + await clickText('Stretch'); + await clickText('Repeat'); + await settle(); + const picked = await frame(); + check( + 'the inspector offers the fill beside the borders, and picking it writes the frame', + picked.fill === 'repeat', + JSON.stringify(picked), + ); + await showTab('scene'); + await sleep(400); + const repeatedStripes = await stripes(); + check( + 'a repeated middle tiles the picture instead of stretching it once', + stretchedStripes === 1 && repeatedStripes > 15, + JSON.stringify({ stretched: stretchedStripes, repeated: repeatedStripes }), + ); + await shot('39-nine-slice-repeat'); + + // 37e. Narrower than its own two borders: they are scaled down together until the corners meet, + // so the row across it is all corner and no middle. Kept tall, so the reading stays in the + // band the gizmos leave alone. + await mutate(` + Object.assign(__gm.state.project.assets.find((a) => a.id === 'ns-sprite').props, { width: 8 });`); + await sleep(400); + const small = await json("__gm.objectRect('ons')"); + const seam = await json( + `__gm.pixelRow(${Math.round(small.x - small.w / 2 + 1)}, ${rowY}, ${Math.max(2, Math.round(small.w) - 2)})`, + ); + check( + 'a sprite narrower than its two borders is all corner, with no middle between them', + seam.length > 2 && seam.every((c) => colourDistance(c, RED) < 60), + JSON.stringify({ w: small.w, seam }), + ); + + // The scene back as it was, without the sprite this step brought: later readers count what is in it. + await mutate(` + __gm.state.activeScene.objects = window.__nsOthers ?? []; + delete window.__nsOthers; + __gm.state.select(null)`); + await settle(); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index ffee8f90..0abf0bb2 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -39,6 +39,7 @@ import { renderOnChange } from './33-render-on-change.mjs'; import { sound } from './34-sound.mjs'; import { loadScene } from './35-load-scene.mjs'; import { graphGroups } from './36-graph-groups.mjs'; +import { nineSlice } from './37-nine-slice.mjs'; export const STEPS = [ hierarchy, @@ -77,4 +78,5 @@ export const STEPS = [ sound, loadScene, graphGroups, + nineSlice, ];