Draw scene symbols as white panel icons that make room for each other
game-maker / gate (push) Successful in 2m54s
game-maker / suite (push) Successful in 9m5s

The scene view drew its symbols from its own SVGs, each in a type
colour and some on a plate. They looked heavier than the panel icons
and were hard to read on busy ground. Symbols of different objects
also sat on top of each other whenever their positions met.

- Every symbol is now the panel icon of its kind, one file for both
  places. The scene-only SVGs in src/viewport/icons are gone.
- Symbols are white in a thin dark outline with no plate behind them.
  haloSvg widens each shape of the file by its own stroke and draws
  that silhouette dark under the icon, so no second file is needed.
- A butt stroke end would leave its end without outline. The halo caps
  such strokes square, and the rigidbody arrow shaft is now a filled
  rect so its top edge is outlined exactly.
- Symbols are 18 world units across, stay upright and are clickable.
  A click selects the object, a drag moves it.
- symbolLayout places every symbol on its object's position. Symbols
  that overlap stand on a ring round the middle of their positions,
  evenly spaced one icon apart. A ring that reaches into another
  symbol takes it in until nothing overlaps. Ownership plays no part.
- Names sit beside their symbol, on the outside of a ring. Selection
  frames and parent links follow the symbol.
- A collider icon no longer shows trigger or invalid state. The
  outline colour of the shape still does.
- A lone collider sits on its sprite's middle again. A press there on
  an unselected sprite drags the collider, so e2e 02 and 16 now grab
  the sprite beside its middle. Hook __gm.container.objectRect helps
  16 find that spot.

Tests:
- check.sh: 2245 tests, CHECK EXIT 0 (+8 for layout and halo)
- e2e 1-16,31,39-43: 670 PASS, 2 FAIL. Both were sampling too
  strictly for the thinner, smaller icons. 5c looked for the exact
  outline colour, which the thin outline only reaches blended at this
  zoom. 31n started the ring scan one pixel past the smaller ring.
  Both are fixed, and e2e 5,31 then passed with 48 checks.
- 5c checks white with an outline and none of the old colours, 41b
  checks the ring positions and that a click on a symbol selects its
  object, 31n checks the now white particle ring.
- Counter-checks: without the halo only 5c fails, also with the looser
  outline match. Without the layout the ring and click checks in 41b
  fail. Originals restored by cmp.
- The e2e check total stays 1351.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 20:07:47 +02:00
1 parent e3d47ca9c1
commit cb32b4bc23
22 files changed
+414 -176

No files matched your search

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-14 -14 28 28" width="28" height="28">
<path d="M0 -14 L14 0 L0 14 L-14 0 Z" fill="currentColor" />
<path d="M-6 -3 H-1 V3 H6" fill="none" stroke="#ffffff" stroke-width="2" />
<circle cx="-6" cy="-3" r="2" fill="#ffffff" />
<circle cx="6" cy="3" r="2" fill="#ffffff" />
</svg>

Before

Width:  |  Height:  |  Size: 334 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-20 -10 40 20" width="40" height="20">
<rect x="-12" y="-8" width="24" height="16" fill="currentColor" />
<path d="M12 0 L20 -6 L20 6 Z" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 220 B

-8
View File
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-13 -13 26 26" width="26" height="26">
<!-- A bounding box with its corner handles, as in the asset panel. -->
<rect x="-9" y="-9" width="18" height="18" fill="currentColor" fill-opacity="0.25" stroke="currentColor" stroke-width="2" />
<rect x="-12" y="-12" width="6" height="6" fill="currentColor" />
<rect x="6" y="-12" width="6" height="6" fill="currentColor" />
<rect x="-12" y="6" width="6" height="6" fill="currentColor" />
<rect x="6" y="6" width="6" height="6" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 560 B

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-13 -13 26 26" width="26" height="26">
<rect x="-12" y="-12" width="24" height="24" rx="3" fill="currentColor" fill-opacity="0.25" stroke="currentColor" stroke-width="2" />
<rect x="-7" y="-7" width="6" height="6" fill="currentColor" />
<rect x="1" y="-7" width="6" height="6" fill="currentColor" />
<rect x="-7" y="1" width="6" height="6" fill="currentColor" />
<rect x="1" y="1" width="6" height="6" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 491 B

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-15 -15 30 30" width="30" height="30">
<!-- A source draws itself, so the ring stays hollow and the sparks sit outside it: its effect shows through the middle. -->
<circle cx="0" cy="0" r="10" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M8.485 8.485 L11.314 11.314 M-8.485 8.485 L-11.314 11.314 M8.485 -8.485 L11.314 -11.314 M-8.485 -8.485 L-11.314 -11.314" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12.728" cy="12.728" r="2" fill="currentColor" />
<circle cx="-12.728" cy="12.728" r="2" fill="currentColor" />
<circle cx="12.728" cy="-12.728" r="2" fill="currentColor" />
<circle cx="-12.728" cy="-12.728" r="2" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 749 B

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-13 -13 26 26" width="26" height="26">
<!-- A weight: a ring with an arrow pointing the way gravity pulls. -->
<circle cx="0" cy="0" r="12" fill="currentColor" fill-opacity="0.25" stroke="currentColor" stroke-width="2" />
<path d="M0 -6 V4" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M-5 1 L0 7 L5 1 Z" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 410 B

+25 -19
View File
@@ -1,12 +1,16 @@
/** The fixed pictures of the scene view's symbols, kept as SVG files in `icons/` (#175). */
/**
* The pictures of the scene view's symbols. They are the asset panel's icons (#176), so a thing
* looks the same in the panel and in the scene: white, in a dark outline that keeps it readable on
* any ground, and without a plate behind it.
*/
import type { Graphics } from 'pixi.js';
import { drawSvg, Theme } from '../ui';
import behavior from './icons/behavior.svg?raw';
import camera from './icons/camera.svg?raw';
import collider from './icons/collider.svg?raw';
import container from './icons/container.svg?raw';
import particles from './icons/particles.svg?raw';
import rigidbody from './icons/rigidbody.svg?raw';
import { drawSvg, haloSvg, Theme } from '../ui';
import behavior from '../editor/panels/icons/behavior.svg?raw';
import camera from '../editor/panels/icons/camera.svg?raw';
import collider from '../editor/panels/icons/collider.svg?raw';
import container from '../editor/panels/icons/container.svg?raw';
import particles from '../editor/panels/icons/particles.svg?raw';
import rigidbody from '../editor/panels/icons/rigidbody.svg?raw';
export type SymbolIcon = 'behavior' | 'camera' | 'collider' | 'container' | 'particles' | 'rigidbody';
@@ -19,16 +23,18 @@ export const SYMBOL_SVG: Record<SymbolIcon, string> = {
rigidbody,
};
const SYMBOL_COLOR: Record<SymbolIcon, number> = {
behavior: Theme.colors.behavior,
camera: Theme.colors.textMuted,
collider: Theme.colors.collider,
container: Theme.colors.container,
particles: Theme.colors.particles,
rigidbody: Theme.colors.collider,
};
/** The icons are drawn on a grid of this many units, centred on their origin. */
export const SYMBOL_GRID = 16;
/** How far the outline reaches past the icon, in the icon's own units. */
export const SYMBOL_OUTLINE = 0.75;
/** Adds the icon to what `node` already draws; `color` for one that changes, like a trigger's. */
export function drawSymbolIcon(node: Graphics, icon: SymbolIcon, color = SYMBOL_COLOR[icon]): void {
drawSvg(node, SYMBOL_SVG[icon], color);
const outlines = new Map<string, string>();
/** Adds the icon, outline first, to what `node` already draws – in the icon's own units. */
export function drawSymbolIcon(node: Graphics, icon: SymbolIcon): void {
const svg = SYMBOL_SVG[icon];
let outline = outlines.get(svg);
if (!outline) outlines.set(svg, (outline = haloSvg(svg, SYMBOL_OUTLINE)));
drawSvg(node, outline, Theme.colors.symbolOutline);
drawSvg(node, svg, Theme.colors.symbol);
}
+82
View File
@@ -0,0 +1,82 @@
/**
* 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;
}
+3 -4
View File
@@ -18,7 +18,6 @@ import { Theme } from '../ui';
import type { DrawItem } from './items';
import { sliceGeometry, sliceKey } from './nineSlice';
import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders';
import { drawSymbolIcon } from './symbolIcons';
import type { TextureLibrary } from './TextureLibrary';
/** Screen-pixel half-width of the grab band along a collider's outline. */
@@ -129,16 +128,16 @@ export function drawItem(
case 'tilemap':
node.hitArea = new Rectangle(-size.w / 2, -size.h / 2, size.w, size.h);
return;
// What stands for these is their icon, which the scene view draws and places on its own (see
// `layoutSymbols`) – off the object's position when another icon wants the same spot.
case 'camera':
node
.rect(-size.w / 2, -size.h / 2, size.w, size.h)
.stroke({ width: 2 / scale, color: Theme.colors.textMuted });
drawSymbolIcon(node, 'camera');
return;
case 'behavior':
case 'container':
case 'rigidbody':
drawSymbolIcon(node, item.type);
return;
case 'text':
node.hitArea = new Rectangle(
@@ -170,7 +169,7 @@ export function drawItem(
}
/**
* The colour of a collider's outline and icon. A polygon that crosses itself collides with nothing,
* The colour of a collider's outline. A polygon that crosses itself collides with nothing,
* and says so in red – as does a frame outline without a frame or without a drawn outline (#193).
*/
export function colliderColor(shape: ColliderShape, props: PropertyBag): number {