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>
378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
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';
|
||
import { findAsset, type Project } from './Project';
|
||
|
||
/** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */
|
||
const FILE_PATTERN = /^[a-f0-9]{64}\.(png|jpg|webp|gif)$/;
|
||
|
||
export function isTextureFile(name: string): boolean {
|
||
return FILE_PATTERN.test(name);
|
||
}
|
||
|
||
export interface FrameRect {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
export const MIN_FRAME_SIZE = 1;
|
||
|
||
/**
|
||
* `rect` is null for the whole image rather than a rectangle covering it: the renderer can then
|
||
* skip the texture matrix (Pixi fits an unmatrixed fill to the shape) and the meaning survives
|
||
* the image being re-uploaded at a different size.
|
||
*/
|
||
export interface TextureRef {
|
||
file: string;
|
||
smooth: boolean;
|
||
rect: FrameRect | null;
|
||
sourceWidth: number;
|
||
sourceHeight: number;
|
||
/** Set when the picture this ref names is sliced (#41); null means "draw it over the whole box". */
|
||
slice: NineSlice | null;
|
||
/** 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
|
||
* reason the rectangle is – drawing has no access to the project.
|
||
*/
|
||
export interface NineSlice {
|
||
border: FrameBorders;
|
||
fill: FrameFill;
|
||
/** Size of the frame in image pixels; the borders are distances inside it. */
|
||
sourceW: number;
|
||
sourceH: number;
|
||
/** 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 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;
|
||
}
|
||
|
||
export interface FrameRef {
|
||
asset: TextureAsset;
|
||
frame: TextureFrame;
|
||
}
|
||
|
||
/**
|
||
* Frame ids share the generator with asset ids and are unique across the document, which lets a
|
||
* sprite's `texture` property hold either: an asset id means the whole picture, a frame id a cut-out.
|
||
*/
|
||
export function findFrame(project: Project, frameId: string): FrameRef | null {
|
||
if (!frameId) return null;
|
||
for (const asset of project.assets) {
|
||
if (asset.type !== 'texture') continue;
|
||
const frame = asset.frames.find((f) => f.id === frameId);
|
||
if (frame) return { asset, frame };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Every id that means "this picture": the texture asset itself and each of its frames. */
|
||
export function textureRefIds(asset: TextureAsset): string[] {
|
||
return [asset.id, ...asset.frames.map((f) => f.id)];
|
||
}
|
||
|
||
/** What a sprite wearing `props.texture` shows. */
|
||
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
|
||
return resolvePicture(project, textOf(props.texture ?? ''));
|
||
}
|
||
|
||
/** A texture, a frame or a generating asset; any other asset's id is no picture. */
|
||
export function resolvePicture(project: Project, id: string): TextureRef | null {
|
||
if (!id) return null;
|
||
const asset = findAsset(project, id);
|
||
if (asset) return asset.type === 'texture' ? refFor(asset, null) : generatedRef(asset, project);
|
||
const found = findFrame(project, id);
|
||
if (found) return refFor(found.asset, found.frame);
|
||
// A frame of pixel art (#130) is a cut of its generated sheet, as a texture frame is of its image.
|
||
const pixel = findPixelFrame(project, id);
|
||
return pixel ? generatedRef(pixel.asset, project, pixelFrameRect(pixel.asset, pixel.index)) : 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. 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,
|
||
...(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;
|
||
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,
|
||
...(frame ? cutOnRef(frame, frame, asset) : { slice: null }),
|
||
};
|
||
}
|
||
|
||
/** 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 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 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 };
|
||
export const DEFAULT_FILL: FrameFill = 'repeat';
|
||
|
||
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
|
||
const MIN_SLICE_MIDDLE = 1;
|
||
|
||
/** The frame's collision outlines (#141), a copy; empty when it has none. */
|
||
export function frameCollision(frame: TextureFrame): Vec2[][] {
|
||
return (frame.collision ?? []).map((outline) => outline.map((p) => ({ ...p })));
|
||
}
|
||
|
||
/**
|
||
* Sets the outlines. One without corners is dropped, and none left removes the key, so a frame
|
||
* without an outline keeps saying so; one being drawn, with a corner or two, is kept.
|
||
*/
|
||
export function setFrameCollision(frame: TextureFrame, outlines: readonly (readonly Vec2[])[]): void {
|
||
const kept = outlines.filter((outline) => outline.length > 0);
|
||
if (kept.length === 0) delete frame.collision;
|
||
else frame.collision = kept.map((outline) => outline.map((p) => ({ x: p.x, y: p.y })));
|
||
}
|
||
|
||
export function cutBorders(cut: PictureCut): FrameBorders {
|
||
return cut.border ? { ...cut.border } : { ...NO_BORDERS };
|
||
}
|
||
|
||
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 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: cutFill(cut), sourceW: size.w, sourceH: size.h, scale };
|
||
}
|
||
|
||
/**
|
||
* Whole pixels, never negative, and never eating the whole frame: two opposite borders are scaled
|
||
* down together until at least one pixel is left between them. A middle of no pixels has nothing
|
||
* to draw the space between the corners with, which would show as a hole rather than as an error.
|
||
*/
|
||
export function normalizeBorders(border: FrameBorders, w: number, h: number): FrameBorders {
|
||
const [left, right] = fitPair(border.left, border.right, w);
|
||
const [top, bottom] = fitPair(border.top, border.bottom, h);
|
||
return { left, top, right, bottom };
|
||
}
|
||
|
||
function fitPair(near: number, far: number, size: number): [number, number] {
|
||
const a = Math.max(0, Math.round(Number.isFinite(near) ? near : 0));
|
||
const b = Math.max(0, Math.round(Number.isFinite(far) ? far : 0));
|
||
const room = Math.max(0, Math.floor(size) - MIN_SLICE_MIDDLE);
|
||
if (a + b <= room) return [a, b];
|
||
if (room <= 0) return [0, 0];
|
||
// Proportional, so a frame that is dragged small keeps the shape of its corners.
|
||
const scaled = Math.round((a / (a + b)) * room);
|
||
return [scaled, room - scaled];
|
||
}
|
||
|
||
/** The slice settings of a picture, as one value – what an inspector row and a command pass around. */
|
||
export interface CutSlice {
|
||
border: FrameBorders;
|
||
fill: FrameFill;
|
||
}
|
||
|
||
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 picture nobody sliced
|
||
* reads and saves without them, so undo of the first slice restores the old shape.
|
||
*/
|
||
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) cut.fill = slice.fill;
|
||
else delete cut.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 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;
|
||
}
|
||
|
||
/**
|
||
* Rounds to whole pixels and forces the rectangle inside the image: a fractional frame samples
|
||
* half of a neighbouring pixel along every edge, the classic sprite-sheet seam.
|
||
*/
|
||
export function normalizeFrameRect(rect: FrameRect, imageWidth: number, imageHeight: number): FrameRect {
|
||
// An image of unknown size (upload still running) cannot constrain anything; only round.
|
||
const limitX = imageWidth > 0 ? imageWidth : Infinity;
|
||
const limitY = imageHeight > 0 ? imageHeight : Infinity;
|
||
const x0 = clampInt(Math.min(rect.x, rect.x + rect.w), 0, limitX - MIN_FRAME_SIZE);
|
||
const y0 = clampInt(Math.min(rect.y, rect.y + rect.h), 0, limitY - MIN_FRAME_SIZE);
|
||
const x1 = clampInt(Math.max(rect.x, rect.x + rect.w), x0 + MIN_FRAME_SIZE, limitX);
|
||
const y1 = clampInt(Math.max(rect.y, rect.y + rect.h), y0 + MIN_FRAME_SIZE, limitY);
|
||
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
||
}
|
||
|
||
function clampInt(value: number, min: number, max: number): number {
|
||
const n = Math.round(Number.isFinite(value) ? value : 0);
|
||
return Math.max(min, Math.min(max, n));
|
||
}
|
||
|
||
export interface SliceOptions {
|
||
columns: number;
|
||
rows: number;
|
||
/** Pixels skipped at the left/top edge of the sheet before the first cell. */
|
||
offsetX: number;
|
||
offsetY: number;
|
||
spacingX: number;
|
||
spacingY: number;
|
||
}
|
||
|
||
export const DEFAULT_SLICE: SliceOptions = {
|
||
columns: 4,
|
||
rows: 4,
|
||
offsetX: 0,
|
||
offsetY: 0,
|
||
spacingX: 0,
|
||
spacingY: 0,
|
||
};
|
||
|
||
/**
|
||
* The rectangles a grid cuts a sheet into, in reading order. Cell size is derived from the image;
|
||
* a cell that would reach past the edge is dropped rather than clipped.
|
||
*/
|
||
export function sliceFrames(imageWidth: number, imageHeight: number, options: SliceOptions): FrameRect[] {
|
||
const columns = Math.floor(options.columns);
|
||
const rows = Math.floor(options.rows);
|
||
// NaN passes `columns < 1` (every comparison against it is false), so reject it explicitly.
|
||
if (!Number.isFinite(columns) || !Number.isFinite(rows)) return [];
|
||
if (!Object.values(options).every((value) => Number.isFinite(value))) return [];
|
||
if (columns < 1 || rows < 1 || imageWidth <= 0 || imageHeight <= 0) return [];
|
||
const cellW = Math.floor((imageWidth - options.offsetX - options.spacingX * (columns - 1)) / columns);
|
||
const cellH = Math.floor((imageHeight - options.offsetY - options.spacingY * (rows - 1)) / rows);
|
||
if (cellW < MIN_FRAME_SIZE || cellH < MIN_FRAME_SIZE) return [];
|
||
const rects: FrameRect[] = [];
|
||
for (let row = 0; row < rows; row++) {
|
||
for (let column = 0; column < columns; column++) {
|
||
const x = options.offsetX + column * (cellW + options.spacingX);
|
||
const y = options.offsetY + row * (cellH + options.spacingY);
|
||
if (x + cellW > imageWidth || y + cellH > imageHeight) continue;
|
||
rects.push({ x, y, w: cellW, h: cellH });
|
||
}
|
||
}
|
||
return rects;
|
||
}
|
||
|
||
export function frameRect(frame: TextureFrame): FrameRect {
|
||
return { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
|
||
}
|
||
|
||
export function setFrameRect(frame: TextureFrame, rect: FrameRect): void {
|
||
frame.x = rect.x;
|
||
frame.y = rect.y;
|
||
frame.w = rect.w;
|
||
frame.h = rect.h;
|
||
}
|
||
|
||
/** A name free within the texture: `base`, else "base 2", "base 3" … – the shape `nextAssetName` produces. */
|
||
export function uniqueFrameName(asset: TextureAsset, base: string, ignoreId = ''): string {
|
||
const taken = takenFrameNames(asset, ignoreId);
|
||
const stem = base || 'Frame';
|
||
return taken.has(stem) ? firstFreeName(taken, stem, 2) : stem;
|
||
}
|
||
|
||
/**
|
||
* `count` free names numbered from 1. Taken numbers are skipped rather than suffixed again, so
|
||
* re-slicing a sheet that keeps its old frames cannot produce "Frame 2 1".
|
||
*/
|
||
export function nextFrameNames(asset: TextureAsset, count: number, base = 'Frame'): string[] {
|
||
const taken = takenFrameNames(asset);
|
||
const names: string[] = [];
|
||
let n = 1;
|
||
for (let i = 0; i < count; i++) {
|
||
const name = firstFreeName(taken, base, n);
|
||
taken.add(name);
|
||
names.push(name);
|
||
n = Number(name.slice(base.length + 1)) + 1;
|
||
}
|
||
return names;
|
||
}
|
||
|
||
function takenFrameNames(asset: TextureAsset, ignoreId = ''): Set<string> {
|
||
return new Set(asset.frames.filter((f) => f.id !== ignoreId).map((f) => f.name));
|
||
}
|
||
|
||
function firstFreeName(taken: ReadonlySet<string>, base: string, from: number): string {
|
||
for (let i = from; ; i++) {
|
||
const candidate = `${base} ${i}`;
|
||
if (!taken.has(candidate)) return candidate;
|
||
}
|
||
}
|