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

+2
View File
@@ -63,6 +63,8 @@ export function sceneHooks({ state, editor }: HookContext) {
layerOf: (id: string) => viewport.layerOf(id),
/** The icon a particle source wears in the Scene tab, with its name and its owner link. */
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(),
objectScreen: (id: string) => viewport.objectScreen(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 { dashedLine } from '../../viewport/symbols';
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 { screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
@@ -87,6 +88,11 @@ const OFF_GIZMO_ALPHA = 0.45;
const SYMBOL_SIZE = 18;
/** World units between the centres of icons that share a spot – an icon and its outline, and a hair. */
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. */
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>();
/** Names under icons, icons above everything else in the overlay – outlines and frames included. */
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();
/** Where each icon stood at the last redraw, in world units (see `layoutSymbols`). */
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 host: SceneViewHost = {
state: this.state,
@@ -146,6 +156,7 @@ export class SceneViewport extends FreeViewport {
this.content.addChild(this.gizmos);
this.links.eventMode = 'none';
this.labelLayer.eventMode = 'none';
this.labelLayer.addChild(this.plates);
this.overlay.addChild(this.labelLayer, this.iconLayer);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
@@ -677,15 +688,52 @@ export class SceneViewport extends FreeViewport {
.filter((item) => item.enabled && hasSceneIcon(item.type))
.map((item) => ({ id: item.id, x: item.x, y: item.y }));
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) {
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);
if (!label?.visible) continue;
if (!label) continue;
const left = spot.angle !== null && Math.cos(spot.angle) < -1e-6;
label.anchor.set(left ? 1 : 0, 0.5);
label.position.set(spot.x + (left ? -gap : gap), spot.y);
lefts.set(id, left);
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 {
@@ -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 {
let label = this.labels.get(item.id);
if (!show || !hasSceneIcon(item.type)) {
if (!hasSceneIcon(item.type)) {
if (label) label.visible = false;
return;
}
if (!label) {
label = new Text({ text: '', style: textStyle('viewportLabelMuted') });
label = new Text({ text: '', style: textStyle('viewportLabel') });
label.eventMode = 'none';
this.labels.set(item.id, label);
this.labelLayer.addChild(label);
}
label.visible = true;
label.text = item.name;
label.visible = show;
if (label.text !== item.name) label.text = item.name;
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
* 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;
}