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>
472 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|