From 3f12a77aea8816aa6f9ff3d9153ff04b2f1bc4b6 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 4 Oct 2026 14:27:35 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/editor/debug/uiHooks.ts | 16 ++++ src/editor/inspector/SpritePreview.ts | 111 ++++++++++++++++++++++++++ src/editor/inspector/assetSection.ts | 2 + src/editor/inspector/objectSection.ts | 2 + src/editor/inspector/previewFit.ts | 24 ++++++ src/viewport/symbols.ts | 2 +- tests/editor/preview-fit.test.ts | 46 +++++++++++ tests/editor/sections.test.ts | 1 + tools/e2e/48-pixelart.mjs | 37 ++++++++- 10 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 src/editor/inspector/SpritePreview.ts create mode 100644 src/editor/inspector/previewFit.ts create mode 100644 tests/editor/preview-fit.test.ts diff --git a/README.md b/README.md index 47b5c23f..7e7445ca 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,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, 1742 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1744 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 3e152d5a..667973a9 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -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); diff --git a/src/editor/inspector/SpritePreview.ts b/src/editor/inspector/SpritePreview.ts new file mode 100644 index 00000000..8deedcce --- /dev/null +++ b/src/editor/inspector/SpritePreview.ts @@ -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 }); + } +} diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 3efce6cc..e8651871 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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). */ diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 4c0dc244..ee318288 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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 }); } /** diff --git a/src/editor/inspector/previewFit.ts b/src/editor/inspector/previewFit.ts new file mode 100644 index 00000000..f5835c21 --- /dev/null +++ b/src/editor/inspector/previewFit.ts @@ -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) }; +} diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index cb77e1bc..ecc7456e 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -63,7 +63,7 @@ export function newSpriteMesh(): Mesh { export function syncSpriteMesh( mesh: Mesh, - item: DrawItem, + item: Pick, size: { w: number; h: number }, textures: TextureLibrary, shaders: ShaderSource, diff --git a/tests/editor/preview-fit.test.ts b/tests/editor/preview-fit.test.ts new file mode 100644 index 00000000..92326132 --- /dev/null +++ b/tests/editor/preview-fit.test.ts @@ -0,0 +1,46 @@ +/** + * A sprite's preview in the inspector: magnified by whole device pixels with its corner on one, so + * pixel art stays sharp; shrunk freely when it does not fit, and with room for a shader's padding. + */ +import { describe, expect, it } from 'vitest'; +import { previewFit } from '../../src/editor/inspector/previewFit'; + +const room = { w: 300, h: 140 }; +const options = { margin: 8, maxZoom: 8, ratio: 1 }; + +/** Whether `v` device pixels is a whole one, within float noise. */ +const whole = (v: number) => Math.abs(v - Math.round(v)) < 1e-9; + +describe('previewFit', () => { + it('magnifies a small sprite by a whole factor and centres it on whole pixels', () => { + // 124 / 15 = 8.27 is capped at 8; the 120-px picture leaves 90 and 10 at the sides. + const fit = previewFit(15, 15, room, options); + expect(fit).toEqual({ zoom: 8, x: 90, y: 10 }); + }); + + it('rounds the zoom down to whole device pixels', () => { + // 124 / 48 = 2.58: two pixels per unit, not a blur between two and three. + expect(previewFit(48, 48, room, options).zoom).toBe(2); + // At a device ratio of 1.5 the same room holds 3.875 device pixels per unit: three, = 2 panel px. + const fit = previewFit(48, 48, room, { ...options, ratio: 1.5 }); + expect(fit.zoom).toBe(2); + expect(whole(fit.x * 1.5) && whole(fit.y * 1.5)).toBe(true); + }); + + it('puts the corner of an odd size on a whole device pixel', () => { + const fit = previewFit(15, 9, room, { ...options, ratio: 1.25 }); + expect(whole(fit.zoom * 1.25)).toBe(true); + expect(whole(fit.x * 1.25) && whole(fit.y * 1.25)).toBe(true); + }); + + it('shrinks a sprite bigger than the room to fit, without rounding to nothing', () => { + const fit = previewFit(800, 200, room, options); + expect(fit.zoom).toBeCloseTo(284 / 800); + expect(800 * fit.zoom).toBeLessThanOrEqual(room.w - 16); + }); + + it("keeps room for a shader's padding", () => { + // 10 % on each side: 124 / (15 * 1.2) = 6.9 → 6 rather than 8. + expect(previewFit(15, 15, room, { ...options, pad: { u: 0.1, v: 0.1 } }).zoom).toBe(6); + }); +}); diff --git a/tests/editor/sections.test.ts b/tests/editor/sections.test.ts index 7b7b7b98..4124db86 100644 --- a/tests/editor/sections.test.ts +++ b/tests/editor/sections.test.ts @@ -351,6 +351,7 @@ describe('the groups of the inspector', () => { '[TRANSFORM]', '[EDITOR OFFSET (SCENE VIEW ONLY)]', '[PROPERTIES]', + '[PREVIEW]', ]); }); diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs index 808b4d43..b7432944 100644 --- a/tools/e2e/48-pixelart.mjs +++ b/tools/e2e/48-pixelart.mjs @@ -675,6 +675,26 @@ export async function pixelArt(t) { JSON.stringify({ spriteRed, spriteBlue }), ); + // 48l-i. The inspector previews the selected sprite as the scene draws it, frame and all. + const previewSpots = async () => { + const rect = await json('__gm.inspectorSpritePreview()'); + await settle(); + if (!rect) return null; + const at = (px, py) => + json( + `__gm.pixelAt(${Math.round(rect.x + ((px + 0.5) / 32) * rect.w)}, ${Math.round(rect.y + ((py + 0.5) / 32) * rect.h)})`, + ); + return { rect, red: await at(5, 2), blue: await at(10, 10) }; + }; + const assetPreview = await previewSpots(); + check( + 'the inspector previews the sprite asset with the frame it wears', + Boolean(assetPreview) && + colourDistance(assetPreview.red, HEX[RED]) < 12 && + colourDistance(assetPreview.blue, HEX[BLUE]) < 12, + JSON.stringify(assetPreview), + ); + // 48l-ii. Nine-slice on pixel art (#308): 12-pixel borders at scale 2 draw the corner at 2 units a // pixel instead of 4, so red (5, 2) and blue (10, 10) move to units (11, 5) and (21, 21). The same // spots are read before the borders are set, so a sprite that ignored them could not pass. @@ -710,7 +730,8 @@ export async function pixelArt(t) { // once and the cut with it, and Pixi warned for the source and its sampler every time. await mutate(` Object.assign(__gm.state.activeScene.objects.find((o) => o.id === 'pa-object').overrides, - { shader: 'adjust-colors', saturation: 0 })`); + { shader: 'adjust-colors', saturation: 0 }); + __gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: 'pa-object', objectIds: ['pa-object'] })`); await settle(); await sleep(300); const boundLogFrom = t.consoleLog.length; @@ -730,10 +751,22 @@ export async function pixelArt(t) { boundWarnings.length === 0 && waiting.every((n) => n === 0), JSON.stringify({ warnings: boundWarnings.length, waiting }), ); + // The preview of the placed object wears its shader and its own saturation of 0: no colour left. + const objectPreview = await previewSpots(); + const grey = (c) => { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); + return Math.max(r, g, b) - Math.min(r, g, b) <= 6; + }; + check( + "the preview of a placed sprite draws its shader with the object's own values", + Boolean(objectPreview) && grey(objectPreview.red) && grey(objectPreview.blue), + JSON.stringify(objectPreview), + ); await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === 'pa-object'); delete o.overrides.shader; - delete o.overrides.saturation;`); + delete o.overrides.saturation; + __gm.state.select({ kind: 'asset', assetId: 'pa-sprite' })`); await settle(); const textureRow = await json("__gm.inspectorField('Texture', true)");