Preview a sprite at the end of its inspector, placed or as an asset

A sprite's inspector listed its rows but never showed what they add up
to. The picture, its frame, nine-slice, tint and shader could only be
judged in the scene, often far from the object being edited.

- SpritePreview draws the sprite through syncSpriteMesh, the function
  the scene draws it with. Slices, padding, tint and shader come out
  the same without a second path. syncSpriteMesh now takes only the
  three fields of a DrawItem it reads.
- The subject is resolved by previewPicture from the Shader tab, so a
  placed sprite shows its own overrides.
- previewFit is the rule for size and place: whole device pixels per
  unit from 1 on, at most 8, and the corner on a whole device pixel.
  Pixel art stays sharp. A sprite larger than the group shrinks freely.
- A shader with a Time node keeps the preview redrawing once a frame
  while it is on screen. A folded group or a hidden panel stops it.
- New test hook __gm.inspectorSpritePreview().

Finding: the preview redrew on a model change in a microtask at first.
A drawn picture whose revision moved on is retried once, in a
microtask queued by the scene's synchronous redraw. That retry ran
before the preview had let go, and the old pixel art sheet stayed
waiting (e2e 48l-iii read waiting [1,1,1]). The preview now redraws
at once on a model change, as the scene does.

Tests:
- unit: 238 files, 3592 tests (+5 in preview-fit.test.ts), and the
  group list of a placed sprite in sections.test.ts ends in PREVIEW
- e2e 48: ALL PASSED, 47 checks (+2: 48l-i asset preview with
  the frame, 48l-iii placed preview with its shader values)
- counter-checks: without whole-pixel zoom 2 unit tests fail; with
  the microtask redraw 48l-iii fails with waiting [1,1,1]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-04 14:27:35 +02:00
1 parent 1665576fb9
commit 3f12a77aea
10 files changed
+239 -4

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
import { Graphics } from 'pixi.js';
import { SHEET_EDGE } from '../../core/shader/nodes';
import { crisp, hairline, type Size, Theme, ui, Widget } from '../../ui';
import { cancelFrameWork, onFrame } from '../../ui/render';
import { quadPadding } from '../../viewport/quadPadding';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { wornShader } from '../../viewport/shaders';
import { disposeSpriteMesh, newSpriteMesh, syncSpriteMesh } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { type PictureSubject, previewPicture } from '../shader/previewPicture';
import type { EditorState } from '../state';
import { addTitle, type InspectorHost } from './host';
import { previewFit } from './previewFit';
const HEIGHT = 140;
const MARGIN = 8;
/** A 16-px sprite fills the panel only blurred by the zoom; past this it stops growing. */
const MAX_ZOOM = 8;
let instances = 0;
/** The PREVIEW group at the end of a sprite's inspector, placed or not. */
export function addSpritePreview(host: InspectorHost, subject: PictureSubject): void {
addTitle(host, 'PREVIEW');
host.body.add(new SpritePreview(host.state, subject));
}
/**
* A sprite as the scene draws it – picture, slices, tint and shader through `syncSpriteMesh` –
* fitted into the inspector. Read from the model on every redraw, so an edit shows at once; a
* shader with a Time node keeps it redrawing while it is on screen.
*/
export class SpritePreview extends Widget {
private readonly bg = crisp(new Graphics());
private readonly clip = new Graphics();
private readonly mesh = newSpriteMesh();
private readonly frameKey = `sprite-preview:${(instances += 1)}`;
private readonly stopWatching: (() => void)[];
/** Where the picture was drawn, in panel pixels – what the checks sample. */
drawn = { x: 0, y: 0, w: 0, h: 0 };
animates = false;
constructor(
private readonly state: EditorState,
private readonly subject: PictureSubject,
) {
super();
this.eventMode = 'none';
this.minHeight = HEIGHT;
this.mesh.mask = this.clip;
this.addChild(this.bg, this.mesh, this.clip);
this.stopWatching = [
// At once rather than in a microtask: a drawn picture whose revision moved on is freed once
// every wearer has synced in the same turn, and a later redraw here would keep it waiting.
state.on('project', () => this.redraw()),
textureLibrary.onChange(() => this.invalidate()),
];
}
override measure(): Size {
return { w: this.minWidth, h: HEIGHT };
}
override redraw(): void {
const c = Theme.colors;
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.viewport);
this.bg.rect(0, 0, this.w, this.h).stroke({ width: hairline(), color: c.border, alignment: 1 });
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
const picture = previewPicture(this.state.project, this.subject);
this.mesh.visible = picture !== null;
this.animates = false;
if (!picture) return;
const shaders = shaderLibrary.source(this.state.project);
const worn = wornShader(picture.props, shaders);
const texture = picture.texture && textureLibrary.get(picture.texture, worn?.edge ?? SHEET_EDGE);
const pad = quadPadding(worn?.padding ?? 0, texture?.width ?? picture.w, texture?.height ?? picture.h);
const ratio = ui().app.renderer.resolution;
const { zoom, x, y } = previewFit(picture.w, picture.h, this, {
margin: MARGIN,
maxZoom: MAX_ZOOM,
ratio,
pad,
});
const [w, h] = [picture.w * zoom, picture.h * zoom];
const m = { a: zoom, b: 0, c: 0, d: zoom, tx: x + w / 2, ty: y + h / 2 };
syncSpriteMesh(
this.mesh,
{ m, props: picture.props, texture: picture.texture },
picture,
textureLibrary,
shaders,
);
this.drawn = { x, y, w, h };
this.animates = Boolean(worn?.time) && this.onScreen();
if (this.animates) onFrame(this.frameKey, () => this.invalidate());
}
/** Folding the section or leaving the panel hides an ancestor; nothing is then worth a frame. */
private onScreen(): boolean {
if (!this.visible) return false;
for (let at = this.parent; at; at = at.parent) if (!at.visible) return false;
return true;
}
override destroy(): void {
for (const stop of this.stopWatching) stop();
cancelFrameWork(this.frameKey);
disposeSpriteMesh(this.mesh);
super.destroy({ children: true });
}
}
+2
View File
@@ -65,6 +65,7 @@ import { addedRows, addNameField, addNote, addTitle, type InspectorHost, section
import { buildShapeListSection, shapeListSignature } from './shapeSection';
import { buildVariable, kindMenu, type StructTyping, type VariableEditing } from './variableRows';
import { listSource, previewingHandlers, structFields } from './previewHandlers';
import { addSpritePreview } from './SpritePreview';
import { assetValue } from './valueSpecs';
/** A struct field declares its own fields, starting with none. */
@@ -171,6 +172,7 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
host.body.add(
asset.file ? new SoundPreview(asset.file) : new Label('Not uploaded.', { style: 'warning' }),
);
if (asset.type === 'sprite') addSpritePreview(host, { kind: 'asset', assetId });
}
/** Pushes the current asset values into the rows. False when the asset is gone (rebuild). */
+2
View File
@@ -62,6 +62,7 @@ import { addGap, addNameField, addNote, addTitle, type InspectorHost, sectionHea
import type { EditorState, ListSlot } from '../state';
import { parentRow } from './parentRow';
import { listSource, previewingHandlers, structFields } from './previewHandlers';
import { addSpritePreview } from './SpritePreview';
import { overrideValue, tilemapSizeValue } from './valueSpecs';
import { buildVariable } from './variableRows';
import { objectPicker } from '../objectPick';
@@ -243,6 +244,7 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
}
if (asset.type === 'tilemap') gridEdgesSection(host, holderId, object);
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
if (asset.type === 'sprite') addSpritePreview(host, { kind: 'object', holderId, objectId });
}
/**
+24
View File
@@ -0,0 +1,24 @@
/**
* How big a picture of `w`×`h` units is drawn in a preview of `room` and where its top left lands.
* `pad` is the room a worn shader asks for around it, as a share of each side (see `quadPadding`).
* From one device pixel per unit on the zoom is whole device pixels, and the corner sits on one, so
* pixel art stays sharp; below that the picture only shrinks to fit. `ratio` is device pixels per
* panel pixel.
*/
export function previewFit(
w: number,
h: number,
room: { w: number; h: number },
options: { margin: number; maxZoom: number; ratio: number; pad?: { u: number; v: number } | null },
): { zoom: number; x: number; y: number } {
const { margin, maxZoom, ratio, pad } = options;
const fits = Math.min(
(room.w - 2 * margin) / (w * (1 + 2 * (pad?.u ?? 0))),
(room.h - 2 * margin) / (h * (1 + 2 * (pad?.v ?? 0))),
maxZoom,
);
const device = fits * ratio;
const zoom = Math.max(0, device >= 1 ? Math.floor(device) / ratio : fits);
const corner = (space: number, size: number) => Math.round(((space - size * zoom) / 2) * ratio) / ratio;
return { zoom, x: corner(room.w, w), y: corner(room.h, h) };
}