Files
feuerwerk/src/viewport/symbolLayout.ts
T
adminandClaude Opus 5.5 4c7861b5b8 Keep the frame rate up while painting a big tilemap (#347)
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>
2026-10-07 16:54:59 +02:00

107 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Where the scene view's symbols stand. Every symbol wants the position of its object; symbols that
* would overlap there – whoever owns them – share the spot instead: they stand on a ring round the
* middle of their positions, evenly spaced, the ring just wide enough that neighbours keep `spacing`
* between their centres. A ring that reaches into another symbol or ring takes that one in as well,
* until nothing overlaps.
*/
import type { Vec2 } from '../core/model/Properties';
export interface SymbolAnchor {
id: string;
x: number;
y: number;
}
export interface SymbolSpot {
x: number;
y: number;
/** Direction from the middle of the ring in radians, null for a symbol standing alone. */
angle: number | null;
}
interface Group {
members: SymbolAnchor[];
centre: Vec2;
}
/** Radius of the ring `count` symbols stand on so neighbours are `spacing` apart. */
export function ringRadius(count: number, spacing: number): number {
return count <= 1 ? 0 : spacing / (2 * Math.sin(Math.PI / count));
}
/** How far a group reaches from its centre: the ring plus half a symbol. */
function reach(group: Group, spacing: number): number {
return ringRadius(group.members.length, spacing) + spacing / 2;
}
function centreOf(members: readonly SymbolAnchor[]): Vec2 {
const x = members.reduce((sum, m) => sum + m.x, 0) / members.length;
const y = members.reduce((sum, m) => sum + m.y, 0) / members.length;
return { x, y };
}
/**
* The spot of every anchor, in the order given. A ring starts on the left of its middle and goes
* round clockwise on screen (y points down), so two symbols stand left and right of it and keep
* the middle itself free.
*/
export function layoutSymbols(anchors: readonly SymbolAnchor[], spacing: number): Map<string, SymbolSpot> {
const order = new Map(anchors.map((anchor, k) => [anchor, k]));
let groups: Group[] = anchors.map((a) => ({ members: [a], centre: { x: a.x, y: a.y } }));
for (let merged = true; merged;) {
merged = false;
outer: for (let i = 0; i < groups.length; i++)
for (let j = i + 1; j < groups.length; j++) {
const a = groups[i]!;
const b = groups[j]!;
// A hair of slack: two groups that just touch are fine, and rounding must not merge them.
const gap = Math.hypot(a.centre.x - b.centre.x, a.centre.y - b.centre.y);
if (gap >= reach(a, spacing) + reach(b, spacing) - 1e-6) continue;
const members = [...a.members, ...b.members];
members.sort((p, q) => order.get(p)! - order.get(q)!);
groups = [...groups.slice(0, i), { members, centre: centreOf(members) }, ...groups.slice(i + 1)];
groups.splice(j, 1);
merged = true;
break outer;
}
}
const spots = new Map<string, SymbolSpot>();
for (const { members, centre } of groups) {
if (members.length === 1) {
spots.set(members[0]!.id, { x: centre.x, y: centre.y, angle: null });
continue;
}
const r = ringRadius(members.length, spacing);
members.forEach((m, k) => {
const angle = Math.PI + (2 * Math.PI * k) / members.length;
spots.set(m.id, { x: centre.x + Math.cos(angle) * r, y: centre.y + Math.sin(angle) * r, angle });
});
}
return spots;
}
/**
* `layoutSymbols` for a view that asks on every redraw: while the anchors stay where they were, the
* last answer stands. Merging rings costs up to the cube of the symbols, and a scene with fifty of
* them spent a tenth of every frame on it (#347).
*/
export class SymbolLayoutMemo {
private anchors: readonly SymbolAnchor[] = [];
private spacing = Number.NaN;
private spots = new Map<string, SymbolSpot>();
layout(anchors: readonly SymbolAnchor[], spacing: number): ReadonlyMap<string, SymbolSpot> {
if (spacing !== this.spacing || !sameAnchors(this.anchors, anchors)) {
this.spots = layoutSymbols(anchors, spacing);
this.anchors = anchors;
this.spacing = spacing;
}
return this.spots;
}
}
function sameAnchors(a: readonly SymbolAnchor[], b: readonly SymbolAnchor[]): boolean {
return a.length === b.length && a.every((p, i) => p.id === b[i]!.id && p.x === b[i]!.x && p.y === b[i]!.y);
}