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:
adminandClaude Opus 5.5 committed 2026-10-03 19:53:51 +02:00
1 parent d9c6bbfa4d
commit 3e05cbbed5
20 files changed
+666 -300

No files matched your search

+63 -50
View File
@@ -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;
}
/**