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:
adminandClaude Opus 5 committed 2026-09-16 18:09:43 +02:00
1 parent cc6db5f350
commit 94a0a578ed
8 files changed
+105 -21

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 958 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 960 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+13 -3
View File
@@ -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 };
}
/**
+3
View File
@@ -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
View File
@@ -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. */
+1 -1
View File
@@ -16,7 +16,7 @@ describe('CreateTextureCommand', () => {
const asset = findAsset(project, command.asset.id);
expect(asset).toMatchObject({ type: 'texture', name: 'Hero', file: FILE, width: 64, height: 32 });
// The schema default comes along, so the new texture is a complete asset – nothing to fill in later.
expect(asset?.props).toEqual({ filter: 'linear' });
expect(asset?.props).toEqual({ filter: 'linear', sliceScale: 1 });
stack.undo();
expect(project.assets).toHaveLength(0);
+7
View File
@@ -83,7 +83,14 @@ describe('resolveTexture', () => {
fill: 'repeat',
sourceW: 32,
sourceH: 24,
scale: 1,
});
// The scale is the texture's, not the frame's (#96); a value that is no size falls back to 1.
texture.props.sliceScale = 2.5;
expect(resolveTexture(fixture.project, sprite.props)?.slice?.scale).toBe(2.5);
texture.props.sliceScale = 0;
expect(resolveTexture(fixture.project, sprite.props)?.slice?.scale).toBe(1);
delete texture.props.sliceScale;
delete texture.frames[0]!.border;
delete texture.frames[0]!.fill;
+38
View File
@@ -7,6 +7,7 @@ const slice = (border: Partial<NineSlice['border']>, fill: NineSlice['fill'] = '
fill,
sourceW: 24,
sourceH: 24,
scale: 1,
});
describe('sliceSpans', () => {
@@ -78,6 +79,42 @@ describe('sliceSpans', () => {
it('answers nothing for a box or a picture of no size', () => {
expect(sliceSpans(0, 24, 8, 8, 'stretch')).toEqual([]);
expect(sliceSpans(200, 0, 8, 8, 'stretch')).toEqual([]);
expect(sliceSpans(200, 24, 8, 8, 'stretch', 0)).toEqual([]);
});
it('draws the borders scaled, and samples the same pixels for them (#96)', () => {
const spans = sliceSpans(200, 24, 8, 8, 'stretch', 2);
expect(spans.map((s) => [s.p0, s.p1])).toEqual([
[0, 16],
[16, 184],
[184, 200],
]);
// Bigger on screen, not more of the picture: the corner still shows its eight pixels.
expect(spans.map((s) => [s.t0, s.t1])).toEqual([
[0, 1 / 3],
[1 / 3, 2 / 3],
[2 / 3, 1],
]);
// Below 1 the borders shrink the same way.
expect(sliceSpans(200, 24, 8, 8, 'stretch', 0.5)[0]).toMatchObject({ p0: 0, p1: 4 });
});
it('scales the repeated tile with the borders, so the pattern keeps its proportion', () => {
const middle = sliceSpans(80, 24, 8, 8, 'repeat', 2).slice(1, -1);
// A middle of 48 units in tiles of 8 × 2 = 16.
expect(middle.map((s) => [s.p0, s.p1])).toEqual([
[16, 32],
[32, 48],
[48, 64],
]);
});
it('squeezes scaled borders like unscaled ones when the sprite is too small for them', () => {
// 16 + 16 on a box of 20: both shrink to 10, the middle is gone.
expect(sliceSpans(20, 24, 8, 8, 'stretch', 2).map((s) => [s.p0, s.p1])).toEqual([
[0, 10],
[10, 20],
]);
});
});
@@ -115,5 +152,6 @@ describe('sliceKey', () => {
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(base, 201, 100));
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(slice({ left: 8, right: 7 }), 200, 100));
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(slice({ left: 8, right: 8 }, 'repeat'), 200, 100));
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey({ ...base, scale: 2 }, 200, 100));
});
});
+21 -1
View File
@@ -34,7 +34,7 @@ const SIZE = 200;
export async function nineSlice(t) {
const { check, sleep, json, evaluate, mutate, clickText, drag, press, shot, showTab, settle } = t;
const { undo, redo } = t;
const { undo, redo, select, has } = t;
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
@@ -235,6 +235,26 @@ export async function nineSlice(t) {
JSON.stringify({ w: small.w, seam }),
);
// 37f. The texture's nine-slice scale (#96): the same 8-pixel border, drawn 3 units per pixel.
// Set on the texture and not on the frame, so the sprite must follow a value it never names.
await mutate(`
Object.assign(__gm.state.project.assets.find((a) => a.id === 'ns-sprite').props, { width: ${SIZE} });
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.sliceScale = 3;`);
await sleep(400);
const tripled = runOf(await rowAcross(120), RED);
await mutate(`
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.sliceScale = 1;`);
await sleep(400);
const single = runOf(await rowAcross(120), RED);
check(
"the texture's nine-slice scale draws every border that many times bigger, and 1 undoes it",
Math.abs(tripled - 24 * scale) <= 2 && Math.abs(single - 8 * scale) <= 2,
JSON.stringify({ tripled, single, expected: [24 * scale, 8 * scale] }),
);
await shot('40-nine-slice-scale');
await select({ kind: 'asset', assetId: texture.id });
check("the scale is a row of the texture's inspector", await has('Nine-slice scale'));
// The scene back as it was, without the sprite this step brought: later readers count what is in it.
await mutate(`
__gm.state.activeScene.objects = window.__nsOthers ?? [];