Painting on the 60x60 map of a project with fifty objects dropped the editor to 20 to 39 fps. A profile of a brush stroke showed four causes, none of them the brush itself. - Each frame of a stroke previews the model. The asset and hierarchy panels then built every row anew, so every label became a new text and a new upload to the GPU. One run spent 46 % of the main thread in those uploads. ListView.setItems now keeps a row whose item draws the same (sameListItem) and builds only the rows that changed. - The brush drew the whole scene on every pointer event, on top of the preview. Now the preview draws what the stroke changed, once a frame. The rectangle tool asks for one drawing a frame for its box. - An animated tile made the view draw in the tick and the preview drew it again in the same frame. animate now asks just before the frame is drawn and stays out when the view was drawn since the tick began. - layoutSymbols ran on every drawing and costs up to the cube of the symbols. SymbolLayoutMemo keeps the spots while no anchor moves. - A painted cell made the map read every "x,y" key again, and once more for each of the eight neighbours of every cell. layerCells reads a layer once per content into an index by place. layerTiles and the blocking cells take their neighbours from there (neighboursIn). parseCellKey reads a key without split, with the same results. The test hook sceneRedraws counts how often the scene view was drawn. Not done: a painted cell still works out the tiles and blocking cells of the whole map again, so that part grows with the map. Tests: - Brush stroke on a copy of the project (stein2, Chrome): 20 to 39 fps before, 47.5 after, 55 to 57 without painting. Labels drawn again during the stroke 2012 before, none after. Scene drawings per frame 1.32 before, 1 after. - One painted cell, headless: 28 to 16 ms on 60x60, 109 to 49 ms on the same map doubled to 120x120. - frame-preview: 2 new, red with the old pointer redraw. - tilemap: 3 new (key reading, index, refresh), list-ping 1 new, symbol-layout 1 new. 5 single reversals turned each red. - e2e 39,40 (melektaus-atx): ALL PASSED, 107 checks (1 new, a fast stroke: 6 drawings in 6 frames, no label drawn again). With the row reuse and the pointer redraw reverted it fails: 24 drawings in 3 frames, and the panels draw their labels again. - Gate (melektaus-atx): CHECK EXIT 0, 260 files, 4038 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
460 lines
16 KiB
TypeScript
460 lines
16 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 } 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('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);
|
|
});
|
|
});
|