Let pixel art and vector art be nine-sliced and sorted (#308)
Nine-slice and the sort line hung on the texture frame alone. Drawing and sorting already read both off the TextureRef every picture hands out, but the generated pictures always said "no slice, no line". So the feature moves from the frame to the picture, without a second copy of any rule. - PictureCut (border, fill, sortLine) is one type. TextureFrame extends it, PixelArtAsset and VectorArtAsset carry it as a whole. The texture file format does not change, and no migration is needed. - The rules in texture.ts take a cut and a size: cutSortLine, setCutSlice, rawCutSlice and the rest replace the frame* versions. serialize.ts reads all three through one readCut. - cutTarget.ts resolves a target to cut and size: a texture frame, a pixel frame's size or the vector canvas. It lives apart from texture.ts because vectorart.ts reaches texture.ts itself. - SetSliceCommand and SetSortLineCommand take that target instead of (assetId, frameId). They refuse an asset that draws no picture. - generatedRef fills slice and sortLine from the drawing. The whole strip of a pixel art asset with several frames gets none, it is no picture of that size. With one frame asset and frame are the same. - Nine-slice scale is one shared schema row, now also on pixel art and vector art. - cutSection.ts holds the NINE-SLICE and SORTING rows once. The frame inspector and the drawing inspector both build them. - SheetCanvas draws a drawing's lines that are set, dashed as in the Texture tab. They are not dragged there, the painting tools own the canvas (as proposed in the issue). - drawOrder falls back to the picture's height when a ref has no rect, or a sort line on vector art would have been ignored. Tests: - Gate: 232 files, 3542 tests (+8: drawing-cut.test.ts, and vector art in sort-by-y.test.ts). Three counter-proofs bite: no cut on a drawing, a cut on the whole strip, no height fallback. - e2e 48: 44 checks (+1, sum 1732). 48l-ii reads red and blue where 12-pixel borders at scale 2 put them. Without borders both spots read green. - Looked at: the Pixel art tab with the lines, and the sliced sprite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d9c6bbfa4d
commit
3e05cbbed5
20 files changed
+666
-300
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<TextureFrame, 'border' | 'fill'> = {};
|
||||
private previous: Pick<PictureCut, 'border' | 'fill'> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-15
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+63
-50
@@ -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<TextureRef, 'slice' | 'sortLine'> {
|
||||
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<TextureFrame, 'border' | 'fill'> {
|
||||
const raw: Pick<TextureFrame, 'border' | 'fill'> = {};
|
||||
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<PictureCut, 'border' | 'fill'> {
|
||||
const raw: Pick<PictureCut, 'border' | 'fill'> = {};
|
||||
if (cut.border) raw.border = { ...cut.border };
|
||||
if (cut.fill) raw.fill = cut.fill;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function restoreFrameSlice(frame: TextureFrame, raw: Pick<TextureFrame, 'border' | 'fill'>): 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<PictureCut, 'border' | 'fill'>): void {
|
||||
if (raw.border) cut.border = { ...raw.border };
|
||||
else delete cut.border;
|
||||
if (raw.fill) cut.fill = raw.fill;
|
||||
else delete cut.fill;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<AssetType, PropertySchema> = {
|
||||
scene: [
|
||||
@@ -481,11 +491,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
{ 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<AssetType, PropertySchema> = {
|
||||
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: [],
|
||||
|
||||
+33
-22
@@ -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<string, unknown>): 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> = 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<FoundCut, number>(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<FoundCut, CutSlice>(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;
|
||||
}
|
||||
@@ -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<TextureFrame, number>(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<TextureFrame, FrameSlice>(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,
|
||||
|
||||
@@ -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<PixelArtAsset> {
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<A> 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;
|
||||
|
||||
@@ -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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
// 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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
* 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<TextureAsset> {
|
||||
|
||||
/** 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<TextureAsset> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<VectorArtAsset> {
|
||||
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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>[] };
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string, unknown>[] }[];
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user