A shader could only be judged by placing it on a sprite and running the scene. The shader tab now shows its effect next to the graph, on the image the user picked last, without a picker to maintain. - The preview follows the last selection that has an image: a placed sprite with its overrides, size and tint, a sprite asset, a texture or a frame. Clicking the shader, a node, a behavior or a scene keeps the picture. - Without such a selection, or when the picked image is deleted, a built-in 64 px test image is shown. Its colour runs with u and v, a checkerboard shows where a UV lands and the border fades for alpha. - Each property gets a slider. The sliders belong to the preview and never write to the project. They start at the sprite's values when it carries this shader, otherwise at the default. - The preview has its own clock. Hold and Restart are only enabled when a Time node is in the graph. - The graph | splitter | panel layout of the particles tab moves into PreviewSplit, and both tabs use it. - A nine-sliced frame is previewed unsliced. This is documented. Findings: - The slider row stayed squeezed to a few pixels when the properties changed while the tab was hidden. The layout was triggered one level too high. The slider check in block 28n holds this. - The graph canvas is narrower now, 385 px at a 1280 px window, the same as in the particles tab. Block 28b clicked at a fixed x+420 and now uses the measured width. Tests: - ./check.sh: 132 files, 1952 tests (was 131 / 1941), exit 0 - npm run build: exit 0 - GM_STEPS=28,31 freshly seeded: ALL PASSED, 105 checks - 11 new unit tests in tests/editor/shader-preview.test.ts - 7 new e2e checks in block 28n - Counter-probes: start values ignoring the sprite fail 1 unit test, a preview ignoring the selection fails 2 e2e checks - Check total 1209 in README is computed (1202 + 7), CI measures it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
7.1 KiB
TypeScript
163 lines
7.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
|
||
import { RemoveSceneObjectsCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import type { FlatDef } from '../../src/core/properties/PropertySchema';
|
||
import {
|
||
pictureSubjectOf,
|
||
previewPicture,
|
||
sliderRange,
|
||
startValues,
|
||
TEST_IMAGE_SIZE,
|
||
testImagePixels,
|
||
} from '../../src/editor/shader/previewPicture';
|
||
import type { Selection } from '../../src/editor/state';
|
||
import { makeSpriteScene, place } from '../helpers';
|
||
|
||
const FILE = `${'cd'.repeat(32)}.png`;
|
||
|
||
/** A scene with a sprite "Box", a 64×32 texture "Sheet" with a 16×8 frame "Idle", and one placed Box. */
|
||
function setup() {
|
||
const fixture = makeSpriteScene();
|
||
const texture = new CreateTextureCommand('Sheet', { file: FILE, width: 64, height: 32 });
|
||
fixture.stack.execute(texture);
|
||
const frame = new AddFrameCommand(texture.asset.id, { x: 16, y: 8, w: 16, h: 8 }, 'Idle');
|
||
fixture.stack.execute(frame);
|
||
const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||
const stored = () => fixture.sceneOf().objects.find((o) => o.id === objectId)!;
|
||
return { ...fixture, textureId: texture.asset.id, frameId: frame.frameId, objectId, stored };
|
||
}
|
||
|
||
function numberRow(key: string, extra: Partial<FlatDef<'number'>> = {}): FlatDef<'number'> {
|
||
return { key, label: key, kind: 'number', default: 1, min: 0, max: 2, ...extra };
|
||
}
|
||
|
||
describe('the picture the Shader tab previews (#168)', () => {
|
||
it('is named by a selected sprite, placed or not, a texture or a frame', () => {
|
||
const f = setup();
|
||
const object: Selection = {
|
||
kind: 'object',
|
||
holderId: f.sceneId,
|
||
objectId: f.objectId,
|
||
objectIds: [f.objectId],
|
||
};
|
||
expect(pictureSubjectOf(f.project, object)).toEqual({
|
||
kind: 'object',
|
||
holderId: f.sceneId,
|
||
objectId: f.objectId,
|
||
});
|
||
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.spriteId })).toEqual({
|
||
kind: 'asset',
|
||
assetId: f.spriteId,
|
||
});
|
||
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.textureId })).not.toBeNull();
|
||
expect(pictureSubjectOf(f.project, { kind: 'frame', assetId: f.textureId, frameId: f.frameId })).toEqual({
|
||
kind: 'frame',
|
||
frameId: f.frameId,
|
||
});
|
||
});
|
||
|
||
it('is not named by anything without a picture, so the preview keeps the one before', () => {
|
||
const f = setup();
|
||
const behavior = new CreateAssetCommand('behavior', 'Mover');
|
||
f.stack.execute(behavior);
|
||
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: behavior.asset.id })).toBeNull();
|
||
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.sceneId })).toBeNull();
|
||
expect(pictureSubjectOf(f.project, null)).toBeNull();
|
||
const node: Selection = {
|
||
kind: 'node',
|
||
assetId: behavior.asset.id,
|
||
nodeId: 'n',
|
||
nodeIds: ['n'],
|
||
annotationIds: [],
|
||
};
|
||
expect(pictureSubjectOf(f.project, node)).toBeNull();
|
||
});
|
||
|
||
it('shows a frame at its own size, a whole texture at the sheet size', () => {
|
||
const f = setup();
|
||
const frame = previewPicture(f.project, { kind: 'frame', frameId: f.frameId })!;
|
||
expect(frame).toMatchObject({ name: 'Sheet / Idle', w: 16, h: 8, tint: 0xffffff, props: {} });
|
||
expect(frame.texture?.rect).toEqual({ x: 16, y: 8, w: 16, h: 8 });
|
||
const sheet = previewPicture(f.project, { kind: 'asset', assetId: f.textureId })!;
|
||
expect(sheet).toMatchObject({ name: 'Sheet', w: 64, h: 32 });
|
||
expect(sheet.texture?.rect).toBeNull();
|
||
});
|
||
|
||
it('shows a placed sprite as the scene does: its overrides, size and colour', () => {
|
||
const f = setup();
|
||
Object.assign(f.stored().overrides, { texture: f.frameId, width: 48, height: 24, color: 0xff8040 });
|
||
const picture = previewPicture(f.project, { kind: 'object', holderId: f.sceneId, objectId: f.objectId })!;
|
||
expect(picture).toMatchObject({ name: 'Box', w: 48, h: 24, tint: 0xff8040 });
|
||
expect(picture.texture?.rect).toEqual({ x: 16, y: 8, w: 16, h: 8 });
|
||
// The asset itself is untouched by the object's overrides.
|
||
const asset = previewPicture(f.project, { kind: 'asset', assetId: f.spriteId })!;
|
||
expect(asset).toMatchObject({ name: 'Box', w: 100, h: 100, texture: null });
|
||
});
|
||
|
||
it('follows an edit, and is gone with its object – the tab then shows the test image', () => {
|
||
const f = setup();
|
||
const subject = { kind: 'object', holderId: f.sceneId, objectId: f.objectId } as const;
|
||
f.stored().overrides.width = 20;
|
||
expect(previewPicture(f.project, subject)?.w).toBe(20);
|
||
f.stack.execute(new RemoveSceneObjectsCommand(f.sceneId, [f.objectId]));
|
||
expect(previewPicture(f.project, subject)).toBeNull();
|
||
f.stack.undo();
|
||
expect(previewPicture(f.project, subject)?.w).toBe(20);
|
||
expect(findAsset(f.project, f.spriteId)).toBeDefined();
|
||
});
|
||
});
|
||
|
||
describe('the preview sliders (#168)', () => {
|
||
const defs = [numberRow('glow'), numberRow('speed', { default: 0.5 })];
|
||
|
||
it('start at the values of a sprite wearing this shader', () => {
|
||
expect(startValues(defs, { shader: 'neon', glow: 1.75, speed: 0.25 }, 'neon')).toEqual({
|
||
glow: 1.75,
|
||
speed: 0.25,
|
||
});
|
||
// A row the sprite does not carry yet starts at its default.
|
||
expect(startValues(defs, { shader: 'neon', glow: 0.2 }, 'neon')).toEqual({ glow: 0.2, speed: 0.5 });
|
||
});
|
||
|
||
it('start at the defaults when the picture wears another shader or none', () => {
|
||
expect(startValues(defs, { shader: 'other', glow: 1.75 }, 'neon')).toEqual({ glow: 1, speed: 0.5 });
|
||
expect(startValues(defs, {}, 'neon')).toEqual({ glow: 1, speed: 0.5 });
|
||
});
|
||
|
||
it('reach the declared range, or twice the default without a max', () => {
|
||
expect(sliderRange(numberRow('a', { min: -1, max: 3 }))).toEqual({ min: -1, max: 3 });
|
||
expect(sliderRange(numberRow('b', { max: undefined, default: 1.5 }))).toEqual({ min: 0, max: 3 });
|
||
expect(sliderRange(numberRow('c', { min: 2, max: undefined, default: 2 }))).toEqual({ min: 2, max: 3 });
|
||
});
|
||
});
|
||
|
||
describe('the built-in test image (#168)', () => {
|
||
const pixels = testImagePixels();
|
||
const at = (x: number, y: number) =>
|
||
Array.from(pixels.slice((y * TEST_IMAGE_SIZE + x) * 4, (y * TEST_IMAGE_SIZE + x) * 4 + 4));
|
||
|
||
it('runs red from left to right and green from top to bottom', () => {
|
||
expect(pixels.length).toBe(TEST_IMAGE_SIZE * TEST_IMAGE_SIZE * 4);
|
||
const [r1, g1, b1] = at(16, 16);
|
||
const [r2, g2, b2] = at(48, 48);
|
||
expect(r2).toBeGreaterThan(r1!);
|
||
expect(g2).toBeGreaterThan(g1!);
|
||
expect(b2).toBeLessThan(b1!);
|
||
});
|
||
|
||
it('has a checker, so a moved UV shows', () => {
|
||
// Two neighbours across a cell border: same place in the gradient, a different shade.
|
||
expect(at(24, 20)[0]! / at(23, 20)[0]!).toBeCloseTo(0.55, 1);
|
||
});
|
||
|
||
it('fades out at the edge and is opaque inside', () => {
|
||
expect(at(0, 32)[3]).toBe(16);
|
||
expect(at(32, TEST_IMAGE_SIZE - 1)[3]).toBe(16);
|
||
expect(at(4, 32)[3]).toBe(143);
|
||
expect(at(8, 32)[3]).toBe(255);
|
||
expect(at(32, 32)[3]).toBe(255);
|
||
});
|
||
});
|