In a graph of many nodes the frame rate dropped while panning, and more so while one node was dragged. Every pointer move rebuilt every box, every text and every curve, and a drag also announced a changed project, so the panels rebuilt along with it. - Panning and zooming move the picture. GraphCanvas.applyView sets position and scale and draws anew only the grid and the rubber band. It used to call redraw(). - A node draws its box and makes its texts only when what they show has changed. NodeView keeps a signature of what redraw reads and one of what rebuildTexts reads, and returns early while they hold. - A drag does not announce a changed project. GraphDrags writes the model on every pointer move and asks the canvas once a frame to follow (GraphCanvasHost.moved). state.preview() waits for the command of the release. - Nodes outside the picture are not drawn. inView in graphView.ts is the rule, cull() applies it after every redraw and every change of the view. - The graph is a render group, and box and pins go into the batch. The mask sits on a container above the group, on the group itself it would be panned and zoomed along. - The rubber band and the drop mark of a dragged behavior are drawn on their own (drawBand), without a redraw of the graph. Measured in the CI image with 388 nodes and 540 links, fitted to the canvas, 240 pointer moves 16 ms apart: - Pan, script per move: 25.2 ms before, 2.0 ms after - Pan: 4.4 frames per second before, 18.7 after - Drag a node: 2.7 frames per second before, 22.4 after - The still picture: 9 frames per second before, 21 after Not solved: the still picture of such a graph stays at 21 frames per second in the test browser. What is left is the drawing itself, and the frame rates are those of that browser, not of a desktop GPU. Tests: - Gate on melektaus-atx: 207 files, 3121 tests (3114 + 7), exit 0 - e2e 4,6,18,19,20,23,24,26,28,31,36,43,51,53 on melektaus-atx: 542 pass, 1 fail. The fail is module 51 after module 43, an order the suite does not have, and it fails the same way without this change. Module 51 alone: 12 checks, all pass. - No full run and no smokes. The sum of 1660 (1656 + 4) is reckoned, and the CI workflow is switched off. - Seven unit tests: six for inView in tests/editor/graph-view.test.ts and one in frame-preview.test.ts - Four e2e checks in block 6z-xii of module 06: pan, drag, zoom in and fit, a changed value - Seven browser counter-probes fail 2 to 3 checks each Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
381 lines
13 KiB
TypeScript
381 lines
13 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,
|
|
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 } from '../../src/editor/state';
|
|
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('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);
|
|
});
|
|
});
|