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>
156 lines
5.5 KiB
TypeScript
156 lines
5.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { layoutSymbols, ringRadius, SymbolLayoutMemo } from '../../src/viewport/symbolLayout';
|
||
import { haloSvg } from '../../src/ui/svg';
|
||
|
||
const SPACING = 28;
|
||
|
||
function distance(a: { x: number; y: number }, b: { x: number; y: number }): number {
|
||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||
}
|
||
|
||
describe('layoutSymbols', () => {
|
||
it('leaves a symbol with room around it on its own position', () => {
|
||
const spots = layoutSymbols(
|
||
[
|
||
{ id: 'a', x: 0, y: 0 },
|
||
{ id: 'b', x: 100, y: 0 },
|
||
],
|
||
SPACING,
|
||
);
|
||
expect(spots.get('a')).toEqual({ x: 0, y: 0, angle: null });
|
||
expect(spots.get('b')).toEqual({ x: 100, y: 0, angle: null });
|
||
});
|
||
|
||
it('puts two symbols on one spot left and right of it, the middle free', () => {
|
||
const spots = layoutSymbols(
|
||
[
|
||
{ id: 'body', x: 50, y: 20 },
|
||
{ id: 'collider', x: 50, y: 20 },
|
||
],
|
||
SPACING,
|
||
);
|
||
const body = spots.get('body')!;
|
||
const collider = spots.get('collider')!;
|
||
expect(body.x).toBeCloseTo(36);
|
||
expect(body.y).toBeCloseTo(20);
|
||
expect(collider.x).toBeCloseTo(64);
|
||
expect(collider.y).toBeCloseTo(20);
|
||
});
|
||
|
||
it('spaces any number evenly on a ring round the middle, neighbours a symbol apart', () => {
|
||
for (const count of [3, 4, 5, 8]) {
|
||
const anchors = Array.from({ length: count }, (_, i) => ({ id: `s${i}`, x: 10, y: -5 }));
|
||
const spots = [...layoutSymbols(anchors, SPACING).values()];
|
||
const r = ringRadius(count, SPACING);
|
||
for (const spot of spots) expect(distance(spot, { x: 10, y: -5 })).toBeCloseTo(r);
|
||
for (let i = 0; i < count; i++)
|
||
expect(distance(spots[i]!, spots[(i + 1) % count]!)).toBeCloseTo(SPACING);
|
||
const mean = {
|
||
x: spots.reduce((s, p) => s + p.x, 0) / count,
|
||
y: spots.reduce((s, p) => s + p.y, 0) / count,
|
||
};
|
||
expect(distance(mean, { x: 10, y: -5 })).toBeCloseTo(0);
|
||
}
|
||
});
|
||
|
||
it('shares a spot whoever owns the symbols, and rings round the middle of close positions', () => {
|
||
const spots = layoutSymbols(
|
||
[
|
||
{ id: 'a', x: 0, y: 0 },
|
||
{ id: 'b', x: 10, y: 0 },
|
||
],
|
||
SPACING,
|
||
);
|
||
expect(spots.get('a')!.x).toBeCloseTo(-9);
|
||
expect(spots.get('b')!.x).toBeCloseTo(19);
|
||
});
|
||
|
||
it('takes in a symbol the ring would cover, until nothing overlaps', () => {
|
||
// Two on the origin reach out to ±28 – the third at 40 would sit on the right one.
|
||
const anchors = [
|
||
{ id: 'a', x: 0, y: 0 },
|
||
{ id: 'b', x: 0, y: 0 },
|
||
{ id: 'c', x: 40, y: 0 },
|
||
];
|
||
const spots = [...layoutSymbols(anchors, SPACING).values()];
|
||
for (let i = 0; i < spots.length; i++)
|
||
for (let j = i + 1; j < spots.length; j++)
|
||
expect(distance(spots[i]!, spots[j]!)).toBeGreaterThanOrEqual(SPACING - 1e-9);
|
||
expect(spots.every((s) => s.angle !== null)).toBe(true);
|
||
});
|
||
|
||
it('places the same input the same way every time', () => {
|
||
const anchors = [
|
||
{ id: 'a', x: 0, y: 0 },
|
||
{ id: 'b', x: 3, y: 1 },
|
||
{ id: 'c', x: -2, y: 4 },
|
||
];
|
||
expect([...layoutSymbols(anchors, SPACING)]).toEqual([...layoutSymbols(anchors, SPACING)]);
|
||
});
|
||
});
|
||
|
||
describe('SymbolLayoutMemo (#347)', () => {
|
||
it('answers with the same spots while the anchors stay put, and works them out again once one moves', () => {
|
||
const memo = new SymbolLayoutMemo();
|
||
const anchors = () => [
|
||
{ id: 'a', x: 0, y: 0 },
|
||
{ id: 'b', x: 0, y: 0 },
|
||
];
|
||
const first = memo.layout(anchors(), SPACING);
|
||
expect(memo.layout(anchors(), SPACING)).toBe(first);
|
||
const moved = memo.layout(
|
||
[
|
||
{ id: 'a', x: 0, y: 0 },
|
||
{ id: 'b', x: 100, y: 0 },
|
||
],
|
||
SPACING,
|
||
);
|
||
expect(moved).not.toBe(first);
|
||
expect(moved.get('b')).toEqual({ x: 100, y: 0, angle: null });
|
||
const wider = memo.layout(
|
||
[
|
||
{ id: 'a', x: 0, y: 0 },
|
||
{ id: 'b', x: 100, y: 0 },
|
||
],
|
||
2 * SPACING,
|
||
);
|
||
expect(wider).not.toBe(moved);
|
||
expect([...memo.layout(anchors(), SPACING)]).toEqual([...layoutSymbols(anchors(), SPACING)]);
|
||
});
|
||
});
|
||
|
||
describe('haloSvg', () => {
|
||
it('widens every shape by its outline in currentColor, keeping hollow shapes hollow', () => {
|
||
const svg = [
|
||
'<svg viewBox="-8 -8 16 16">',
|
||
' <rect x="-5" y="-5" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.5" />',
|
||
' <circle cx="0" cy="0" r="2" fill="currentColor" />',
|
||
'</svg>',
|
||
].join('\n');
|
||
const halo = haloSvg(svg, 1.25);
|
||
expect(halo).toContain(
|
||
'<rect x="-5" y="-5" width="10" height="10" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="square" />',
|
||
);
|
||
expect(halo).toContain(
|
||
'<circle cx="0" cy="0" r="2" fill="currentColor" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" />',
|
||
);
|
||
expect(halo.startsWith('<svg viewBox="-8 -8 16 16">')).toBe(true);
|
||
});
|
||
|
||
it('outlines the open ends of a stroke too, keeping a round cap round', () => {
|
||
const svg = [
|
||
'<svg viewBox="-8 -8 16 16">',
|
||
' <path d="M0 -3 V3" fill="none" stroke="currentColor" stroke-width="1.5" />',
|
||
' <path d="M-3 0 H3" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" />',
|
||
'</svg>',
|
||
].join('\n');
|
||
const halo = haloSvg(svg, 1);
|
||
expect(halo).toContain(
|
||
'<path d="M0 -3 V3" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="square" />',
|
||
);
|
||
expect(halo).toContain(
|
||
'<path d="M-3 0 H3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" />',
|
||
);
|
||
});
|
||
});
|