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:
1 parent
1665576fb9
commit
3f12a77aea
10 files changed
+239
-4
No files matched your search
@@ -18,6 +18,7 @@ import { generalSettings, keepGeneralSettings } from '../generalSettings';
|
||||
import { currentGroupDialog } from '../panels/GroupNameDialog';
|
||||
import { currentSoundPicker } from '../sound/SoundPicker';
|
||||
import { rectOf, textProbe } from '../textProbe';
|
||||
import { SpritePreview } from '../inspector/SpritePreview';
|
||||
import { currentImagePicker } from '../texture/ImagePicker';
|
||||
import type { UsersPane } from '../users/UsersPane';
|
||||
import { Color, type ColorSource, type Container, Text } from 'pixi.js';
|
||||
@@ -167,6 +168,21 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
return (last ? hits.at(-1) : hits[0]) ?? null;
|
||||
},
|
||||
inspectorField: fieldOf,
|
||||
/** A sprite's preview in the inspector, scrolled into view: where its picture is drawn, on screen. */
|
||||
inspectorSpritePreview: () => {
|
||||
let preview: SpritePreview | null = null;
|
||||
const walk = (node: Container): void => {
|
||||
if (node instanceof SpritePreview) preview = node;
|
||||
else for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(inspector);
|
||||
const found = preview as SpritePreview | null;
|
||||
if (!found) return null;
|
||||
inspector.reveal(found);
|
||||
const at = found.getGlobalPosition();
|
||||
const { x, y, w, h } = found.drawn;
|
||||
return { x: at.x + x, y: at.y + y, w, h, animates: found.animates };
|
||||
},
|
||||
/** The mark in front of the row named `label` – what a variable, a field or a port is dragged by (#310). */
|
||||
inspectorMark: (label: string, last = false) => {
|
||||
const mark = besideOf(label, last, -1);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user