Files
feuerwerk/tests/editor/frame-preview.test.ts
adminandClaude Opus 5.5 cb3183227b Keep drags at full speed in large projects (#394)
Dragging a band of a Filter or a Parametric EQ ran at about 30 fps
in a large project. Each frame of a drag announces the changed
project, and every listener of that event ran, also those whose work
nobody sees while the drag goes on.

- FramePreview marks its own frame, read through isDragFrame().
- The Scene view behind another tab skips such a frame and draws on
  coming back, as it already did while a game runs (#265).
- preloadProjectFiles skips it as well. It walks every asset and
  checks every bake, which costs milliseconds in a large project.
- The command that ends a drag emits for itself, and a drag that
  changes nothing ends with a plain preview, so both catch up then.
- This holds for every drag through FramePreview, the knobs of the
  devices in the graph included.

Measured on stein2 with a copy of a project of 162 assets, a band of
a Parametric EQ dragged at 250 pointer moves a second for 3 s:

- Listeners per frame: 17 ms before, 7.4 ms after.
- Hidden Scene view: 814 ms before, 53 ms after.
- preloadProjectFiles: 514 ms before, 0.4 ms after.
- What is left is mostly the Hierarchy and Assets panels on screen
  (about 3 and 1.3 ms a frame). They stay as they are.

Tests:

- Unit frame-preview, play-throttle: 29 passed (1 new)
- Counter-check: without the mark the new test fails
- e2e 34 (melektaus-atx): ALL PASSED, 136 checks
- Gate (melektaus-atx): CHECK EXIT 0, 288 files, 4417 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:45:59 +02:00

472 lines
17 KiB
TypeScript

/**
* A drag writes the model on every pointer move but tells the editor once per frame (#213): the
* model event rebuilds panels and pictures, and a mouse reports faster than the screen draws.
*/
import { type Application, CanvasTextMetrics } from 'pixi.js';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import type { Command } from '../../src/core/commands/Command';
import type {
CurveAsset,
GraphAsset,
PaletteAsset,
PixelArtAsset,
SceneAsset,
TilemapAsset,
VectorArtAsset,
VectorGradient,
} from '../../src/core/model/Asset';
import { PALETTE_KEY } from '../../src/core/model/pixelart';
import { createEmptyProject } from '../../src/core/model/Project';
import { CurveCanvas } from '../../src/editor/curve/CurveCanvas';
import { FramePreview, isDragFrame } from '../../src/editor/framePreview';
import { GraphDrags } from '../../src/editor/graph/graphDrags';
import type { GraphCanvasHost } from '../../src/editor/graph/graphHost';
import type { InspectorHost } from '../../src/editor/inspector/host';
import { previewingHandlers } from '../../src/editor/inspector/previewHandlers';
import { buildShapeSection, currentGradientBar } from '../../src/editor/inspector/shapeSection';
import { PixelArtCanvas } from '../../src/editor/pixelart/PixelArtCanvas';
import { PixelArtSide } from '../../src/editor/pixelart/PixelArtSide';
import { PixelThumbs } from '../../src/editor/pixelart/PixelThumbs';
import { EditorState, objectSelection } from '../../src/editor/state';
import { chooseTool, pickTile } from '../../src/editor/panels/TilemapPanel';
import type { SceneViewHost } from '../../src/editor/viewport/sceneViewHost';
import { TilemapPainter } from '../../src/editor/viewport/tilemapPainting';
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
import { IDENTITY } from '../../src/core/model/matrix';
import { cellOrigin, tilemapTileSize } from '../../src/core/model/tilemap';
import type { DrawItem } from '../../src/viewport/items';
import { VStack } from '../../src/ui';
import { initRenderLoop, resetRenderLoop } from '../../src/ui/render';
import { PaintCache } from '../../src/viewport/vectorArtPicture';
import { allShapes } from '../../src/core/model/vectorTree';
/** One frame of the render loop, the way the ticker runs it. */
function frames(): () => void {
const added: Array<() => void> = [];
const app = {
render(): void {},
ticker: {
add: (fn: () => void) => added.push(fn),
remove: () => undefined,
},
};
initRenderLoop(app as unknown as Application);
return () => added.forEach((fn) => fn());
}
function fakeState() {
return {
selectedAnnotationIds: [] as string[],
selectedNodeIds: ['n1'],
preview: vi.fn(),
execute: vi.fn(() => true),
};
}
describe('FramePreview', () => {
beforeEach(() => resetRenderLoop());
it('turns any number of requests in one frame into a single preview', () => {
const tick = frames();
const state = fakeState();
const preview = new FramePreview(state, 'drag');
for (let i = 0; i < 5; i++) preview.request();
expect(state.preview).not.toHaveBeenCalled();
tick();
expect(state.preview).toHaveBeenCalledTimes(1);
tick();
expect(state.preview).toHaveBeenCalledTimes(1);
});
it('drops the waiting preview on cancel', () => {
const tick = frames();
const state = fakeState();
const preview = new FramePreview(state, 'drag');
preview.request();
preview.cancel();
tick();
expect(state.preview).not.toHaveBeenCalled();
});
it('says during its own preview that the event is a drag frame, and at no other time (#394)', () => {
const tick = frames();
const seen: boolean[] = [];
const state = { preview: vi.fn(() => seen.push(isDragFrame())) };
new FramePreview(state, 'drag').request();
expect(isDragFrame()).toBe(false);
tick();
state.preview();
expect(seen).toEqual([true, false]);
expect(isDragFrame()).toBe(false);
});
it('keeps two gestures of the same name apart', () => {
const tick = frames();
const a = fakeState();
const b = fakeState();
new FramePreview(a, 'drag').request();
new FramePreview(b, 'drag').request();
tick();
expect(a.preview).toHaveBeenCalledTimes(1);
expect(b.preview).toHaveBeenCalledTimes(1);
});
});
describe('dragging a node in the graph', () => {
beforeEach(() => resetRenderLoop());
function rig() {
const state = fakeState();
const asset = {
id: 'b',
graph: { nodes: [{ id: 'n1', type: 'Add', x: 0, y: 0, params: {} }], edges: [] },
} as unknown as GraphAsset;
const host = {
state: state as unknown as EditorState,
assetId: () => 'b',
asset: () => asset,
nodeBoxes: () => [{ id: 'n1', x: 0, y: 0, w: 100, h: 40 }],
toGraph: (p: { x: number; y: number }) => ({ x: p.x, y: p.y }),
zoom: () => 1,
snapping: () => false,
moved: vi.fn(),
} as unknown as GraphCanvasHost;
return { state, host, node: asset.graph.nodes[0]!, drags: new GraphDrags(host) };
}
it('moves the node with every pointer move, and the canvas follows once a frame', () => {
const tick = frames();
const { host, node, drags } = rig();
drags.grabNodes({ x: 10, y: 10 });
for (let i = 1; i <= 5; i++) {
drags.move({ x: 10 + i * 10, y: 10 });
expect(node.x).toBe(i * 10);
}
expect(host.moved).not.toHaveBeenCalled();
tick();
expect(host.moved).toHaveBeenCalledTimes(1);
});
it('does not announce a changed project while the drag runs (#292)', () => {
const tick = frames();
const { state, drags } = rig();
drags.grabNodes({ x: 10, y: 10 });
drags.move({ x: 50, y: 30 });
tick();
expect(state.preview).not.toHaveBeenCalled();
});
it('leaves nothing for the frame after the release: the command has the last word', () => {
const tick = frames();
const { state, host, node, drags } = rig();
drags.grabNodes({ x: 10, y: 10 });
drags.move({ x: 50, y: 30 });
drags.releaseNodes();
expect(state.execute).toHaveBeenCalledTimes(1);
expect(node.x).toBe(0);
tick();
expect(host.moved).not.toHaveBeenCalled();
});
});
describe('editing an inspector row (#274)', () => {
beforeEach(() => resetRenderLoop());
function rig() {
const state = fakeState();
const target = { x: 1 };
const commit = vi.fn((value: number) => ({ value }) as unknown as Command);
const handlers = previewingHandlers<typeof target, number>(
{ state: state as unknown as EditorState },
{ target: () => target, read: (t) => t.x, write: (t, value) => (t.x = value), commit },
);
return { state, target, commit, handlers };
}
it('writes every input into the model and previews once a frame', () => {
const tick = frames();
const { state, target, handlers } = rig();
for (let i = 2; i <= 6; i++) {
handlers.onInput(i);
expect(target.x).toBe(i);
}
expect(state.preview).not.toHaveBeenCalled();
tick();
expect(state.preview).toHaveBeenCalledTimes(1);
});
it('commits from the value before the scrub and leaves nothing for the next frame', () => {
const tick = frames();
const { state, target, commit, handlers } = rig();
handlers.onInput(5);
handlers.onChange(7);
expect(target.x).toBe(1);
expect(commit).toHaveBeenCalledWith(7);
expect(state.execute).toHaveBeenCalledTimes(1);
tick();
expect(state.preview).not.toHaveBeenCalled();
});
});
describe('dragging a colour stop of a gradient (#274)', () => {
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
PaintCache.prototype.gradient = (() => null) as never;
});
beforeEach(() => resetRenderLoop());
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
asset.layers[0]!.items.push({
id: 's',
name: 'Sky',
fill: 0x0000ff,
opacity: 1,
rings: [],
paint: {
type: 'linear',
from: { x: 0, y: 0 },
to: { x: 100, y: 0 },
stops: [
{ offset: 0, color: 0x0000ff, alpha: 1 },
{ offset: 1, color: 0xffffff, alpha: 1 },
],
},
});
state.project.assets.push(asset);
const host: InspectorHost = {
state,
body: new VStack(),
fields: new Map(),
nameField: null,
project: () => state.project,
liveVariables: () => null,
};
buildShapeSection(host, asset.id, 's', ['s']);
const bar = currentGradientBar('fill')!;
// 100 px between the outermost markers, which stand 6 px in from either end.
bar.resize(112, 32);
const pointer = (type: string, x: number) =>
bar.emit(type, { button: 0, global: { x, y: 25 }, stopPropagation: () => undefined });
const offsets = () => (allShapes(asset)[0]!.paint as VectorGradient).stops.map((stop) => stop.offset);
return { state, preview: vi.spyOn(state, 'preview'), pointer, offsets };
}
it('moves the stop with every pointer move and previews once a frame', () => {
const tick = frames();
const { preview, pointer, offsets } = rig();
pointer('pointerdown', 6);
for (let i = 1; i <= 5; i++) {
pointer('globalpointermove', 6 + i * 10);
expect(offsets()[0]).toBeCloseTo(i / 10);
}
expect(preview).not.toHaveBeenCalled();
tick();
expect(preview).toHaveBeenCalledTimes(1);
});
it('leaves nothing for the frame after the release, and the drag is one undo step', () => {
const tick = frames();
const { state, preview, pointer, offsets } = rig();
pointer('pointerdown', 6);
pointer('globalpointermove', 56);
pointer('pointerup', 56);
expect(offsets()[0]).toBeCloseTo(0.5);
tick();
expect(preview).not.toHaveBeenCalled();
state.undo();
expect(offsets()[0]).toBe(0);
});
});
describe('the side panel of the pixel art tab (#274)', () => {
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
// Canvas and thumbnails paint onto browser canvases, which Node has none of.
PixelArtCanvas.prototype.redraw = () => undefined;
PixelThumbs.prototype.paint = () => undefined;
});
beforeEach(() => resetRenderLoop());
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const palette = buildAsset('palette', 'Palette') as PaletteAsset;
const art = buildAsset('pixelart', 'Art') as PixelArtAsset;
art.props[PALETTE_KEY] = palette.id;
state.project.assets.push(palette, art);
const canvas = new PixelArtCanvas(state);
const side = new PixelArtSide(state, canvas);
canvas.show(art.id);
canvas.setColor(1);
const layer = art.layers[0]!;
// The row's number field calls these two; the list keeps them to itself.
const opacity = side as unknown as {
previewOpacity(layerId: string, opacity: number): void;
commitOpacity(layerId: string, opacity: number): void;
};
return { state, palette, art, layer, side, opacity, preview: vi.spyOn(state, 'preview') };
}
it('writes every colour under the picker into the palette and previews once a frame', () => {
const tick = frames();
const { state, palette, side, preview } = rig();
const first = palette.colors[0]!;
for (const color of [0x111111, 0x222222, 0x333333]) {
side.colorField.onInput?.(color);
expect(palette.colors[0]).toBe(color);
}
expect(preview).not.toHaveBeenCalled();
tick();
expect(preview).toHaveBeenCalledTimes(1);
side.colorField.onInput?.(0x444444);
side.colorField.onChange?.(0x555555);
tick();
expect(preview).toHaveBeenCalledTimes(1);
expect(palette.colors[0]).toBe(0x555555);
state.undo();
expect(palette.colors[0]).toBe(first);
});
it('says so when the picker ends on the colour it started from', () => {
const tick = frames();
const { palette, side, preview } = rig();
const first = palette.colors[0]!;
side.colorField.onInput?.(0x111111);
tick();
side.colorField.onChange?.(first);
expect(palette.colors[0]).toBe(first);
expect(preview).toHaveBeenCalledTimes(2);
});
it('writes every step of an opacity scrub into the layer and previews once a frame', () => {
const tick = frames();
const { state, art, layer, opacity, preview } = rig();
for (const value of [0.9, 0.8, 0.7]) {
opacity.previewOpacity(layer.id, value);
expect(layer.opacity).toBe(value);
}
expect(preview).not.toHaveBeenCalled();
tick();
expect(preview).toHaveBeenCalledTimes(1);
opacity.previewOpacity(layer.id, 0.6);
opacity.commitOpacity(layer.id, 0.5);
tick();
expect(preview).toHaveBeenCalledTimes(1);
expect(art.layers[0]!.opacity).toBe(0.5);
state.undo();
expect(art.layers[0]!.opacity).toBe(1);
});
});
describe('painting a placed tilemap (#347)', () => {
beforeEach(() => resetRenderLoop());
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const scene = buildAsset('scene', 'Scene') as SceneAsset;
const map = buildAsset('tilemap', 'Ground') as TilemapAsset;
const tile = buildAsset('tile', 'Grass');
state.project.assets.push(scene, map, tile);
state.project.activeSceneId = scene.id;
const placed = new PlaceAssetCommand(scene.id, map.id, { x: 0, y: 0 });
state.execute(placed);
state.select(objectSelection(scene.id, [placed.objectId]));
pickTile(state, tile.id);
const item = { id: placed.objectId, type: 'tilemap', m: IDENTITY, props: map.props } as DrawItem;
const redraw = vi.fn();
const host: SceneViewHost = {
state,
holder: () => scene,
items: () => [item],
worldAt: (global) => global,
local: (global) => global,
view: () => ({ x: 0, y: 0, scale: 1, rect: { x: 0, y: 0, w: 0, h: 0 } }),
scale: () => 1,
redraw,
};
const painter = new TilemapPainter(host);
const size = tilemapTileSize(map.props);
const centre = (x: number) => {
const at = cellOrigin(map.props, x, 0);
return { x: at.x + size / 2, y: at.y + size / 2 };
};
const cells = () => Object.keys(map.layers[0]!.cells).sort();
const press = () => painter.press(painter.targetOf(placed.objectId)!, centre(0));
return { state, painter, redraw, centre, cells, press, preview: vi.spyOn(state, 'preview') };
}
it('writes every cell the brush crosses at once and draws the scene once a frame', () => {
const tick = frames();
const { painter, redraw, centre, cells, press, preview } = rig();
expect(press()).toBe(true);
redraw.mockClear();
for (let x = 1; x <= 4; x++) painter.move(centre(x));
expect(cells()).toEqual(['0,0', '1,0', '2,0', '3,0', '4,0']);
expect(redraw).not.toHaveBeenCalled();
tick();
expect(preview).toHaveBeenCalledTimes(1);
expect(redraw).not.toHaveBeenCalled();
});
it('draws the box a rectangle drag would fill once a frame, and nothing after the release', () => {
const tick = frames();
const { state, painter, redraw, centre, cells, press } = rig();
chooseTool(state, 'rect');
press();
redraw.mockClear();
for (let x = 1; x <= 4; x++) painter.move(centre(x));
expect(redraw).not.toHaveBeenCalled();
tick();
expect(redraw).toHaveBeenCalledTimes(1);
painter.move(centre(5));
painter.release();
redraw.mockClear();
tick();
expect(redraw).not.toHaveBeenCalled();
expect(cells()).toHaveLength(6);
});
});
describe('the place fields of the curve tab (#274)', () => {
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
});
beforeEach(() => resetRenderLoop());
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const curve = buildAsset('curve', 'Ease') as CurveAsset;
state.project.assets.push(curve);
const canvas = new CurveCanvas(state);
canvas.show(curve.id);
(canvas as unknown as { picked: number }).picked = 1;
return { state, curve, canvas, preview: vi.spyOn(state, 'preview') };
}
it('moves the picked point with every input and previews once a frame', () => {
const tick = frames();
const { state, curve, canvas, preview } = rig();
const before = curve.points[1]!.y;
for (const v of [0.9, 0.8, 0.7]) {
canvas.movePicked({ x: 1, y: v }, false);
expect(canvas.pickedPlace?.y).toBeCloseTo(v);
}
expect(preview).not.toHaveBeenCalled();
tick();
expect(preview).toHaveBeenCalledTimes(1);
canvas.movePicked({ x: 1, y: 0.6 }, false);
canvas.movePicked({ x: 1, y: 0.5 }, true);
tick();
expect(preview).toHaveBeenCalledTimes(1);
expect(curve.points[1]!.y).toBeCloseTo(0.5);
state.undo();
expect(curve.points[1]!.y).toBe(before);
});
});