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

+20 -15
View File
@@ -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;
+39
View File
@@ -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
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;
}
/**