Files
feuerwerk/src/core/model/texture.ts
T
adminandClaude Opus 5.5 89048413f1 Let a texture frame set where a sprite stands for Y sorting (#228)
A sprite sorted by Y stood on the bottom of its box. With a shadow or
empty rows under the feet that is too low, so a figure slipped in
front of a tree it should stand behind. The line belongs to the frame,
not the texture, because one texture can hold several sprite sheets.

- New field `sortLine` on a frame: whole image pixels up from the
  frame's bottom edge, 0 to the frame height. 0 is stored as nothing,
  so old projects stay the same. Shrinking a frame keeps the line
  inside it.
- `feetOf` in drawOrder.ts lifts a sprite by the same share of its
  box. Scene view, game view, play and picking all use it. In an
  animation the line of the frame shown right now counts.
- The texture tab draws the selected frame's line dashed in yellow
  with a knob left of the frame. At 0 the line shares the bottom edge
  with the nine-slice border and a resize handle, so the knob is
  tested first. A line inside the frame can be dragged directly,
  tested after the nine-slice lines.
- The inspector has a new "Sorting" section with a "Sort line"
  field. Dragging or typing is one undo step each.

Limit: frames of one walk cycle need the same value, set by hand.
Otherwise the figure changes its place in the order while walking.

Tests:
- ./check.sh: 160 files, 2356 tests, CHECK EXIT 0 (before 2350).
- 6 new unit tests in tests/viewport/sort-by-y.test.ts.
- e2e 37 and 39-43: ALL PASSED, 162 checks. New check 37b-ii drags
  the knob up 6 px and undoes it, plus the inspector row.
- Counter checks: if feetOf ignores the line, exactly the 3 order
  tests fail. If the knob cannot be grabbed, 37b-ii fails.
- The e2e sum in the README is 1380, counted, not yet seen in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:34:37 +02:00

365 lines
15 KiB
TypeScript
Raw 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 type { Asset, FrameBorders, FrameFill, 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 frame 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. */
sortLine?: 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 texture'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 {
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. No slicing –
* that is cut out of an uploaded sheet; a frame only where the drawing has frames (pixel art).
*/
function generatedRef(asset: Asset, project: Project, rect: FrameRect | null = null): TextureRef | null {
const picture = generatedPictureOf(asset, project);
if (!picture) return null;
return {
file: generatedKey(asset.id, picture.revision),
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
rect,
sourceWidth: picture.width,
sourceHeight: picture.height,
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 } : {}),
};
}
/** Whole pixels from the frame'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);
}
/** 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;
}
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 frameBorders(frame: TextureFrame): FrameBorders {
return frame.border ? { ...frame.border } : { ...NO_BORDERS };
}
export function frameFill(frame: TextureFrame): FrameFill {
return frame.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);
if (!hasBorders(border)) return null;
return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.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 frame, as one value – what an inspector row and a command pass around. */
export interface FrameSlice {
border: FrameBorders;
fill: FrameFill;
}
export function frameSlice(frame: TextureFrame): FrameSlice {
return { border: frameBorders(frame), fill: frameFill(frame) };
}
/**
* Writes the slice back, dropping the keys again once they mean nothing: a frame nobody sliced
* reads and saves exactly as it did before #41, 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;
// 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;
}
/** 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;
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;
}
/**
* 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;
}
}