Files
adminandClaude Opus 5.5 3e05cbbed5 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>
2026-10-03 19:53:51 +02:00

377 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand, SetSortLineCommand } from '../../src/core/commands/frameCommands';
import { MoveSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands';
import {
AddTilemapLayerCommand,
PaintTilesCommand,
RenameTilemapLayerCommand,
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 { 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';
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
import { stackAt } from '../../src/viewport/pick';
import { groupByTile } from '../../src/viewport/tilemapMesh';
import { sortLineAt, sortLineY } from '../../src/editor/texture/sheetView';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/**
* A 20 × 15 map of 48-unit cells at the origin, so row `r` ends at `-360 + (r + 1) * 48`: row 5 at
* -72, row 8 at 72. "Ground" is a plain layer; "Trees" holds a trunk at (2,5), its crown at (2,4)
* and a bush at (7,8). The sprite "Box" is 100 tall and stands with its bottom at `feet`.
*/
function makeScene(feet: number, crownOffset = 1) {
const f = makeSpriteScene('Box');
const map = new CreateAssetCommand('tilemap', 'Forest');
f.stack.execute(map);
const mapId = map.asset.id;
const assetOf = () => findAsset(f.project, mapId) as TilemapAsset;
const ground = assetOf().layers[0]!.id;
f.stack.execute(new RenameTilemapLayerCommand(mapId, ground, 'Ground'));
const treesAdd = new AddTilemapLayerCommand(mapId);
f.stack.execute(treesAdd);
const trees = treesAdd.layerId;
f.stack.execute(new RenameTilemapLayerCommand(mapId, trees, 'Trees'));
const tile = (name: string, props = {}) => {
const created = new CreateAssetCommand('tile', name, props);
f.stack.execute(created);
return created.asset.id;
};
const trunk = tile('Trunk');
const crown = tile('Crown', { [TILE_SORT_OFFSET_KEY]: crownOffset });
f.stack.execute(new PaintTilesCommand(mapId, ground, [{ x: 0, y: 0, tileId: trunk }]));
f.stack.execute(
new PaintTilesCommand(mapId, trees, [
{ x: 2, y: 5, tileId: trunk },
{ x: 2, y: 4, tileId: crown },
{ x: 7, y: 8, tileId: trunk },
]),
);
const mapObject = place(f, mapId, { x: 0, y: 0, z: 0 });
const box = place(f, f.spriteId, { x: 0, y: feet - 50, z: 0 });
const names = new Map([
[mapObject, 'map'],
[box, 'box'],
]);
const layerNames = new Map([
[ground, 'Ground'],
[trees, 'Trees'],
]);
const sortTrees = () => f.stack.execute(new SetTilemapLayerSortByYCommand(mapId, trees, true));
return { f, mapId, mapObject, box, ground, trees, crown, assetOf, names, layerNames, sortTrees };
}
type Scene = ReturnType<typeof makeScene>;
function readable(s: Scene, slots: DrawSlot[]): string[] {
return slots.map((slot) => {
if (!slot.layerId) return s.names.get(slot.id) ?? slot.id;
const name = s.layerNames.get(slot.layerId)!;
return slot.row === null ? name : `${name} ${slot.row}`;
});
}
function sceneOrder(s: Scene): string[] {
return readable(s, drawSlots(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items));
}
describe('a tilemap layer sorted by Y (#228)', () => {
it('is off by default, and the map then draws as one piece per layer as before', () => {
const s = makeScene(-50);
expect(s.assetOf().layers.map((l) => l.sortByY)).toEqual([undefined, undefined]);
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees', 'box']);
});
it('sets, undoes and redoes, and stores off as nothing', () => {
const s = makeScene(-50);
const trees = () => s.assetOf().layers[1]!;
s.sortTrees();
expect(trees().sortByY).toBe(true);
s.f.stack.execute(new SetTilemapLayerSortByYCommand(s.mapId, s.trees, false));
expect('sortByY' in trees()).toBe(false);
s.f.stack.undo();
expect(trees().sortByY).toBe(true);
s.f.stack.undo();
expect('sortByY' in trees()).toBe(false);
s.f.stack.redo();
expect(trees().sortByY).toBe(true);
});
it('survives a round trip, and loading keeps only a literal true', () => {
const s = makeScene(-50);
s.sortTrees();
const back = findAsset(roundTrip(s.f.project), s.mapId) as TilemapAsset;
expect(back.layers.map((l) => l.sortByY)).toEqual([undefined, true]);
const saved = serializeProject(s.f.project) as {
assets: { id: string; layers?: Record<string, unknown>[] }[];
};
const layers = saved.assets.find((a) => a.id === s.mapId)!.layers!;
for (const [raw, kept] of [
[true, true],
['true', undefined],
[1, undefined],
[false, undefined],
] as const) {
layers[1]!.sortByY = raw;
const loaded = findAsset(deserializeProject(saved), s.mapId) as TilemapAsset;
expect(loaded.layers[1]!.sortByY, `stored ${String(raw)}`).toBe(kept);
}
});
it('puts a figure in front of a row whose bottom is above its feet, behind one below', () => {
const between = makeScene(-50);
between.sortTrees();
expect(sceneOrder(between)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
const behind = makeScene(-100);
behind.sortTrees();
expect(sceneOrder(behind)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
const front = makeScene(100);
front.sortTrees();
expect(sceneOrder(front)).toEqual(['map', 'Ground', 'Trees 5', 'Trees 8', 'box']);
});
it('sorts a crown with the trunk its sort offset points to, and on its own row without one', () => {
const offset = makeScene(-80);
offset.sortTrees();
// Feet at -80: below the crown's own row (ends at -120), above the trunk's (ends at -72).
expect(sceneOrder(offset)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
const plain = makeScene(-80, 0);
plain.sortTrees();
expect(sceneOrder(plain)).toEqual(['map', 'Ground', 'Trees 4', 'box', 'Trees 5', 'Trees 8']);
});
it('hands out the very same rows while cells and offsets stay, and new ones when either changes', () => {
const s = makeScene(-80);
s.sortTrees();
const rows = () => {
const draw = collectHolderItems(s.f.project, s.f.sceneOf(), 0).items.find(
(item) => item.id === s.mapObject,
)!.tilemap!;
return layerRows(
draw,
draw.layers.find((layer) => layer.id === s.trees)!,
);
};
const first = rows();
expect(rows()).toBe(first);
expect([...first.keys()]).toEqual([5, 8]);
expect(groupByTile(first.get(5)!)).toBe(groupByTile(rows().get(5)!));
s.f.stack.execute(new SetAssetPropertyCommand(s.crown, TILE_SORT_OFFSET_KEY, 0));
const moved = rows();
expect(moved).not.toBe(first);
expect([...moved.keys()]).toEqual([4, 5, 8]);
s.f.stack.execute(new PaintTilesCommand(s.mapId, s.trees, [{ x: 3, y: 9, tileId: s.crown }]));
expect(rows()).not.toBe(moved);
expect([...rows().keys()]).toEqual([4, 5, 8, 9]);
});
it('sorts objects on that z among each other by their feet, too', () => {
const s = makeScene(100);
s.sortTrees();
const far = place(s.f, s.f.spriteId, { x: 0, y: -150, z: 0 });
s.names.set(far, 'far');
// `far` comes after `box` in the hierarchy but stands further up, so it is drawn first.
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'far', 'Trees 5', 'Trees 8', 'box']);
});
it('leaves everything on another z as it was', () => {
const s = makeScene(-50);
s.sortTrees();
s.f.stack.execute(new MoveSceneObjectCommand(s.f.sceneId, s.box, { x: 0, y: -100, z: 3 }));
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees 5', 'Trees 8', 'box']);
});
it('holds in the running game, and follows a figure that walks past a row', () => {
const s = makeScene(-100);
s.sortTrees();
const { world } = startWorld(s.f);
const gameOrder = () => readable(s, drawSlots(collectWorldItems(world).items));
expect(gameOrder()).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
world.entities.find((e) => e.objectId === s.box)!.y = 0;
expect(gameOrder()).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
});
it('picks the figure over a row behind it and the row over a figure behind it', () => {
const cellHit = (s: Scene) => ({
id: s.mapObject,
layers: [s.ground, s.trees],
rows: new Map([[s.trees, 5]]),
});
const pick = (s: Scene) =>
stackAt(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items, [
{ id: s.box, layers: null },
cellHit(s),
]).map((id) => s.names.get(id));
const inFront = makeScene(-50);
inFront.sortTrees();
expect(pick(inFront)).toEqual(['box', 'map']);
const behind = makeScene(-100);
behind.sortTrees();
expect(pick(behind)).toEqual(['map', 'box']);
});
});
/**
* The scene above with "Box" wearing a frame of a 50 px tall picture, so one image pixel is two
* units of its 100-unit box. "Stand" has the sort line under test, "Plain" has none.
*/
function withFrames(feet: number, line: number) {
const s = makeScene(feet);
const texture = new CreateAssetCommand('texture', 'Hero');
s.f.stack.execute(texture);
const sheet = findAsset(s.f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 64;
sheet.height = 50;
const stand = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 50 }, 'Stand');
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 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);
const frameOf = (id: string) => sheet.frames.find((fr) => fr.id === id)!;
return { ...s, textureId: texture.asset.id, stand: stand.frameId, plain: plain.frameId, wear, frameOf };
}
describe('the sort line of a texture frame (#228)', () => {
it('lifts where a sprite wearing the frame stands by its share of the box', () => {
// Feet at -50 are below row 5 (ends at -72); 15 px of 50 lift them 30 units, to -80, above it.
const lifted = withFrames(-50, 15);
lifted.sortTrees();
expect(sceneOrder(lifted)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
const low = withFrames(-50, 5);
low.sortTrees();
expect(sceneOrder(low)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
});
it('belongs to the frame: another frame of the same picture stands on its bottom edge', () => {
const s = withFrames(-50, 15);
s.sortTrees();
s.wear(s.plain);
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
s.wear(s.stand);
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
});
it('holds in the running game', () => {
const s = withFrames(-50, 15);
s.sortTrees();
const { world } = startWorld(s.f);
expect(readable(s, drawSlots(collectWorldItems(world).items))).toEqual([
'map',
'Ground',
'box',
'Trees 5',
'Trees 8',
]);
});
it('sets, undoes and redoes, and stores 0 as nothing', () => {
const s = withFrames(0, 12);
const stand = () => s.frameOf(s.stand);
expect(stand().sortLine).toBe(12);
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);
s.f.stack.redo();
expect('sortLine' in stand()).toBe(false);
expect('sortLine' in s.frameOf(s.plain)).toBe(false);
const fresh = withFrames(0, 0);
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);
});
it('is held to whole pixels inside the frame, also when loaded', () => {
const s = withFrames(0, 0);
for (const [raw, kept] of [
[2.6, 3],
[-4, 0],
[80, 50],
] as const) {
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>[] }[];
};
const frames = saved.assets.find((a) => a.id === s.textureId)!.frames!;
frames[0]!.sortLine = 70;
frames[1]!.sortLine = 'x';
const loaded = findAsset(deserializeProject(saved), s.textureId) as TextureAsset;
expect(loaded.frames.map((fr) => fr.sortLine)).toEqual([50, undefined]);
const back = findAsset(roundTrip(s.f.project), s.textureId) as TextureAsset;
expect(back.frames.map((fr) => fr.sortLine)).toEqual([50, undefined]);
});
it('is grabbed by its knob left of the frame, or on the line inside it', () => {
const rect = { x: 10, y: 20, w: 30, h: 40 };
expect(sortLineY(rect, 0)).toBe(60);
expect(sortLineY(rect, 6)).toBe(54);
expect(sortLineAt(rect, 6, { x: 4, y: 54 }, 1, 1, 8)).toBe('knob');
expect(sortLineAt(rect, 6, { x: 25, y: 54.5 }, 1, 1, 8)).toBe('line');
expect(sortLineAt(rect, 6, { x: 25, y: 57 }, 1, 1, 8)).toBeNull();
expect(sortLineAt(rect, 6, { x: 0, y: 54 }, 1, 1, 8)).toBeNull();
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.
const rect = { x: 100, y: 20, w: 24, h: 24 };
expect(sortLineAt(rect, 0, { x: 92, y: 44 }, 8, 4, 14)).toBe('knob');
expect(sortLineAt(rect, 0, { x: 97, y: 44 }, 8, 4, 14)).toBeNull();
});
});
describe('the sort offset of a tile (#228)', () => {
it('is 0 by default and held to whole rows between -16 and 16', () => {
const s = makeScene(0);
const crown = () => findAsset(s.f.project, s.crown) as TileAsset;
expect(tileSortOffset({ ...crown(), props: {} })).toBe(0);
for (const [raw, kept] of [
[1, 1],
[2.4, 2],
[-3, -3],
[40, 16],
[-40, -16],
['x', 0],
] as const)
expect(tileSortOffset({ ...crown(), props: { [TILE_SORT_OFFSET_KEY]: raw } }), String(raw)).toBe(kept);
});
});