Keep scene view names readable on plates and in rows of their own
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:
1 parent
af6ac12ab5
commit
50bddf68d4
6 files changed
+223
-12
No files matched your search
@@ -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:
|
||||
|
||||
```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
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -100,10 +100,52 @@ export async function drawing(t) {
|
||||
JSON.stringify(wrong.length ? wrong : looks.map((s) => `${s.id} ${s.white}/${s.outline}`)),
|
||||
);
|
||||
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(`
|
||||
const p = __gm.state.project;
|
||||
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));
|
||||
__gm.lookAt(0, 0);`);
|
||||
|
||||
|
||||
Reference in new issue
Block a user