Draw scene symbols as white panel icons that make room for each other
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:
1 parent
e3d47ca9c1
commit
cb32b4bc23
22 files changed
+414
-176
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { layoutSymbols, ringRadius } from '../../src/viewport/symbolLayout';
|
||||
import { haloSvg } from '../../src/ui/svg';
|
||||
|
||||
const SPACING = 28;
|
||||
|
||||
function distance(a: { x: number; y: number }, b: { x: number; y: number }): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
describe('layoutSymbols', () => {
|
||||
it('leaves a symbol with room around it on its own position', () => {
|
||||
const spots = layoutSymbols(
|
||||
[
|
||||
{ id: 'a', x: 0, y: 0 },
|
||||
{ id: 'b', x: 100, y: 0 },
|
||||
],
|
||||
SPACING,
|
||||
);
|
||||
expect(spots.get('a')).toEqual({ x: 0, y: 0, angle: null });
|
||||
expect(spots.get('b')).toEqual({ x: 100, y: 0, angle: null });
|
||||
});
|
||||
|
||||
it('puts two symbols on one spot left and right of it, the middle free', () => {
|
||||
const spots = layoutSymbols(
|
||||
[
|
||||
{ id: 'body', x: 50, y: 20 },
|
||||
{ id: 'collider', x: 50, y: 20 },
|
||||
],
|
||||
SPACING,
|
||||
);
|
||||
const body = spots.get('body')!;
|
||||
const collider = spots.get('collider')!;
|
||||
expect(body.x).toBeCloseTo(36);
|
||||
expect(body.y).toBeCloseTo(20);
|
||||
expect(collider.x).toBeCloseTo(64);
|
||||
expect(collider.y).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it('spaces any number evenly on a ring round the middle, neighbours a symbol apart', () => {
|
||||
for (const count of [3, 4, 5, 8]) {
|
||||
const anchors = Array.from({ length: count }, (_, i) => ({ id: `s${i}`, x: 10, y: -5 }));
|
||||
const spots = [...layoutSymbols(anchors, SPACING).values()];
|
||||
const r = ringRadius(count, SPACING);
|
||||
for (const spot of spots) expect(distance(spot, { x: 10, y: -5 })).toBeCloseTo(r);
|
||||
for (let i = 0; i < count; i++)
|
||||
expect(distance(spots[i]!, spots[(i + 1) % count]!)).toBeCloseTo(SPACING);
|
||||
const mean = {
|
||||
x: spots.reduce((s, p) => s + p.x, 0) / count,
|
||||
y: spots.reduce((s, p) => s + p.y, 0) / count,
|
||||
};
|
||||
expect(distance(mean, { x: 10, y: -5 })).toBeCloseTo(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('shares a spot whoever owns the symbols, and rings round the middle of close positions', () => {
|
||||
const spots = layoutSymbols(
|
||||
[
|
||||
{ id: 'a', x: 0, y: 0 },
|
||||
{ id: 'b', x: 10, y: 0 },
|
||||
],
|
||||
SPACING,
|
||||
);
|
||||
expect(spots.get('a')!.x).toBeCloseTo(-9);
|
||||
expect(spots.get('b')!.x).toBeCloseTo(19);
|
||||
});
|
||||
|
||||
it('takes in a symbol the ring would cover, until nothing overlaps', () => {
|
||||
// Two on the origin reach out to ±28 – the third at 40 would sit on the right one.
|
||||
const anchors = [
|
||||
{ id: 'a', x: 0, y: 0 },
|
||||
{ id: 'b', x: 0, y: 0 },
|
||||
{ id: 'c', x: 40, y: 0 },
|
||||
];
|
||||
const spots = [...layoutSymbols(anchors, SPACING).values()];
|
||||
for (let i = 0; i < spots.length; i++)
|
||||
for (let j = i + 1; j < spots.length; j++)
|
||||
expect(distance(spots[i]!, spots[j]!)).toBeGreaterThanOrEqual(SPACING - 1e-9);
|
||||
expect(spots.every((s) => s.angle !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it('places the same input the same way every time', () => {
|
||||
const anchors = [
|
||||
{ id: 'a', x: 0, y: 0 },
|
||||
{ id: 'b', x: 3, y: 1 },
|
||||
{ id: 'c', x: -2, y: 4 },
|
||||
];
|
||||
expect([...layoutSymbols(anchors, SPACING)]).toEqual([...layoutSymbols(anchors, SPACING)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('haloSvg', () => {
|
||||
it('widens every shape by its outline in currentColor, keeping hollow shapes hollow', () => {
|
||||
const svg = [
|
||||
'<svg viewBox="-8 -8 16 16">',
|
||||
' <rect x="-5" y="-5" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.5" />',
|
||||
' <circle cx="0" cy="0" r="2" fill="currentColor" />',
|
||||
'</svg>',
|
||||
].join('\n');
|
||||
const halo = haloSvg(svg, 1.25);
|
||||
expect(halo).toContain(
|
||||
'<rect x="-5" y="-5" width="10" height="10" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="square" />',
|
||||
);
|
||||
expect(halo).toContain(
|
||||
'<circle cx="0" cy="0" r="2" fill="currentColor" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" />',
|
||||
);
|
||||
expect(halo.startsWith('<svg viewBox="-8 -8 16 16">')).toBe(true);
|
||||
});
|
||||
|
||||
it('outlines the open ends of a stroke too, keeping a round cap round', () => {
|
||||
const svg = [
|
||||
'<svg viewBox="-8 -8 16 16">',
|
||||
' <path d="M0 -3 V3" fill="none" stroke="currentColor" stroke-width="1.5" />',
|
||||
' <path d="M-3 0 H3" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" />',
|
||||
'</svg>',
|
||||
].join('\n');
|
||||
const halo = haloSvg(svg, 1);
|
||||
expect(halo).toContain(
|
||||
'<path d="M0 -3 V3" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="square" />',
|
||||
);
|
||||
expect(halo).toContain(
|
||||
'<path d="M-3 0 H3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" />',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user