A border drawn in pixels is as wide as the texture says a pixel is, so a small sheet can frame a large sprite (#96)
A sliced frame drew its borders one source pixel to one sprite unit. Pixel-art sheets are small: a 16px corner on a 400-unit button is a thin line, and the only way around it was a larger image. The texture now carries `Nine-slice scale` (default 1), next to `Filter`. It sets how many sprite units one border pixel takes, for every sliced frame of that texture. The UVs stay as they are - a corner shows the same pixels, only larger or smaller. When the sprite is too small for both borders they are still scaled down together, now starting from the scaled size. With `Repeat` the tile size scales too, otherwise a doubled border would sit beside a pattern of the old size. The value is a schema row, so inspector field, saving and undo come without their own code. Anything that is not a size above 0 reads as 1, and documents without it draw as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc6db5f350
commit
94a0a578ed
8 files changed
+105
-21
No files matched your search
@@ -45,6 +45,16 @@ 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). */
|
||||
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. */
|
||||
export 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 {
|
||||
@@ -102,7 +112,7 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
|
||||
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) : null,
|
||||
slice: frame ? nineSliceOf(frame, sliceScaleOf(asset)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,10 +135,10 @@ export function hasBorders(border: FrameBorders): boolean {
|
||||
}
|
||||
|
||||
/** What a frame is sliced into, or null when nobody sliced it – then a sprite draws it as one piece. */
|
||||
export function nineSliceOf(frame: TextureFrame): NineSlice | null {
|
||||
export 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 };
|
||||
return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h, scale };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type VariableKind,
|
||||
} from './kinds';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { SLICE_SCALE_KEY } from '../model/texture';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { flatListOf, listItemOf } from './listItems';
|
||||
import { FONT_KEY } from './fonts';
|
||||
@@ -197,6 +198,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
{ value: 'nearest', label: 'Nearest' },
|
||||
],
|
||||
},
|
||||
// How big the borders of every sliced frame are drawn: 2 makes a 16 px border 32 units wide (#96).
|
||||
{ key: SLICE_SCALE_KEY, label: 'Nine-slice scale', kind: 'number', default: 1, min: 0.01, step: 0.1 },
|
||||
],
|
||||
// The pictures are intrinsic data on the asset (see AnimationAsset).
|
||||
animation: [
|
||||
|
||||
+21
-15
@@ -19,9 +19,9 @@ export interface Span {
|
||||
|
||||
/**
|
||||
* The parts one axis is cut into. `near`/`far` are the two borders in source pixels, and they keep
|
||||
* that size in the sprite's units – that is the whole point of slicing. A sprite too small to hold
|
||||
* both is not left with overlapping corners: they are scaled down together until the middle is
|
||||
* exactly zero, so the seam sits where the two corners meet.
|
||||
* that size times `scale` in the sprite's units – that is the whole point of slicing. A sprite too
|
||||
* small to hold both is not left with overlapping corners: they are scaled down together until the
|
||||
* middle is exactly zero, so the seam sits where the two corners meet.
|
||||
*/
|
||||
export function sliceSpans(
|
||||
target: number,
|
||||
@@ -29,11 +29,14 @@ export function sliceSpans(
|
||||
near: number,
|
||||
far: number,
|
||||
fill: FrameFill,
|
||||
scale = 1,
|
||||
): Span[] {
|
||||
if (!(target > 0) || !(source > 0)) return [];
|
||||
const shrink = near + far > target ? target / (near + far) : 1;
|
||||
const n = near * shrink;
|
||||
const f = far * shrink;
|
||||
if (!(target > 0) || !(source > 0) || !(scale > 0)) return [];
|
||||
const nearUnits = near * scale;
|
||||
const farUnits = far * scale;
|
||||
const shrink = nearUnits + farUnits > target ? target / (nearUnits + farUnits) : 1;
|
||||
const n = nearUnits * shrink;
|
||||
const f = farUnits * shrink;
|
||||
const middle = Math.max(0, target - n - f);
|
||||
const sourceMiddle = source - near - far;
|
||||
const spans: Span[] = [];
|
||||
@@ -43,13 +46,15 @@ export function sliceSpans(
|
||||
const t1 = (source - far) / source;
|
||||
// Whole tiles of the frame's own size, the last one cut off where the middle ends. Sampling
|
||||
// with UVs past 1 would do it in one quad, but a frame is a cut-out of a sheet: past its edge
|
||||
// lies the neighbouring cell, not the frame again.
|
||||
const tiles = fill === 'repeat' ? Math.ceil(middle / sourceMiddle) : 1;
|
||||
// lies the neighbouring cell, not the frame again. The tile scales with the borders (#96),
|
||||
// or a border drawn twice as big would sit beside a pattern drawn at its old size.
|
||||
const tile = sourceMiddle * scale;
|
||||
const tiles = fill === 'repeat' ? Math.ceil(middle / tile) : 1;
|
||||
if (fill === 'repeat' && tiles <= MAX_TILES) {
|
||||
for (let i = 0; i < tiles; i++) {
|
||||
const p0 = n + i * sourceMiddle;
|
||||
const p1 = Math.min(n + middle, p0 + sourceMiddle);
|
||||
spans.push({ p0, p1, t0, t1: t0 + ((p1 - p0) / sourceMiddle) * (t1 - t0) });
|
||||
const p0 = n + i * tile;
|
||||
const p1 = Math.min(n + middle, p0 + tile);
|
||||
spans.push({ p0, p1, t0, t1: t0 + ((p1 - p0) / tile) * (t1 - t0) });
|
||||
}
|
||||
} else {
|
||||
spans.push({ p0: n, p1: n + middle, t0, t1 });
|
||||
@@ -68,8 +73,9 @@ export function sliceGeometry(
|
||||
w: number,
|
||||
h: number,
|
||||
): { positions: Float32Array; uvs: Float32Array; indices: Uint32Array } {
|
||||
const columns = sliceSpans(w, slice.sourceW, slice.border.left, slice.border.right, slice.fill);
|
||||
const rows = sliceSpans(h, slice.sourceH, slice.border.top, slice.border.bottom, slice.fill);
|
||||
const { border, fill, scale } = slice;
|
||||
const columns = sliceSpans(w, slice.sourceW, border.left, border.right, fill, scale);
|
||||
const rows = sliceSpans(h, slice.sourceH, border.top, border.bottom, fill, scale);
|
||||
const positions: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
@@ -103,7 +109,7 @@ export function sliceGeometry(
|
||||
*/
|
||||
export function sliceKey(slice: NineSlice, w: number, h: number): string {
|
||||
const b = slice.border;
|
||||
return `${b.left},${b.top},${b.right},${b.bottom}|${slice.fill}|${slice.sourceW}×${slice.sourceH}|${round(w)}×${round(h)}`;
|
||||
return `${b.left},${b.top},${b.right},${b.bottom}|${slice.fill}|×${slice.scale}|${slice.sourceW}×${slice.sourceH}|${round(w)}×${round(h)}`;
|
||||
}
|
||||
|
||||
/** Sub-pixel changes of size do not change the picture, and would rebuild on every mouse move. */
|
||||
|
||||
Reference in new issue
Block a user