diff --git a/README.md b/README.md index 2f23bb93..52298741 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1731 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1732 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/frameCommands.ts b/src/core/commands/frameCommands.ts index c6a3c5f5..5f4dc263 100644 --- a/src/core/commands/frameCommands.ts +++ b/src/core/commands/frameCommands.ts @@ -3,22 +3,23 @@ * dragging in a zoomed view, so callers produce fractional and inverted rectangles routinely. */ import { newId } from '../ids'; -import type { TextureAsset, TextureFrame } from '../model/Asset'; +import type { PictureCut, TextureAsset, TextureFrame } from '../model/Asset'; +import { type CutTarget, findCut, type FoundCut } from '../model/cutTarget'; import type { Project } from '../model/Project'; import type { Vec2 } from '../model/Properties'; import { + type CutSlice, frameRect, type FrameRect, - type FrameSlice, nextFrameNames, normalizeFrameRect, - rawFrameSlice, + rawCutSlice, frameCollision, - restoreFrameSlice, + restoreCutSlice, + setCutSlice, + setCutSortLine, setFrameCollision, setFrameRect, - setFrameSlice, - setFrameSortLine, sliceFrames, type SliceOptions, uniqueFrameName, @@ -96,54 +97,60 @@ export class SetFrameRectCommand implements Command { } } +/** A texture frame or a drawing as a whole (#308), with the reason when it is neither. */ +function requireCut(project: Project, target: CutTarget): FoundCut { + if (target.frameId !== undefined) requireFrame(requireTexture(project, target.assetId), target.frameId); + const found = findCut(project, target); + if (!found) throw new CommandError(`${requireAsset(project, target.assetId).name} cannot be sliced`); + return found; +} + /** - * The four borders and the fill of one frame (#41), in one command: dragging a border line and + * The four borders and the fill of one picture (#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 { +export class SetSliceCommand implements Command { readonly label = 'Nine-slice'; - private previous: Pick = {}; + private previous: Pick = {}; constructor( - private readonly assetId: string, - private readonly frameId: string, - private readonly slice: FrameSlice, + private readonly target: CutTarget, + private readonly slice: CutSlice, ) {} execute(project: Project): void { - const frame = requireFrame(requireTexture(project, this.assetId), this.frameId); - this.previous = rawFrameSlice(frame); - setFrameSlice(frame, this.slice); + const { cut, size } = requireCut(project, this.target); + this.previous = rawCutSlice(cut); + setCutSlice(cut, size, this.slice); } undo(project: Project): void { - restoreFrameSlice(requireFrame(requireTexture(project, this.assetId), this.frameId), this.previous); + restoreCutSlice(requireCut(project, this.target).cut, this.previous); } } -/** Where a sprite wearing the frame stands when sorted by Y (#228). */ -export class SetFrameSortLineCommand implements Command { +/** Where a sprite wearing the picture stands when sorted by Y (#228). */ +export class SetSortLineCommand implements Command { readonly label = 'Sort line'; private previous: number | undefined; constructor( - private readonly assetId: string, - private readonly frameId: string, + private readonly target: CutTarget, private readonly line: number, ) {} execute(project: Project): void { - const frame = requireFrame(requireTexture(project, this.assetId), this.frameId); + const { cut, size } = requireCut(project, this.target); if (!Number.isFinite(this.line)) throw new CommandError('The sort line is not a number'); - this.previous = frame.sortLine; - setFrameSortLine(frame, this.line); + this.previous = cut.sortLine; + setCutSortLine(cut, size, this.line); } undo(project: Project): void { - const frame = requireFrame(requireTexture(project, this.assetId), this.frameId); - if (this.previous === undefined) delete frame.sortLine; - else frame.sortLine = this.previous; + const { cut } = requireCut(project, this.target); + if (this.previous === undefined) delete cut.sortLine; + else cut.sortLine = this.previous; } } diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index e33c7bc0..15b53b19 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -161,30 +161,34 @@ export interface FrameBorders { } /** - * `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. + * How one picture is cut for drawing and sorting: a texture frame, or a drawing as a whole (#308). + * `border` and `fill` are absent on a picture nobody sliced, which is what every document written + * before #41 says – read them through `cutBorders`/`cutFill`, never off the object. */ -export interface TextureFrame { +export interface PictureCut { + border?: FrameBorders; + fill?: FrameFill; + /** + * Where a sprite wearing this picture stands when a layer sorts by Y (#228): whole picture pixels + * up from its bottom edge. Absent is 0, the bottom edge itself. + */ + sortLine?: number; +} + +/** `id` shares the generator with asset ids and is unique across the document (see `model/texture.ts`). */ +export interface TextureFrame extends PictureCut { id: string; name: string; x: number; y: number; w: number; h: number; - border?: FrameBorders; - fill?: FrameFill; /** * The outlines a tile made of this frame blocks with (#141), in pixels from the frame's top-left * corner, so they move with the frame. Several, because an autotile's block holds several shapes * – its bordered tile and its notches. Absent is none; one of fewer than three corners collides as none. */ collision?: Vec2[][]; - /** - * Where a sprite wearing this frame stands when a layer sorts by Y (#228): whole image pixels up - * from the frame's bottom edge. Absent is 0, the bottom edge itself. - */ - sortLine?: number; } /** @@ -436,9 +440,9 @@ export interface VectorShape { * shapes never leave this asset. The shapes are intrinsic data, as a texture's frames are. * * They stand in layers (#190), the last layer on top, and a drawing always has one. How the tree is - * read and edited is `model/vectorTree.ts`. + * read and edited is `model/vectorTree.ts`. Its cut (#308) is measured in canvas pixels. */ -export interface VectorArtAsset extends AssetBase { +export interface VectorArtAsset extends AssetBase, PictureCut { type: 'vectorart'; layers: VectorLayer[]; } @@ -490,9 +494,10 @@ export interface PixelFrame { * A picture painted pixel by pixel (#130). Every pixel is an index into the palette the `palette` row * names, 0 being transparent. The canvas size is intrinsic here, not a property as with vector art: * resizing rewrites every cel, so it is a command with an anchor rather than a free row. All frames - * are drawn side by side into one generated picture (#185), and a frame reads as a cut of it. + * are drawn side by side into one generated picture (#185), and a frame reads as a cut of it. One + * cut (#308) serves every frame: they share the canvas size. */ -export interface PixelArtAsset extends AssetBase { +export interface PixelArtAsset extends AssetBase, PictureCut { type: 'pixelart'; width: number; height: number; diff --git a/src/core/model/cutTarget.ts b/src/core/model/cutTarget.ts new file mode 100644 index 00000000..05fc778f --- /dev/null +++ b/src/core/model/cutTarget.ts @@ -0,0 +1,39 @@ +/** + * Which picture a nine-slice or a sort line is set on (#308): a frame of a texture, or a drawing + * as a whole. Apart from `texture.ts`, whose rules it hands the size to, because the size of vector + * art comes from `vectorart.ts`, which reaches `texture.ts` itself. + */ +import type { PictureCut, PixelArtAsset, VectorArtAsset } from './Asset'; +import { findAsset, type Project } from './Project'; +import type { CutSize } from './texture'; +import { vectorCanvasSize } from './vectorart'; + +/** `frameId` names a frame of the texture `assetId`; left out, the asset itself is the picture. */ +export interface CutTarget { + assetId: string; + frameId?: string; +} + +export interface FoundCut { + cut: PictureCut; + size: CutSize; +} + +/** The cut and the size it is measured in; null when the target is gone or cuts nothing. */ +export function findCut(project: Project, target: CutTarget): FoundCut | null { + const asset = findAsset(project, target.assetId); + if (!asset) return null; + if (target.frameId !== undefined) { + const frame = asset.type === 'texture' ? asset.frames.find((f) => f.id === target.frameId) : undefined; + return frame ? { cut: frame, size: frame } : null; + } + if (asset.type !== 'pixelart' && asset.type !== 'vectorart') return null; + return { cut: asset, size: drawnCutSize(asset) }; +} + +/** The size one picture of a drawing has: a pixel frame, or the vector canvas. */ +export function drawnCutSize(asset: PixelArtAsset | VectorArtAsset): CutSize { + if (asset.type === 'pixelart') return { w: asset.width, h: asset.height }; + const { width, height } = vectorCanvasSize(asset.props); + return { w: width, h: height }; +} diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 197d9578..3252a465 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -1,4 +1,4 @@ -import type { Asset, FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset'; +import type { Asset, FrameBorders, FrameFill, PictureCut, TextureAsset, TextureFrame } from './Asset'; import { generatedKey, generatedPictureOf } from './generated'; import { findPixelFrame, pixelFrameRect } from './pixelart'; import { type PropertyBag, textOf, type Vec2 } from './Properties'; @@ -31,12 +31,18 @@ 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". */ + /** Set when the picture this ref names is sliced (#41); null means "draw it over the whole box". */ slice: NineSlice | null; - /** The frame's sort line (#228) in image pixels up from its bottom; absent is 0. */ + /** The picture's sort line (#228) in its own pixels up from its bottom; absent is 0. */ sortLine?: number; } +/** The size a cut is measured against: the frame, or the canvas of a drawing (#308). */ +export interface CutSize { + w: number; + h: number; +} + /** * 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 @@ -48,14 +54,14 @@ export interface NineSlice { /** Size of the frame in image pixels; the borders are distances inside it. */ sourceW: number; sourceH: number; - /** Sprite units one border pixel is drawn with – the texture's, shared by all its frames (#96). */ + /** Sprite units one border pixel is drawn with – the asset's, shared by all its frames (#96). */ scale: number; } export const SLICE_SCALE_KEY = 'sliceScale'; -/** The texture's nine-slice scale; anything that is not a size above zero reads as the default 1. */ -function sliceScaleOf(asset: TextureAsset): number { +/** The asset's nine-slice scale; anything that is not a size above zero reads as the default 1. */ +function sliceScaleOf(asset: Asset): number { const value = Number(asset.props[SLICE_SCALE_KEY] ?? 1); return Number.isFinite(value) && value > 0 ? value : 1; } @@ -103,52 +109,59 @@ export function resolvePicture(project: Project, id: string): TextureRef | null /** * An asset that draws its own picture (#185) reads like a texture of its canvas size: the key - * stands where the file name stands, and the size is what a new sprite is measured by. No slicing – - * that is cut out of an uploaded sheet; a frame only where the drawing has frames (pixel art). + * stands where the file name stands, and the size is what a new sprite is measured by. A frame + * only where the drawing has frames (pixel art). Pixel art and vector art carry a cut of their + * own (#308); the whole sheet of several pixel frames is no picture of that size, so it has none. */ function generatedRef(asset: Asset, project: Project, rect: FrameRect | null = null): TextureRef | null { const picture = generatedPictureOf(asset, project); if (!picture) return null; + const strip = asset.type === 'pixelart' && asset.frames.length > 1 && !rect; + const cut = (asset.type === 'pixelart' || asset.type === 'vectorart') && !strip ? asset : null; return { file: generatedKey(asset.id, picture.revision), smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest', rect, sourceWidth: picture.width, sourceHeight: picture.height, - slice: null, + ...(cut ? cutOnRef(cut, rect ?? { w: picture.width, h: picture.height }, asset) : { slice: null }), }; } function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | null { if (!isTextureFile(asset.file)) return null; - const sortLine = frame ? frameSortLine(frame) : 0; return { file: asset.file, smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest', 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, sliceScaleOf(asset)) : null, - ...(sortLine > 0 ? { sortLine } : {}), + ...(frame ? cutOnRef(frame, frame, asset) : { slice: null }), }; } -/** Whole pixels from the frame's bottom edge up to its top edge at most. */ +/** What a ref carries of a cut: the slice, and the sort line only where there is one. */ +function cutOnRef(cut: PictureCut, size: CutSize, asset: Asset): Pick { + const sortLine = cutSortLine(cut, size); + return { slice: nineSliceOf(cut, size, sliceScaleOf(asset)), ...(sortLine > 0 ? { sortLine } : {}) }; +} + +/** Whole pixels from the picture's bottom edge up to its top edge at most. */ export function normalizeSortLine(value: number, h: number): number { const line = Math.round(Number.isFinite(value) ? value : 0); return Math.max(0, Math.min(Math.floor(h), line)); } -/** Read against the frame as it is now, so a frame resized smaller keeps its line inside it. */ -export function frameSortLine(frame: TextureFrame): number { - return normalizeSortLine(frame.sortLine ?? 0, frame.h); +/** Read against the size as it is now, so a frame or canvas made smaller keeps its line inside it. */ +export function cutSortLine(cut: PictureCut, size: CutSize): number { + return normalizeSortLine(cut.sortLine ?? 0, size.h); } -/** 0 removes the key, so a frame nobody gave a line saves as it did before. */ -export function setFrameSortLine(frame: TextureFrame, value: number | undefined): void { - const line = normalizeSortLine(value ?? 0, frame.h); - if (line > 0) frame.sortLine = line; - else delete frame.sortLine; +/** 0 removes the key, so a picture nobody gave a line saves as it did before. */ +export function setCutSortLine(cut: PictureCut, size: CutSize, value: number | undefined): void { + const line = normalizeSortLine(value ?? 0, size.h); + if (line > 0) cut.sortLine = line; + else delete cut.sortLine; } const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 }; @@ -172,23 +185,23 @@ export function setFrameCollision(frame: TextureFrame, outlines: readonly (reado else frame.collision = kept.map((outline) => outline.map((p) => ({ x: p.x, y: p.y }))); } -export function frameBorders(frame: TextureFrame): FrameBorders { - return frame.border ? { ...frame.border } : { ...NO_BORDERS }; +export function cutBorders(cut: PictureCut): FrameBorders { + return cut.border ? { ...cut.border } : { ...NO_BORDERS }; } -export function frameFill(frame: TextureFrame): FrameFill { - return frame.fill === 'stretch' ? 'stretch' : DEFAULT_FILL; +export function cutFill(cut: PictureCut): FrameFill { + return cut.fill === 'stretch' ? 'stretch' : DEFAULT_FILL; } 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. */ -function nineSliceOf(frame: TextureFrame, scale = 1): NineSlice | null { - const border = normalizeBorders(frameBorders(frame), frame.w, frame.h); +/** What a picture is sliced into, or null when nobody sliced it – then a sprite draws it as one piece. */ +function nineSliceOf(cut: PictureCut, size: CutSize, scale = 1): NineSlice | null { + const border = normalizeBorders(cutBorders(cut), size.w, size.h); if (!hasBorders(border)) return null; - return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h, scale }; + return { border, fill: cutFill(cut), sourceW: size.w, sourceH: size.h, scale }; } /** @@ -213,43 +226,43 @@ function fitPair(near: number, far: number, size: number): [number, number] { 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 { +/** The slice settings of a picture, as one value – what an inspector row and a command pass around. */ +export interface CutSlice { border: FrameBorders; fill: FrameFill; } -export function frameSlice(frame: TextureFrame): FrameSlice { - return { border: frameBorders(frame), fill: frameFill(frame) }; +export function cutSlice(cut: PictureCut): CutSlice { + return { border: cutBorders(cut), fill: cutFill(cut) }; } /** - * Writes the slice back, dropping the keys again once they mean nothing: a frame nobody sliced + * Writes the slice back, dropping the keys again once they mean nothing: a picture nobody sliced * reads and saves without them, 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; +export function setCutSlice(cut: PictureCut, size: CutSize, slice: CutSlice): void { + const border = normalizeBorders(slice.border, size.w, size.h); + if (hasBorders(border)) cut.border = border; + else delete cut.border; // Only the fill that differs from `DEFAULT_FILL` is written. Kept even without borders: a fill // chosen before the borders are dragged would otherwise snap back under the hand that just set it. - if (slice.fill !== DEFAULT_FILL) frame.fill = slice.fill; - else delete frame.fill; + if (slice.fill !== DEFAULT_FILL) cut.fill = slice.fill; + else delete cut.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; +/** The slice keys exactly as the picture carries them – absence included, which undo owes back. */ +export function rawCutSlice(cut: PictureCut): Pick { + const raw: Pick = {}; + if (cut.border) raw.border = { ...cut.border }; + if (cut.fill) raw.fill = cut.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; +export function restoreCutSlice(cut: PictureCut, raw: Pick): void { + if (raw.border) cut.border = { ...raw.border }; + else delete cut.border; + if (raw.fill) cut.fill = raw.fill; + else delete cut.fill; } /** diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 74e1b62f..f729fcab 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -444,6 +444,16 @@ function filterRow(smoothByDefault: boolean): PropertyDef { }; } +/** How big the borders of a sliced picture are drawn: 2 makes a 16 px border 32 units wide (#96). */ +const SLICE_SCALE_ROW: PropertyDef = { + key: SLICE_SCALE_KEY, + label: 'Nine-slice scale', + kind: 'number', + default: 1, + min: 0.01, + step: 0.1, +}; + /** Position is deliberately absent: it belongs to scene objects, not to asset types. */ const ASSET_SCHEMAS: Record = { scene: [ @@ -481,11 +491,7 @@ const ASSET_SCHEMAS: Record = { { key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] }, ], // The picture itself is intrinsic data on the asset (see TextureAsset), not a property. - texture: [ - filterRow(true), - // How big the borders of every sliced frame are drawn: 2 makes a 16 px border 32 units wide (#96). - { key: SLICE_SCALE_KEY, label: 'Nine-slice scale', kind: 'number', default: 1, min: 0.01, step: 0.1 }, - ], + texture: [filterRow(true), SLICE_SCALE_ROW], // The shapes are intrinsic data on the asset (see VectorArtAsset); these three say how big the // picture they are drawn onto is and how it is sampled – the same `filter` row a texture has, // because a generated picture goes through the same path (#185). @@ -511,12 +517,14 @@ const ASSET_SCHEMAS: Record = { decimals: 0, }, filterRow(true), + SLICE_SCALE_ROW, ], // Layers, frames and the canvas size are intrinsic data on the asset (see PixelArtAsset). A palette // is shared, so the asset names one; the filter is the texture's row, sharp by default for pixels. pixelart: [ { key: PALETTE_KEY, label: 'Palette', kind: 'asset', default: '', assetTypes: ['palette'] }, filterRow(false), + SLICE_SCALE_ROW, ], // The colours are intrinsic data on the asset (see PaletteAsset). palette: [], diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 3e2c4cde..d313f63f 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -26,6 +26,8 @@ import type { Asset, BehaviorAsset, ContainerAsset, + PictureCut, + PixelArtAsset, PixelFrame, PixelLayer, SceneAsset, @@ -34,6 +36,7 @@ import type { GradientStop, TilemapLayer, VariablesAsset, + VectorArtAsset, VectorGradient, VectorPaint, VectorPoint, @@ -59,14 +62,15 @@ import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import { readScope, readSharedScope } from '../model/Scope'; import type { ObjectGroup, SceneObject } from '../model/SceneObject'; import { + type CutSize, DEFAULT_FILL, isTextureFile, - normalizeBorders, normalizeFrameRect, + setCutSlice, + setCutSortLine, setFrameCollision, - setFrameSlice, - setFrameSortLine, } from '../model/texture'; +import { drawnCutSize } from '../model/cutTarget'; import { pointsOf } from '../model/polygon'; import { canvasSide, PALETTE_MAX } from '../model/pixelart'; import { isCelFile } from '../model/pixelCel'; @@ -248,9 +252,13 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { } else if (type === 'tilemap') { assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) }); } else if (type === 'vectorart') { - assets.push({ id, type, name, props, layers: normalizeVectorLayers(a) }); + const drawing: VectorArtAsset = { id, type, name, props, layers: normalizeVectorLayers(a) }; + readCut(drawing, drawnCutSize(drawing), a); + assets.push(drawing); } else if (type === 'pixelart') { - assets.push({ id, type, name, props, ...normalizePixelArt(a, takenIds) }); + const drawing: PixelArtAsset = { id, type, name, props, ...normalizePixelArt(a, takenIds) }; + readCut(drawing, drawnCutSize(drawing), a); + assets.push(drawing); } else if (type === 'palette') { assets.push({ id, type, name, props, colors: normalizePalette(a.colors) }); } else if (type === 'curve') { @@ -664,31 +672,34 @@ function normalizeTexture( height, ); 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 === 'stretch' ? 'stretch' : DEFAULT_FILL, - }); + // Against the frame, not the image: the borders are distances inside the cut-out. + readCut(frame, rect, f); // Corners that are no numbers fall away; what is left is kept only as an outline (#141). const outlines = collisionOutlines(f.collision).filter((outline) => outline.length >= 3); if (outlines.length > 0) setFrameCollision(frame, outlines); - setFrameSortLine(frame, toNumber(f.sortLine)); frames.push(frame); } return { file, width, height, frames }; } +/** + * The nine-slice and the sort line of a texture frame or a drawing (#308), measured against `size`. + * A picture without them is one nobody sliced, and keeps saying so. + */ +function readCut(cut: PictureCut, size: CutSize, raw: Record): void { + const border = asRecord(raw.border); + setCutSlice(cut, size, { + border: { + left: toNumber(border.left), + top: toNumber(border.top), + right: toNumber(border.right), + bottom: toNumber(border.bottom), + }, + fill: raw.fill === 'stretch' ? 'stretch' : DEFAULT_FILL, + }); + setCutSortLine(cut, size, toNumber(raw.sortLine)); +} + /** * A frame's stored outlines: a list of corner lists, or – as saved before a frame held several – * one corner list, which is one outline. diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index c6881be8..f1f99211 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -57,6 +57,7 @@ import { clearSaved, hasSaved } from '../saves'; import { SoundPreview } from '../sound/SoundPreview'; import type { VariablesOwner } from '../state'; import { aspectActions, sizeCommand } from './aspectAction'; +import { buildCutRows, CUT_ROW_KEYS, refreshCutRows } from './cutSection'; import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory'; import { addNameField, addNote, addTitle, type InspectorHost, sectionHeader } from './host'; import { buildShapeListSection, shapeListSignature } from './shapeSection'; @@ -154,6 +155,8 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void { addTitle(host, 'SCOPE'); buildMachineScope(host, asset); } + // A drawing is sliced and sorted as a whole, with the rows a texture frame has (#308). + if (isDrawing(asset)) buildCutRows(host, { assetId }, 'CANVAS PIXELS'); // The drawing itself: its shapes are picked here as a texture's frames are picked in its tab (#129). if (asset.type === 'vectorart') buildShapeListSection(host, asset); // A font has no properties at all, so without this its inspector would be the name and nothing. @@ -175,13 +178,18 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole host.nameField?.setValue(asset.name); const project = host.state.project; const values = shownValues(host, asset); + const drawing = isDrawing(asset); for (const [key, row] of host.fields) { + if (drawing && CUT_ROW_KEYS.has(key)) continue; row.setValue(values[key] ?? 0); row.setModified(isModified(project, asset, key)); } + if (drawing) refreshCutRows(host, { assetId }); return true; } +const isDrawing = (asset: Asset): boolean => asset.type === 'pixelart' || asset.type === 'vectorart'; + /** The switches, and the render layer (#302): both say whether the object is seen at all. */ const isSwitch = (key: string): boolean => key === RENDER_LAYER_KEY || COMMON_PROPS.some((def) => def.key === key); diff --git a/src/editor/inspector/cutSection.ts b/src/editor/inspector/cutSection.ts new file mode 100644 index 00000000..8a058710 --- /dev/null +++ b/src/editor/inspector/cutSection.ts @@ -0,0 +1,142 @@ +/** + * The NINE-SLICE and SORTING rows of one picture (#41, #228): a frame in the frame inspector, pixel + * art and vector art as a whole in the asset inspector (#308). One set of rows for both. + */ +import { SetSliceCommand, SetSortLineCommand } from '../../core/commands/frameCommands'; +import type { FrameBorders } from '../../core/model/Asset'; +import { type CutTarget, findCut, type FoundCut } from '../../core/model/cutTarget'; +import { type PropertyValue, textOf } from '../../core/model/Properties'; +import { + cutBorders, + cutFill, + cutSlice, + type CutSlice, + cutSortLine, + DEFAULT_FILL, + normalizeSortLine, + setCutSlice, + setCutSortLine, +} from '../../core/model/texture'; +import { createFieldRow, type FieldHandlers } from './fieldFactory'; +import { addNote, addTitle, type InspectorHost } from './host'; +import { previewingHandlers } from './previewHandlers'; + +const BORDER_COMPONENTS = [ + { key: 'left', label: 'Left' }, + { key: 'top', label: 'Top' }, + { key: 'right', label: 'Right' }, + { key: 'bottom', label: 'Bottom' }, +] as const; + +/** The keys the rows stand under in `host.fields`. */ +export const CUT_ROW_KEYS: ReadonlySet = new Set([ + ...BORDER_COMPONENTS.map((component) => `border-${component.key}`), + 'fill', + 'sortLine', +]); + +/** `unit` names the pixels the numbers count: the image's for a frame, the canvas's for a drawing. */ +export function buildCutRows(host: InspectorHost, target: CutTarget, unit: string): void { + addTitle(host, `NINE-SLICE (${unit})`); + for (const component of BORDER_COMPONENTS) borderRow(host, target, component.key, component.label); + fillRow(host, target); + addNote(host, 'All four at zero: the picture is drawn over the whole box, as it always was.'); + + addTitle(host, `SORTING (${unit})`); + sortLineRow(host, target); + addNote(host, 'Where a sprite of this picture stands on a layer sorted by Y, up from the bottom edge.'); +} + +export function refreshCutRows(host: InspectorHost, target: CutTarget): void { + const found = findCut(host.state.project, target); + if (!found) return; + const border = cutBorders(found.cut); + for (const component of BORDER_COMPONENTS) + host.fields.get(`border-${component.key}`)?.setValue(border[component.key]); + host.fields.get('fill')?.setValue(cutFill(found.cut)); + host.fields.get('sortLine')?.setValue(cutSortLine(found.cut, found.size)); +} + +function sortLineRow(host: InspectorHost, target: CutTarget): void { + const found = currentCut(host, target); + const row = createFieldRow( + host, + { key: 'sortLine', label: 'Sort line', kind: 'number', default: 0, min: 0, step: 1, decimals: 0 }, + found ? cutSortLine(found.cut, found.size) : 0, + previewingHandlers(host, { + target: () => currentCut(host, target), + read: ({ cut, size }) => cutSortLine(cut, size), + write: ({ cut, size }, line) => setCutSortLine(cut, size, line), + toModel: (input, { size }) => normalizeSortLine(Number(input), size.h), + commit: (line) => new SetSortLineCommand(target, line), + }), + ); + host.fields.set('sortLine', row); + host.body.add(row.row); +} + +/** + * One border of the nine-slice, in pixels of the picture. 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, target: CutTarget, key: keyof FrameBorders, label: string): void { + const found = currentCut(host, target); + const row = createFieldRow( + host, + { key: `border-${key}`, label, kind: 'number', default: 0, min: 0, step: 1, decimals: 0 }, + found ? cutBorders(found.cut)[key] : 0, + sliceHandlers(host, target, (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, target: CutTarget): void { + const found = currentCut(host, target); + const row = createFieldRow( + host, + { + key: 'fill', + label: 'Fill', + kind: 'enum', + default: DEFAULT_FILL, + options: [ + { value: 'repeat', label: 'Repeat' }, + { value: 'stretch', label: 'Stretch' }, + ], + }, + found ? cutFill(found.cut) : DEFAULT_FILL, + sliceHandlers(host, target, (input, slice) => ({ + ...slice, + fill: textOf(input) === 'stretch' ? 'stretch' : 'repeat', + })), + ); + host.fields.set('fill', row); + host.body.add(row.row); +} + +function sliceHandlers( + host: InspectorHost, + target: CutTarget, + apply: (input: PropertyValue, slice: CutSlice) => CutSlice, +): FieldHandlers { + return previewingHandlers(host, { + target: () => currentCut(host, target), + read: ({ cut }) => cutSlice(cut), + write: ({ cut, size }, slice) => setCutSlice(cut, size, slice), + toModel: (input, { cut }) => apply(input, cutSlice(cut)), + commit: (slice) => new SetSliceCommand(target, slice), + }); +} + +/** + * Looked up inside the selected asset rather than by frame id alone, so a selection left over + * from a deleted texture cannot edit whichever frame later inherits the id. + */ +function currentCut(host: InspectorHost, target: CutTarget): FoundCut | undefined { + return findCut(host.state.project, target) ?? undefined; +} diff --git a/src/editor/inspector/frameSection.ts b/src/editor/inspector/frameSection.ts index 3ba3cd52..e9291f52 100644 --- a/src/editor/inspector/frameSection.ts +++ b/src/editor/inspector/frameSection.ts @@ -2,28 +2,12 @@ import { RemoveFrameCommand, RenameFrameCommand, SetFrameRectCommand, - SetFrameSliceCommand, - SetFrameSortLineCommand, } from '../../core/commands/frameCommands'; -import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset'; -import { type PropertyValue, textOf } from '../../core/model/Properties'; +import type { TextureAsset, TextureFrame } from '../../core/model/Asset'; import { findAssetOf, type Project } from '../../core/model/Project'; -import { - DEFAULT_FILL, - frameBorders, - frameFill, - frameRect, - type FrameRect, - frameSlice, - type FrameSlice, - frameSortLine, - normalizeFrameRect, - normalizeSortLine, - setFrameRect, - setFrameSlice, - setFrameSortLine, -} from '../../core/model/texture'; +import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture'; import { Button } from '../../ui'; +import { buildCutRows, refreshCutRows } from './cutSection'; import { createFieldRow, type FieldHandlers } from './fieldFactory'; import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; import { previewingHandlers } from './previewHandlers'; @@ -35,13 +19,6 @@ 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'; @@ -68,15 +45,7 @@ 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.'); - - addTitle(host, 'SORTING (IMAGE PIXELS)'); - sortLineRow(host, assetId, frameId); - addNote(host, 'Where a sprite of this frame stands on a layer sorted by Y, up from the bottom edge.'); + buildCutRows(host, { assetId, frameId }, 'IMAGE PIXELS'); addGap(host); host.body.add( @@ -93,97 +62,10 @@ 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)); - host.fields.get('sortLine')?.setValue(frameSortLine(frame)); + refreshCutRows(host, { assetId, frameId }); return true; } -function sortLineRow(host: InspectorHost, assetId: string, frameId: string): void { - const frame = currentFrame(host.state.project, assetId, frameId); - const row = createFieldRow( - host, - { key: 'sortLine', label: 'Sort line', kind: 'number', default: 0, min: 0, step: 1, decimals: 0 }, - frame ? frameSortLine(frame) : 0, - previewingHandlers(host, { - target: () => currentFrame(host.state.project, assetId, frameId), - read: frameSortLine, - write: setFrameSortLine, - toModel: (input, frame) => normalizeSortLine(Number(input), frame.h), - commit: (line) => new SetFrameSortLineCommand(assetId, frameId, line), - }), - ); - host.fields.set('sortLine', row); - host.body.add(row.row); -} - -/** - * 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: DEFAULT_FILL, - options: [ - { value: 'repeat', label: 'Repeat' }, - { value: 'stretch', label: 'Stretch' }, - ], - }, - frame ? frameFill(frame) : DEFAULT_FILL, - sliceHandlers(host, assetId, frameId, (input, slice) => ({ - ...slice, - fill: textOf(input) === 'stretch' ? 'stretch' : 'repeat', - })), - ); - 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/pixelart/PixelArtCanvas.ts b/src/editor/pixelart/PixelArtCanvas.ts index 4457db6b..14834405 100644 --- a/src/editor/pixelart/PixelArtCanvas.ts +++ b/src/editor/pixelart/PixelArtCanvas.ts @@ -18,6 +18,7 @@ import { } from 'pixi.js'; import { EditPixelArtCommand } from '../../core/commands/pixelArtCommands'; import type { PixelArtAsset, PixelFrame } from '../../core/model/Asset'; +import { drawnCutSize } from '../../core/model/cutTarget'; import { celOf, composeFrame, @@ -442,6 +443,7 @@ export class PixelArtCanvas extends SheetCanvas { if (zoom >= GRID_FROM_ZOOM) this.drawGrid(asset, panX, panY, zoom); this.overlay.rect(panX - 0.5, panY - 0.5, w + 1, h + 1).stroke({ width: 1, color: Theme.colors.border }); + this.drawCutLines(this.overlay, asset, drawnCutSize(asset)); const hover = this.hover; if (hover && !stroke && this.tool !== 'fill' && this.tool !== 'picker') { const n = this.brush; diff --git a/src/editor/texture/SheetCanvas.ts b/src/editor/texture/SheetCanvas.ts index 4b1a234e..30de0dd5 100644 --- a/src/editor/texture/SheetCanvas.ts +++ b/src/editor/texture/SheetCanvas.ts @@ -1,7 +1,10 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; +import type { PictureCut } from '../../core/model/Asset'; import type { Vec2 } from '../../core/model/Properties'; +import type { CutSize } from '../../core/model/texture'; import { type Size, Theme, textStyle, ui, Widget, zoomFactor, crispText } from '../../ui'; -import { imageToLocal, type SheetView, zoomAbout } from './sheetView'; +import { dashedLine } from '../../viewport/symbols'; +import { cutLines, imageToLocal, type SheetLine, type SheetView, zoomAbout } from './sheetView'; /** * The shell of a tab that shows one picture-like asset under a `SheetView` – the Texture, Pixel art @@ -104,6 +107,22 @@ export abstract class SheetCanvas extends Widget { this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); } + /** The nine-slice and sort lines of a drawing (#308), dashed as the Texture tab draws a frame's. */ + protected drawCutLines(g: Graphics, cut: PictureCut, size: CutSize): void { + const { borders, sortLine } = cutLines(cut, size); + for (const line of borders) this.dashed(g, line, 4); + g.stroke({ width: 1, color: Theme.colors.marquee }); + if (!sortLine) return; + this.dashed(g, sortLine, 6); + g.stroke({ width: 1, color: Theme.colors.hot }); + } + + private dashed(g: Graphics, line: SheetLine, dash: number): void { + const a = imageToLocal(this.view, line.x1, line.y1); + const b = imageToLocal(this.view, line.x2, line.y2); + dashedLine(g, a.x, a.y, b.x, b.y, dash, 3); + } + private onPointerDown(e: FederatedPointerEvent): void { const target = this.pressTarget(); if (!target) return; diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 94fb2234..514dd067 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -3,27 +3,27 @@ import { AddFrameCommand, SetFrameCollisionCommand, SetFrameRectCommand, - SetFrameSliceCommand, - SetFrameSortLineCommand, + SetSliceCommand, + SetSortLineCommand, } 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, + cutBorders, + cutFill, + cutSortLine, frameCollision, - frameFill, type FrameRect, - frameSortLine, MIN_FRAME_SIZE, normalizeFrameRect, normalizeSortLine, - rawFrameSlice, - restoreFrameSlice, + rawCutSlice, + restoreCutSlice, + setCutSlice, + setCutSortLine, setFrameCollision, - setFrameSlice, - setFrameSortLine, } from '../../core/model/texture'; import { pressCount, Theme, textStyle, type Widget, crispText } from '../../ui'; import { @@ -248,7 +248,7 @@ export class TextureCanvas extends SheetCanvas { const at = imageToLocal( { panX: 0, panY: 0, zoom: this.view.zoom }, frame.x, - sortLineY(frame, frameSortLine(frame)), + sortLineY(frame, cutSortLine(frame, frame)), ); return { x: origin.x + at.x - SORT_KNOB_GAP - SORT_KNOB_SIZE / 2, y: origin.y + at.y }; } @@ -321,7 +321,7 @@ export class TextureCanvas extends SheetCanvas { // 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 onBorder = selected ? borderAt(selected, cutBorders(selected), point, tolerance) : null; const onSortLine = selected ? this.sortLineAt(selected, point, tolerance) : null; if (selected && onSortLine === 'knob') { this.startDrag(selected, null, null, local, true); @@ -496,8 +496,8 @@ export class TextureCanvas extends SheetCanvas { sortLine, startSortLine: frame.sortLine, start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h }, - startBorder: frameBorders(frame), - startSlice: rawFrameSlice(frame), + startBorder: cutBorders(frame), + startSlice: rawCutSlice(frame), pointer: { x: local.x, y: local.y }, moved: false, }; @@ -552,11 +552,12 @@ export class TextureCanvas extends SheetCanvas { 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); + if (drag.sortLine) + setCutSortLine(frame, frame, normalizeSortLine(drag.startSortLine ?? 0, drag.start.h) - dy); else if (drag.edge) - setFrameSlice(frame, { + setCutSlice(frame, frame, { border: dragBorder(drag.startBorder, drag.edge, dx, dy), - fill: frameFill(frame), + fill: cutFill(frame), }); else if (drag.handle) Object.assign( @@ -589,14 +590,14 @@ export class TextureCanvas extends SheetCanvas { 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); + const next = cutSortLine(frame, frame); if (drag.startSortLine === undefined) delete frame.sortLine; else frame.sortLine = drag.startSortLine; - this.state.execute(new SetFrameSortLineCommand(asset.id, drag.frameId, next)); + this.state.execute(new SetSortLineCommand({ assetId: asset.id, frameId: 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)); + const next = { border: cutBorders(frame), fill: cutFill(frame) }; + restoreCutSlice(frame, drag.startSlice); + this.state.execute(new SetSliceCommand({ assetId: asset.id, frameId: drag.frameId }, next)); } else { const next: FrameRect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h }; Object.assign(frame, drag.start); @@ -646,7 +647,7 @@ export class TextureCanvas extends SheetCanvas { 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; + const edge = selected ? borderAt(selected, cutBorders(selected), point, tolerance) : null; if (edge) { this.cursor = borderCursor(edge); return; @@ -769,7 +770,7 @@ export class TextureCanvas extends SheetCanvas { * 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); + const border = cutBorders(frame); for (const edge of BORDER_EDGES) { const line = borderLine(frame, border, edge); const a = imageToLocal(view, line.x1, line.y1); @@ -781,7 +782,7 @@ export class TextureCanvas extends SheetCanvas { /** 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 y = sortLineY(frame, cutSortLine(frame, 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); @@ -797,7 +798,7 @@ export class TextureCanvas extends SheetCanvas { private sortLineAt(frame: TextureFrame, point: Vec2, tolerance: number): 'knob' | 'line' | null { const gap = SORT_KNOB_GAP / this.view.zoom; const reach = (SORT_KNOB_GAP + SORT_KNOB_SIZE + 2) / this.view.zoom; - return sortLineAt(frame, frameSortLine(frame), point, tolerance, gap, reach); + return sortLineAt(frame, cutSortLine(frame, frame), point, tolerance, gap, reach); } /** diff --git a/src/editor/texture/sheetView.ts b/src/editor/texture/sheetView.ts index ef426ade..cd21dc2e 100644 --- a/src/editor/texture/sheetView.ts +++ b/src/editor/texture/sheetView.ts @@ -1,6 +1,12 @@ /** 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 { FrameBorders, PictureCut } from '../../core/model/Asset'; +import { + cutBorders, + type CutSize, + cutSortLine, + type FrameRect, + normalizeBorders, +} 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. */ @@ -114,12 +120,15 @@ export type BorderEdge = 'left' | 'top' | 'right' | 'bottom'; export const BORDER_EDGES: readonly BorderEdge[] = ['left', 'top', 'right', 'bottom']; +export interface SheetLine { + x1: number; + y1: number; + x2: number; + y2: number; +} + /** 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 } { +export function borderLine(rect: FrameRect, border: FrameBorders, edge: BorderEdge): SheetLine { 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 }; @@ -172,6 +181,24 @@ export function sortLineY(rect: FrameRect, line: number): number { return rect.y + rect.h - line; } +/** + * The nine-slice and sort lines a drawing shows in its tab (#308), in canvas pixels. Only those that + * are set: there they are shown, not dragged, and a line on the edge would only hide the art's rim. + */ +export function cutLines( + cut: PictureCut, + size: CutSize, +): { borders: SheetLine[]; sortLine: SheetLine | null } { + const rect = { x: 0, y: 0, w: size.w, h: size.h }; + const border = normalizeBorders(cutBorders(cut), size.w, size.h); + const borders = BORDER_EDGES.filter((edge) => border[edge] > 0).map((edge) => + borderLine(rect, border, edge), + ); + const line = cutSortLine(cut, size); + const y = sortLineY(rect, line); + return { borders, sortLine: line > 0 ? { x1: 0, y1: y, x2: size.w, y2: y } : null }; +} + /** * 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 diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index 598174cd..aa9751f9 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -26,6 +26,7 @@ import type { VectorRing, VectorShape, } from '../../core/model/Asset'; +import { drawnCutSize } from '../../core/model/cutTarget'; import { IDENTITY, type Mat } from '../../core/model/matrix'; import type { Vec2 } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; @@ -992,6 +993,7 @@ export class VectorArtCanvas extends SheetCanvas { paintDrawing(this.art, drawing, this.paints); this.paints.sweep(); this.drawFrame(origin, size); + this.drawCutLines(this.overlay, asset, drawnCutSize(asset)); for (const shapeId of this.selectedShapeIds()) if (shapeId !== this.selectedShapeId()) this.drawOutline(this.shape(shapeId)); if (this.tool === 'paint') { diff --git a/src/viewport/drawOrder.ts b/src/viewport/drawOrder.ts index de0e4150..f62c6d40 100644 --- a/src/viewport/drawOrder.ts +++ b/src/viewport/drawOrder.ts @@ -153,13 +153,14 @@ function standsOnY(slot: Candidate): boolean { /** * Where a slot stands, in world Y: the bottom of an object's box, or of a map's row. A sprite whose - * frame has a sort line stands that share of its box higher up. + * picture has a sort line stands that share of its box higher up – of the frame, or of the whole + * picture when it is one (vector art, #308). */ function feetOf({ item, row }: Candidate): number { if (row === null) { const box = footprintExtent(item.type, item.props, item.m, item.frame); const line = item.type === 'sprite' ? (item.texture?.sortLine ?? 0) : 0; - const frameH = item.texture?.rect?.h ?? 0; + const frameH = item.texture?.rect?.h ?? item.texture?.sourceHeight ?? 0; return line > 0 && frameH > 0 ? box.bottom - (line / frameH) * (box.bottom - box.top) : box.bottom; } const bottom = cellOrigin(item.props, 0, row).y + tilemapTileSize(item.props); diff --git a/tests/core/drawing-cut.test.ts b/tests/core/drawing-cut.test.ts new file mode 100644 index 00000000..9d68b078 --- /dev/null +++ b/tests/core/drawing-cut.test.ts @@ -0,0 +1,142 @@ +/** + * Nine-slice and sort line on pixel art and vector art (#308): the same commands, rules and ref + * fields a texture frame has, measured against a pixel frame or the vector canvas. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { CommandError } from '../../src/core/commands/Command'; +import { SetSliceCommand, SetSortLineCommand } from '../../src/core/commands/frameCommands'; +import type { PixelArtAsset, VectorArtAsset } from '../../src/core/model/Asset'; +import { registerPictureSource } from '../../src/core/model/generated'; +import { pixelArtPicture } from '../../src/core/model/pixelart'; +import { findAsset } from '../../src/core/model/Project'; +import { resolvePicture, SLICE_SCALE_KEY } from '../../src/core/model/texture'; +import { VECTOR_HEIGHT_KEY, VECTOR_WIDTH_KEY, vectorArtPicture } from '../../src/core/model/vectorart'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { cutLines } from '../../src/editor/texture/sheetView'; +import { makeProject, roundTrip } from '../helpers'; + +const RING = { left: 4, top: 4, right: 4, bottom: 4 }; + +function drawings() { + const fixture = makeProject(); + const pixel = new CreateAssetCommand('pixelart', 'Hero'); + const vector = new CreateAssetCommand('vectorart', 'Panel'); + fixture.stack.execute(pixel); + fixture.stack.execute(vector); + const pixelArt = () => findAsset(fixture.project, pixel.asset.id) as PixelArtAsset; + const vectorArt = () => findAsset(fixture.project, vector.asset.id) as VectorArtAsset; + pixelArt().width = 16; + pixelArt().height = 24; + fixture.stack.execute(new SetAssetPropertyCommand(vector.asset.id, VECTOR_WIDTH_KEY, 64)); + fixture.stack.execute(new SetAssetPropertyCommand(vector.asset.id, VECTOR_HEIGHT_KEY, 32)); + return { ...fixture, pixelId: pixel.asset.id, vectorId: vector.asset.id, pixelArt, vectorArt }; +} + +describe('slicing and sorting a drawing as a whole (#308)', () => { + it('sets both on pixel art and vector art, and undo takes the keys away again', () => { + const f = drawings(); + for (const [assetId, read] of [ + [f.pixelId, f.pixelArt], + [f.vectorId, f.vectorArt], + ] as const) { + f.stack.execute(new SetSliceCommand({ assetId }, { border: RING, fill: 'stretch' })); + f.stack.execute(new SetSortLineCommand({ assetId }, 6)); + expect(read()).toMatchObject({ border: RING, fill: 'stretch', sortLine: 6 }); + f.stack.undo(); + f.stack.undo(); + expect('border' in read() || 'fill' in read() || 'sortLine' in read()).toBe(false); + } + }); + + it('holds borders and line inside the frame or the canvas', () => { + const f = drawings(); + const wide = { left: 40, top: 0, right: 40, bottom: 0 }; + f.stack.execute(new SetSliceCommand({ assetId: f.pixelId }, { border: wide, fill: 'repeat' })); + f.stack.execute(new SetSortLineCommand({ assetId: f.pixelId }, 99)); + // 16 wide: one pixel stays between the two; 24 tall is as high as the line goes. + expect(f.pixelArt()).toMatchObject({ border: { left: 8, right: 7 }, sortLine: 24 }); + f.stack.execute(new SetSliceCommand({ assetId: f.vectorId }, { border: wide, fill: 'repeat' })); + f.stack.execute(new SetSortLineCommand({ assetId: f.vectorId }, 99)); + expect(f.vectorArt()).toMatchObject({ border: { left: 32, right: 31 }, sortLine: 32 }); + }); + + it('refuses an asset that draws no picture of its own', () => { + const f = drawings(); + const sprite = new CreateAssetCommand('sprite', 'Box'); + f.stack.execute(sprite); + expect(() => f.stack.execute(new SetSortLineCommand({ assetId: sprite.asset.id }, 3))).toThrow( + /Box cannot be sliced/, + ); + expect(() => f.stack.execute(new SetSortLineCommand({ assetId: f.pixelId, frameId: 'x' }, 3))).toThrow( + CommandError, + ); + }); + + it('survives a round trip, and loading holds it to the canvas', () => { + const f = drawings(); + f.stack.execute(new SetSliceCommand({ assetId: f.vectorId }, { border: RING, fill: 'stretch' })); + f.stack.execute(new SetSortLineCommand({ assetId: f.pixelId }, 5)); + const back = roundTrip(f.project); + expect(findAsset(back, f.vectorId)).toMatchObject({ border: RING, fill: 'stretch' }); + expect(findAsset(back, f.pixelId)).toMatchObject({ sortLine: 5 }); + + const saved = serializeProject(f.project) as { assets: Record[] }; + const pixel = saved.assets.find((a) => a.id === f.pixelId)!; + pixel.sortLine = 70; + pixel.border = { left: 'x', top: 2.6, right: -1, bottom: 0 }; + expect(findAsset(deserializeProject(saved), f.pixelId)).toMatchObject({ + sortLine: 24, + border: { left: 0, top: 3, right: 0, bottom: 0 }, + }); + }); +}); + +describe('the picture a drawing hands to a sprite (#308)', () => { + const removers: (() => void)[] = []; + afterEach(() => removers.splice(0).forEach((remove) => remove())); + + it('carries the cut of a pixel frame, measured in the frame', () => { + removers.push(registerPictureSource('pixelart', pixelArtPicture)); + const f = drawings(); + f.stack.execute(new SetSliceCommand({ assetId: f.pixelId }, { border: RING, fill: 'repeat' })); + f.stack.execute(new SetSortLineCommand({ assetId: f.pixelId }, 6)); + f.stack.execute(new SetAssetPropertyCommand(f.pixelId, SLICE_SCALE_KEY, 2)); + const slice = { border: RING, fill: 'repeat', sourceW: 16, sourceH: 24, scale: 2 }; + expect(resolvePicture(f.project, f.pixelId)).toMatchObject({ slice, sortLine: 6 }); + + // With a second frame the asset is the sheet of both, which is no picture of that size. + f.pixelArt().frames.push({ id: 'second', cels: {} }); + expect(resolvePicture(f.project, f.pixelId)).toMatchObject({ sourceWidth: 32, slice: null }); + expect(resolvePicture(f.project, f.pixelId)?.sortLine).toBeUndefined(); + expect(resolvePicture(f.project, 'second')).toMatchObject({ rect: { x: 16, w: 16 }, slice, sortLine: 6 }); + }); + + it('carries the cut of vector art, measured in the canvas', () => { + removers.push(registerPictureSource('vectorart', vectorArtPicture)); + const f = drawings(); + expect(resolvePicture(f.project, f.vectorId)).toMatchObject({ slice: null }); + f.stack.execute(new SetSliceCommand({ assetId: f.vectorId }, { border: RING, fill: 'stretch' })); + f.stack.execute(new SetSortLineCommand({ assetId: f.vectorId }, 8)); + expect(resolvePicture(f.project, f.vectorId)).toMatchObject({ + rect: null, + slice: { border: RING, fill: 'stretch', sourceW: 64, sourceH: 32, scale: 1 }, + sortLine: 8, + }); + }); +}); + +describe('the lines a drawing tab shows (#308)', () => { + it('draws only what is set, in canvas pixels', () => { + expect(cutLines({}, { w: 16, h: 24 })).toEqual({ borders: [], sortLine: null }); + const { borders, sortLine } = cutLines( + { border: { left: 4, top: 0, right: 0, bottom: 2 }, sortLine: 6 }, + { w: 16, h: 24 }, + ); + expect(borders).toEqual([ + { x1: 4, y1: 0, x2: 4, y2: 24 }, + { x1: 0, y1: 22, x2: 16, y2: 22 }, + ]); + expect(sortLine).toEqual({ x1: 0, y1: 18, x2: 16, y2: 18 }); + }); +}); diff --git a/tests/core/frame-commands.test.ts b/tests/core/frame-commands.test.ts index d7fd9f8b..a65eaf06 100644 --- a/tests/core/frame-commands.test.ts +++ b/tests/core/frame-commands.test.ts @@ -6,12 +6,12 @@ import { RemoveFrameCommand, RenameFrameCommand, SetFrameRectCommand, - SetFrameSliceCommand, + SetSliceCommand, SliceFramesCommand, } from '../../src/core/commands/frameCommands'; import type { TextureAsset, TextureFrame } from '../../src/core/model/Asset'; import { findAsset } from '../../src/core/model/Project'; -import { DEFAULT_SLICE, findFrame, frameFill, type FrameRect } from '../../src/core/model/texture'; +import { cutFill, DEFAULT_SLICE, findFrame, type FrameRect } from '../../src/core/model/texture'; import { makeProject } from '../helpers'; const FILE = `${'ab'.repeat(32)}.png`; @@ -273,10 +273,13 @@ describe('SliceFramesCommand', () => { 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: 'stretch', - }), + new SetSliceCommand( + { assetId: sheet.textureId, frameId }, + { + border: { left: 8, top: 8, right: 8, bottom: 8 }, + fill: 'stretch', + }, + ), ); expect(frame().border).toEqual({ left: 8, top: 8, right: 8, bottom: 8 }); expect(frame().fill).toBe('stretch'); @@ -291,26 +294,32 @@ describe('SliceFramesCommand', () => { const sheet = makeSheet(); const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 }); const frame = () => sheet.frames().find((f) => f.id === frameId)!; - expect(frameFill(frame())).toBe('repeat'); + expect(cutFill(frame())).toBe('repeat'); sheet.stack.execute( - new SetFrameSliceCommand(sheet.textureId, frameId, { - border: { left: 8, top: 8, right: 8, bottom: 8 }, - fill: 'repeat', - }), + new SetSliceCommand( + { assetId: sheet.textureId, frameId }, + { + border: { left: 8, top: 8, right: 8, bottom: 8 }, + fill: 'repeat', + }, + ), ); expect(frame().fill).toBeUndefined(); - expect(frameFill(frame())).toBe('repeat'); + expect(cutFill(frame())).toBe('repeat'); }); 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', - }), + new SetSliceCommand( + { assetId: 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 }); diff --git a/tests/viewport/sort-by-y.test.ts b/tests/viewport/sort-by-y.test.ts index 6dae5382..ff348da5 100644 --- a/tests/viewport/sort-by-y.test.ts +++ b/tests/viewport/sort-by-y.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; -import { AddFrameCommand, SetFrameSortLineCommand } from '../../src/core/commands/frameCommands'; +import { AddFrameCommand, SetSortLineCommand } from '../../src/core/commands/frameCommands'; import { MoveSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddTilemapLayerCommand, @@ -9,8 +9,10 @@ import { SetTilemapLayerSortByYCommand, } from '../../src/core/commands/tilemapCommands'; import type { TextureAsset, TileAsset, TilemapAsset } from '../../src/core/model/Asset'; +import { registerPictureSource } from '../../src/core/model/generated'; import { findAsset } from '../../src/core/model/Project'; -import { frameSortLine } from '../../src/core/model/texture'; +import { VECTOR_HEIGHT_KEY, vectorArtPicture } from '../../src/core/model/vectorart'; +import { cutSortLine } from '../../src/core/model/texture'; import { TILE_SORT_OFFSET_KEY, tileSortOffset } from '../../src/core/model/tile'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { type DrawSlot, drawSlots, layerRows } from '../../src/viewport/drawOrder'; @@ -240,7 +242,7 @@ function withFrames(feet: number, line: number) { const plain = new AddFrameCommand(texture.asset.id, { x: 32, y: 0, w: 32, h: 50 }, 'Plain'); s.f.stack.execute(stand); s.f.stack.execute(plain); - s.f.stack.execute(new SetFrameSortLineCommand(texture.asset.id, stand.frameId, line)); + s.f.stack.execute(new SetSortLineCommand({ assetId: texture.asset.id, frameId: stand.frameId }, line)); const wear = (frameId: string) => s.f.stack.execute(new SetAssetPropertyCommand(s.f.spriteId, 'texture', frameId)); wear(stand.frameId); @@ -285,7 +287,7 @@ describe('the sort line of a texture frame (#228)', () => { const s = withFrames(0, 12); const stand = () => s.frameOf(s.stand); expect(stand().sortLine).toBe(12); - s.f.stack.execute(new SetFrameSortLineCommand(s.textureId, s.stand, 0)); + s.f.stack.execute(new SetSortLineCommand({ assetId: s.textureId, frameId: s.stand }, 0)); expect('sortLine' in stand()).toBe(false); s.f.stack.undo(); expect(stand().sortLine).toBe(12); @@ -294,7 +296,7 @@ describe('the sort line of a texture frame (#228)', () => { expect('sortLine' in s.frameOf(s.plain)).toBe(false); const fresh = withFrames(0, 0); - fresh.f.stack.execute(new SetFrameSortLineCommand(fresh.textureId, fresh.stand, 7)); + fresh.f.stack.execute(new SetSortLineCommand({ assetId: fresh.textureId, frameId: fresh.stand }, 7)); fresh.f.stack.undo(); expect('sortLine' in fresh.frameOf(fresh.stand)).toBe(false); }); @@ -306,8 +308,8 @@ describe('the sort line of a texture frame (#228)', () => { [-4, 0], [80, 50], ] as const) { - s.f.stack.execute(new SetFrameSortLineCommand(s.textureId, s.stand, raw)); - expect(frameSortLine(s.frameOf(s.stand)), String(raw)).toBe(kept); + s.f.stack.execute(new SetSortLineCommand({ assetId: s.textureId, frameId: s.stand }, raw)); + expect(cutSortLine(s.frameOf(s.stand), s.frameOf(s.stand)), String(raw)).toBe(kept); } const saved = serializeProject(s.f.project) as { assets: { id: string; frames?: Record[] }[]; @@ -332,6 +334,21 @@ describe('the sort line of a texture frame (#228)', () => { expect(sortLineAt(rect, 6, { x: 45, y: 54 }, 1, 1, 8)).toBeNull(); }); + it('lifts a sprite wearing vector art by its share of the whole picture (#308)', () => { + const unregister = registerPictureSource('vectorart', vectorArtPicture); + try { + const s = makeScene(-50); + s.sortTrees(); + const art = new CreateAssetCommand('vectorart', 'Hero art', { [VECTOR_HEIGHT_KEY]: 50 }); + s.f.stack.execute(art); + s.f.stack.execute(new SetAssetPropertyCommand(s.f.spriteId, 'texture', art.asset.id)); + s.f.stack.execute(new SetSortLineCommand({ assetId: art.asset.id }, 15)); + expect(sceneOrder(s)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']); + } finally { + unregister(); + } + }); + it('takes the middle of its knob even where the corner handle reaches over it', () => { // The editor's sizes at zoom 1: handles grab 8 px around the corner, the knob is drawn from // 4 to 12 px left of the frame, so its middle sits exactly 8 px out – on the handle's edge. diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs index 55eced2c..c3ddc6bf 100644 --- a/tools/e2e/48-pixelart.mjs +++ b/tools/e2e/48-pixelart.mjs @@ -674,6 +674,37 @@ export async function pixelArt(t) { colourDistance(spriteRed, HEX[RED]) < 12 && colourDistance(spriteBlue, HEX[BLUE]) < 12, JSON.stringify({ spriteRed, spriteBlue }), ); + + // 48l-ii. Nine-slice on pixel art (#308): 12-pixel borders at scale 2 draw the corner at 2 units a + // pixel instead of 4, so red (5, 2) and blue (10, 10) move to units (11, 5) and (21, 21). The same + // spots are read before the borders are set, so a sprite that ignored them could not pass. + const unitAt = (ux, uy) => + json( + `__gm.pixelAt(${Math.round(box.x - box.w / 2 + (ux / 128) * box.w)}, ${Math.round(box.y - box.h / 2 + (uy / 128) * box.h)})`, + ); + const slicedSpots = async () => ({ red: await unitAt(11, 5), blue: await unitAt(21, 21) }); + const unsliced = await slicedSpots(); + await mutate(` + const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + art.border = { left: 12, top: 12, right: 12, bottom: 12 }; + art.props.sliceScale = 2;`); + await settle(); + await sleep(300); + const sliced = await slicedSpots(); + const isSliced = (spots) => + colourDistance(spots.red, HEX[RED]) < 12 && colourDistance(spots.blue, HEX[BLUE]) < 12; + check( + 'a sprite wearing a pixel frame with borders draws its corners at the nine-slice scale', + isSliced(sliced) && !isSliced(unsliced), + JSON.stringify({ sliced, unsliced }), + ); + await shot('48-pixel-nine-slice'); + await mutate(` + const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + delete art.border; + delete art.props.sliceScale;`); + await settle(); + const textureRow = await json("__gm.inspectorField('Texture', true)"); await click(textureRow.x, textureRow.y); await settle();