From 50bddf68d4298681e2711efa4c8c39e7169f9c32 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 25 Sep 2026 08:05:39 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/editor/debug/sceneHooks.ts | 2 + src/editor/viewport/SceneViewport.ts | 80 ++++++++++++++++++++++++---- src/viewport/labelRows.ts | 50 +++++++++++++++++ tests/viewport/label-rows.test.ts | 57 ++++++++++++++++++++ tools/e2e/05-drawing.mjs | 44 ++++++++++++++- 6 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 src/viewport/labelRows.ts create mode 100644 tests/viewport/label-rows.test.ts diff --git a/README.md b/README.md index 5806c756..b7ce02d2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index 6f52aafe..e88e86c2 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -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), diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index ea8cff53..8f6b6ad1 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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(['behavior', 'container', 'rigidbody']); @@ -105,9 +111,13 @@ export class SceneViewport extends FreeViewport { private readonly icons = new Map(); /** 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(); + /** 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(); 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. diff --git a/src/viewport/labelRows.ts b/src/viewport/labelRows.ts new file mode 100644 index 00000000..2a31d925 --- /dev/null +++ b/src/viewport/labelRows.ts @@ -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 { + 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(); + 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; +} diff --git a/tests/viewport/label-rows.test.ts b/tests/viewport/label-rows.test.ts new file mode 100644 index 00000000..227c7e47 --- /dev/null +++ b/tests/viewport/label-rows.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/05-drawing.mjs b/tools/e2e/05-drawing.mjs index 8b0042f9..7438b895 100644 --- a/tools/e2e/05-drawing.mjs +++ b/tools/e2e/05-drawing.mjs @@ -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);`);