Keep scene view names readable on plates and in rows of their own
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Failing after 8m37s

Symbol names in the scene view were plain grey text. On a background
of a similar colour they could not be read, and names of symbols close
together were drawn over each other.

- Each name stands on a rounded, nearly black plate at 70 % opacity,
  3 px padding left and right, 1 px top and bottom.
- The text is light (viewportLabel) instead of muted grey.
- The plate of a selected object gets an accent border.
- New rule stackLabels in src/viewport/labelRows.ts. A name that
  overlaps another name or an icon moves one row down, then one up,
  then two down and so on, until a row is free.
- Names are placed top to bottom, so the upper one keeps its row.
  A name only moves vertically and stays on the side of its icon.
- Names outside the view still claim their row. Otherwise the visible
  names would jump while panning.
- New hook __gm.sceneLabels() for the e2e check.

The frame names in the texture editor already had a plate, so they
are unchanged.

Not covered by tests: the accent border on selection. Readability on a
light background was only judged from a screenshot on a dark one.

Tests:
- 5 unit tests in tests/viewport/label-rows.test.ts: free names stay,
  the lower of two colliding names moves, the stacking order, names
  avoid icons, 30 crowded names never overlap.
- e2e 5c-ii: 8 symbols on one spot give 8 plates, no overlap with each
  other or with icons, each name on the side of its icon.
- Counter-checks: without the overlap test in the rule 4 of 5 unit
  tests fail. With the scene view ignoring the rows exactly 5c-ii
  fails.
- ./check.sh: 161 files, 2369 tests, CHECK EXIT 0.
- e2e 1-5: ALL PASSED, 140 checks. The e2e sum is now 1382 (computed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-25 08:05:39 +02:00
1 parent af6ac12ab5
commit 50bddf68d4
6 files changed
+223 -12

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1381 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1382 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+2
View File
@@ -63,6 +63,8 @@ export function sceneHooks({ state, editor }: HookContext) {
layerOf: (id: string) => viewport.layerOf(id), layerOf: (id: string) => viewport.layerOf(id),
/** The icon a particle source wears in the Scene tab, with its name and its owner link. */ /** The icon a particle source wears in the Scene tab, with its name and its owner link. */
sceneIcon: (id: string) => viewport.iconInfo(id), sceneIcon: (id: string) => viewport.iconInfo(id),
/** The names shown beside the symbols, each with the plate under it, in world units and on screen. */
sceneLabels: () => viewport.labelInfo(),
drawOrder: () => viewport.drawOrder(), drawOrder: () => viewport.drawOrder(),
objectScreen: (id: string) => viewport.objectScreen(id), objectScreen: (id: string) => viewport.objectScreen(id),
objectRect: (id: string) => viewport.objectRect(id), objectRect: (id: string) => viewport.objectRect(id),
+70 -10
View File
@@ -34,6 +34,7 @@ import { openObjectMenu } from '../objectMenu';
import { type LayerFocus, layerAlpha } from '../../viewport/tilemap'; import { type LayerFocus, layerAlpha } from '../../viewport/tilemap';
import { dashedLine } from '../../viewport/symbols'; import { dashedLine } from '../../viewport/symbols';
import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons'; import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons';
import { type LabelBox, stackLabels, type WantedLabel } from '../../viewport/labelRows';
import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout'; import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout';
import { screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World'; import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
@@ -87,6 +88,11 @@ const OFF_GIZMO_ALPHA = 0.45;
const SYMBOL_SIZE = 18; const SYMBOL_SIZE = 18;
/** World units between the centres of icons that share a spot – an icon and its outline, and a hair. */ /** World units between the centres of icons that share a spot – an icon and its outline, and a hair. */
const SYMBOL_SPACING = 21; const SYMBOL_SPACING = 21;
/** Screen pixels round a symbol's name inside its plate, and between two rows of names. */
const LABEL_PAD_X = 3;
const LABEL_PAD_Y = 1;
const LABEL_ROW_GAP = 1;
const LABEL_PLATE_ALPHA = 0.7;
/** Types whose icon is all there is of them: they are selected and outlined where it stands. */ /** Types whose icon is all there is of them: they are selected and outlined where it stands. */
const ICON_ONLY = new Set<DrawItem['type']>(['behavior', 'container', 'rigidbody']); const ICON_ONLY = new Set<DrawItem['type']>(['behavior', 'container', 'rigidbody']);
@@ -105,9 +111,13 @@ export class SceneViewport extends FreeViewport {
private readonly icons = new Map<string, Graphics>(); private readonly icons = new Map<string, Graphics>();
/** Names under icons, icons above everything else in the overlay – outlines and frames included. */ /** Names under icons, icons above everything else in the overlay – outlines and frames included. */
private readonly labelLayer = new Container(); private readonly labelLayer = new Container();
/** The dark plate under every name shown, so it reads on any ground (see `placeLabels`). */
private readonly plates = new Graphics();
private readonly iconLayer = new Container(); private readonly iconLayer = new Container();
/** Where each icon stood at the last redraw, in world units (see `layoutSymbols`). */ /** Where each icon stood at the last redraw, in world units (see `layoutSymbols`). */
private spots = new Map<string, SymbolSpot>(); private spots = new Map<string, SymbolSpot>();
/** The plates of the names shown at the last redraw, in world units (test hook). */
private lastLabelBoxes: (WantedLabel & { left: boolean })[] = [];
private readonly solids = new TilemapSolidOutlines(this.overlay); private readonly solids = new TilemapSolidOutlines(this.overlay);
private readonly host: SceneViewHost = { private readonly host: SceneViewHost = {
state: this.state, state: this.state,
@@ -146,6 +156,7 @@ export class SceneViewport extends FreeViewport {
this.content.addChild(this.gizmos); this.content.addChild(this.gizmos);
this.links.eventMode = 'none'; this.links.eventMode = 'none';
this.labelLayer.eventMode = 'none'; this.labelLayer.eventMode = 'none';
this.labelLayer.addChild(this.plates);
this.overlay.addChild(this.labelLayer, this.iconLayer); this.overlay.addChild(this.labelLayer, this.iconLayer);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
@@ -677,15 +688,52 @@ export class SceneViewport extends FreeViewport {
.filter((item) => item.enabled && hasSceneIcon(item.type)) .filter((item) => item.enabled && hasSceneIcon(item.type))
.map((item) => ({ id: item.id, x: item.x, y: item.y })); .map((item) => ({ id: item.id, x: item.x, y: item.y }));
this.spots = layoutSymbols(anchors, SYMBOL_SPACING * this.symbolScale()); this.spots = layoutSymbols(anchors, SYMBOL_SPACING * this.symbolScale());
const gap = (SYMBOL_SIZE * this.symbolScale()) / 2 + 4 / scale; for (const [id, spot] of this.spots) this.icons.get(id)?.position.set(spot.x, spot.y);
this.placeLabels(scale);
}
/**
* Each name beside its icon – on the outer side of a ring – on a dark plate, and a row up or down
* where it would cover another name or icon (`stackLabels`). Names off screen take their row as
* well, so none jumps while the view pans.
*/
private placeLabels(scale: number): void {
const side = SYMBOL_SIZE * this.symbolScale();
const gap = side / 2 + 4 / scale;
const padX = LABEL_PAD_X / scale;
const padY = LABEL_PAD_Y / scale;
const wanted: WantedLabel[] = [];
const icons: LabelBox[] = [];
const lefts = new Map<string, boolean>();
for (const [id, spot] of this.spots) { for (const [id, spot] of this.spots) {
this.icons.get(id)?.position.set(spot.x, spot.y); icons.push({ x: spot.x - side / 2, y: spot.y - side / 2, w: side, h: side });
const label = this.labels.get(id); const label = this.labels.get(id);
if (!label?.visible) continue; if (!label) continue;
const left = spot.angle !== null && Math.cos(spot.angle) < -1e-6; const left = spot.angle !== null && Math.cos(spot.angle) < -1e-6;
label.anchor.set(left ? 1 : 0, 0.5); lefts.set(id, left);
label.position.set(spot.x + (left ? -gap : gap), spot.y); const w = label.width + padX * 2;
const h = label.height + padY * 2;
const x = left ? spot.x - gap - label.width - padX : spot.x + gap - padX;
wanted.push({ id, x, y: spot.y - h / 2, w, h });
} }
const step = (wanted[0]?.h ?? 0) + LABEL_ROW_GAP / scale;
const tops = stackLabels(wanted, icons, step);
this.plates.clear();
for (const box of wanted) {
const label = this.labels.get(box.id)!;
const top = tops.get(box.id) ?? box.y;
label.anchor.set(0, 0);
label.position.set(box.x + padX, top + padY);
if (!label.visible) continue;
this.plates.roundRect(box.x, top, box.w, box.h, 2 / scale).fill({
color: Theme.colors.symbolOutline,
alpha: LABEL_PLATE_ALPHA,
});
if (this.isSelected(box.id)) this.plates.stroke({ width: 1 / scale, color: Theme.colors.accent });
}
this.lastLabelBoxes = wanted
.filter((box) => this.labels.get(box.id)!.visible)
.map((box) => ({ ...box, y: tops.get(box.id) ?? box.y, left: lefts.get(box.id)! }));
} }
private syncGrid(): void { private syncGrid(): void {
@@ -726,24 +774,36 @@ export class SceneViewport extends FreeViewport {
} }
} }
/** The name of a symbol; `placeIcons` puts it beside the icon once every icon has its spot. */ /**
* The name of a symbol; `placeLabels` puts it beside the icon once every icon has its spot. One
* off screen is kept up to date but hidden: it still takes its row.
*/
private syncLabel(item: DrawItem, show: boolean, scale: number): void { private syncLabel(item: DrawItem, show: boolean, scale: number): void {
let label = this.labels.get(item.id); let label = this.labels.get(item.id);
if (!show || !hasSceneIcon(item.type)) { if (!hasSceneIcon(item.type)) {
if (label) label.visible = false; if (label) label.visible = false;
return; return;
} }
if (!label) { if (!label) {
label = new Text({ text: '', style: textStyle('viewportLabelMuted') }); label = new Text({ text: '', style: textStyle('viewportLabel') });
label.eventMode = 'none'; label.eventMode = 'none';
this.labels.set(item.id, label); this.labels.set(item.id, label);
this.labelLayer.addChild(label); this.labelLayer.addChild(label);
} }
label.visible = true; label.visible = show;
label.text = item.name; if (label.text !== item.name) label.text = item.name;
label.scale.set(1 / scale); label.scale.set(1 / scale);
} }
/** The names shown and their plates in world units, `left` for one on the left of its icon (test hook). */
labelInfo(): (WantedLabel & { left: boolean; screen: { x: number; y: number; w: number; h: number } })[] {
const view = this.currentView();
return this.lastLabelBoxes.map((box) => {
const a = worldToScreen(view, box.x, box.y);
return { ...box, screen: { x: a.x, y: a.y, w: box.w * view.scale, h: box.h * view.scale } };
});
}
/** /**
* Outline at the size the object will have – the footprint answers "does it fit here", which a * Outline at the size the object will have – the footprint answers "does it fit here", which a
* cursor alone does not. Red means the drop would be refused; the crosshair is where it snaps. * cursor alone does not. Red means the drop would be refused; the crosshair is where it snaps.
+50
View File
@@ -0,0 +1,50 @@
/**
* Where the scene view's symbol names stand. Each wants the row beside its icon; a name that would
* cover one already placed, or another icon, moves a row down, up, two down and so on, until it
* finds a free row. Names are placed top to bottom, so of two that collide the upper one keeps its
* row, and a name only ever moves up or down: it stays on its icon's side.
*/
export interface LabelBox {
x: number;
y: number;
w: number;
h: number;
}
export interface WantedLabel extends LabelBox {
id: string;
}
/** Tries per name before it gives up and keeps its own row – far more rows than a scene fills. */
const MAX_TRIES = 4096;
function overlaps(a: LabelBox, b: LabelBox): boolean {
// A hair of slack: boxes that just touch are fine, and rounding must not push one away.
const e = 1e-6;
return a.x < b.x + b.w - e && b.x < a.x + a.w - e && a.y < b.y + b.h - e && b.y < a.y + a.h - e;
}
/** The top of every label, `step` apart from its wanted row: 0, +1, −1, +2, −2 … rows. */
export function stackLabels(
labels: readonly WantedLabel[],
obstacles: readonly LabelBox[],
step: number,
): Map<string, number> {
const order = labels.map((label, k) => ({ label, k }));
order.sort((a, b) => a.label.y - b.label.y || a.label.x - b.label.x || a.k - b.k);
const placed: LabelBox[] = [...obstacles];
const tops = new Map<string, number>();
for (const { label } of order) {
let top = label.y;
for (let k = 0; k < MAX_TRIES; k++) {
const rows = k % 2 === 0 ? -k / 2 : (k + 1) / 2;
const box = { ...label, y: label.y + rows * step };
if (placed.some((other) => overlaps(box, other))) continue;
top = box.y;
break;
}
tops.set(label.id, top);
placed.push({ x: label.x, y: top, w: label.w, h: label.h });
}
return tops;
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { type LabelBox, stackLabels, type WantedLabel } from '../../src/viewport/labelRows';
const H = 12;
const STEP = 13;
function label(id: string, x: number, y: number, w = 40): WantedLabel {
return { id, x, y, w, h: H };
}
function overlap(a: LabelBox, b: LabelBox): boolean {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
describe('stackLabels', () => {
it('leaves names that do not touch on their own row', () => {
const tops = stackLabels([label('a', 0, 0), label('b', 100, 0), label('c', 0, 20)], [], STEP);
expect([...tops]).toEqual([
['a', 0],
['b', 0],
['c', 20],
]);
});
it('moves the lower of two colliding names a row down', () => {
const tops = stackLabels([label('low', 10, 4), label('high', 0, 0)], [], STEP);
expect(tops.get('high')).toBe(0);
expect(tops.get('low')).toBe(4 + STEP);
});
it('stacks names on one spot row by row: down, up, two down, two up', () => {
const names = ['a', 'b', 'c', 'd', 'e'].map((id) => label(id, 0, 0));
const tops = stackLabels(names, [], STEP);
expect(names.map((n) => tops.get(n.id))).toEqual([0, STEP, -STEP, 2 * STEP, -2 * STEP]);
});
it('keeps names off icons they would cover', () => {
// Covers the row above, the name's own and the next one down: two rows down is the first free.
const icon = { x: 20, y: -6, w: 18, h: 30 };
const tops = stackLabels([label('a', 0, 0)], [icon], STEP);
const box = { ...label('a', 0, 0), y: tops.get('a')! };
expect(overlap(box, icon)).toBe(false);
expect(tops.get('a')).toBe(2 * STEP);
});
it('leaves no two names overlapping in a crowd', () => {
const names = Array.from({ length: 30 }, (_, k) =>
label(`n${k}`, (k * 7) % 50, (k * 5) % 30, 30 + (k % 4) * 10),
);
const tops = stackLabels(names, [], STEP);
const boxes = names.map((n) => ({ ...n, y: tops.get(n.id)! }));
for (let i = 0; i < boxes.length; i++)
for (let j = i + 1; j < boxes.length; j++) expect(overlap(boxes[i]!, boxes[j]!)).toBe(false);
// Only up or down: every name stays where its icon wants it across.
for (const box of boxes) expect(box.x).toBe(names.find((n) => n.id === box.id)!.x);
});
});
+43 -1
View File
@@ -100,10 +100,52 @@ export async function drawing(t) {
JSON.stringify(wrong.length ? wrong : looks.map((s) => `${s.id} ${s.white}/${s.outline}`)), JSON.stringify(wrong.length ? wrong : looks.map((s) => `${s.id} ${s.white}/${s.outline}`)),
); );
await shot('15-symbols'); await shot('15-symbols');
// 5c-ii. Eight symbols on one spot stand on a ring, their names beside them on dark plates. Names
// that would cover each other go a row up or down instead – none overlaps another or an
// icon – and every name stays on its icon's side of the ring.
const crowd = Array.from({ length: 8 }, (_, k) => `s-crowd-${k}`);
await mutate(`
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
const assets = ${JSON.stringify(Object.values(PLACED))};
${JSON.stringify(crowd)}.forEach((id, k) => {
scene.objects.push(make(id, assets[k % assets.length], '', ${LANES.symbols}, 'crowded name ' + k));
});
__gm.lookAt(0, ${LANES.symbols});`);
const plates = await json(`(() => {
const crowd = ${JSON.stringify(crowd)};
const icons = crowd.map((id) => __gm.sceneIcon(id));
const labels = __gm.sceneLabels().filter((l) => crowd.includes(l.id));
return { icons: icons.map((i) => ({ x: i.x, y: i.y, size: i.size })), labels };
})()`);
const cross = (a, b) =>
a.x < b.x + b.w - 1e-6 && b.x < a.x + a.w - 1e-6 && a.y < b.y + b.h - 1e-6 && b.y < a.y + a.h - 1e-6;
const iconBoxes = plates.icons.map((i) => ({
x: i.x - i.size / 2,
y: i.y - i.size / 2,
w: i.size,
h: i.size,
}));
const clashes = [];
plates.labels.forEach((a, i) => {
plates.labels.slice(i + 1).forEach((b) => cross(a, b) && clashes.push(`${a.id}/${b.id}`));
iconBoxes.forEach((box, k) => cross(a, box) && clashes.push(`${a.id}/icon ${k}`));
});
const sides = plates.labels.filter((l) => {
const icon = plates.icons[crowd.indexOf(l.id)];
return l.left ? l.x + l.w > icon.x : l.x < icon.x;
});
check(
'names crowded on one spot stand on plates in rows of their own, on their icons’ side',
plates.labels.length === 8 && clashes.length === 0 && sides.length === 0,
`${plates.labels.length} plates, clashes ${JSON.stringify(clashes)}, wrong side ${JSON.stringify(sides.map((l) => l.id))}`,
);
await shot('15a-crowded-names');
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !${JSON.stringify(ids)}.includes(o.id)); scene.objects = scene.objects.filter((o) => ![...${JSON.stringify(ids)}, ...${JSON.stringify(crowd)}].includes(o.id));
p.assets = p.assets.filter((a) => !['s-box-a', 's-rb-a', 's-pt-a'].includes(a.id)); p.assets = p.assets.filter((a) => !['s-box-a', 's-rb-a', 's-pt-a'].includes(a.id));
__gm.lookAt(0, 0);`); __gm.lookAt(0, 0);`);