diff --git a/README.md b/README.md
index e08d9451..17738346 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,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, 1092 checks (GM_STEPS=20 for one step)
+node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1093 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts
index f77d0b1f..90b30d0c 100644
--- a/src/editor/viewport/SceneViewport.ts
+++ b/src/editor/viewport/SceneViewport.ts
@@ -61,7 +61,8 @@ import {
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import type { LayerFocus } from '../../viewport/tilemap';
-import { CONE_LENGTH, dashedLine, drawSourceIcon, markBlockingSide, placeNode } from '../../viewport/symbols';
+import { CONE_LENGTH, dashedLine, markBlockingSide, placeNode } from '../../viewport/symbols';
+import { drawSymbolIcon } from '../../viewport/symbolIcons';
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
import { FreeViewport } from './FreeViewport';
@@ -922,7 +923,7 @@ export class SceneViewport extends FreeViewport {
if (!icon) {
icon = new Graphics();
icon.eventMode = 'none';
- drawSourceIcon(icon);
+ drawSymbolIcon(icon, 'particles');
this.icons.set(item.id, icon);
this.overlay.addChild(icon);
}
diff --git a/src/viewport/icons/behavior.svg b/src/viewport/icons/behavior.svg
new file mode 100644
index 00000000..7af4eeb0
--- /dev/null
+++ b/src/viewport/icons/behavior.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/viewport/icons/camera.svg b/src/viewport/icons/camera.svg
new file mode 100644
index 00000000..df84f935
--- /dev/null
+++ b/src/viewport/icons/camera.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/viewport/icons/container.svg b/src/viewport/icons/container.svg
new file mode 100644
index 00000000..f5bf8245
--- /dev/null
+++ b/src/viewport/icons/container.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/viewport/icons/particles.svg b/src/viewport/icons/particles.svg
new file mode 100644
index 00000000..4c38b121
--- /dev/null
+++ b/src/viewport/icons/particles.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/viewport/icons/rigidbody.svg b/src/viewport/icons/rigidbody.svg
new file mode 100644
index 00000000..a314b5eb
--- /dev/null
+++ b/src/viewport/icons/rigidbody.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/viewport/symbolIcons.ts b/src/viewport/symbolIcons.ts
new file mode 100644
index 00000000..4f56358b
--- /dev/null
+++ b/src/viewport/symbolIcons.ts
@@ -0,0 +1,42 @@
+/**
+ * The fixed pictures of the scene view's symbols, kept as SVG files in `icons/` (#175). A file
+ * draws in the symbol's own units around its origin, and `currentColor` stands for the colour the
+ * theme gives the symbol – so the file still shows in any SVG viewer, and a theme change reaches it.
+ */
+import { type Graphics, SVGParser } from 'pixi.js';
+import { Theme } from '../ui';
+import behavior from './icons/behavior.svg?raw';
+import camera from './icons/camera.svg?raw';
+import container from './icons/container.svg?raw';
+import particles from './icons/particles.svg?raw';
+import rigidbody from './icons/rigidbody.svg?raw';
+
+export type SymbolIcon = 'behavior' | 'camera' | 'container' | 'particles' | 'rigidbody';
+
+export const SYMBOL_SVG: Record = { behavior, camera, container, particles, rigidbody };
+
+const SYMBOL_COLOR: Record = {
+ behavior: Theme.colors.behavior,
+ camera: Theme.colors.textMuted,
+ container: Theme.colors.container,
+ particles: Theme.colors.particles,
+ rigidbody: Theme.colors.collider,
+};
+
+export function tintSvg(svg: string, color: number): string {
+ return svg.replaceAll('currentColor', `#${color.toString(16).padStart(6, '0')}`);
+}
+
+/** Parsed once: a symbol is redrawn with every frame, and the text parse is what costs. */
+const parsed = new Map();
+
+/** Adds the icon to what `node` already draws. */
+export function drawSymbolIcon(node: Graphics, icon: SymbolIcon): void {
+ let svg = parsed.get(icon);
+ if (!svg) {
+ const text = tintSvg(SYMBOL_SVG[icon], SYMBOL_COLOR[icon]);
+ svg = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement as unknown as SVGElement;
+ parsed.set(icon, svg);
+ }
+ SVGParser(svg, node.context);
+}
diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts
index b74f3c6b..53f7d977 100644
--- a/src/viewport/symbols.ts
+++ b/src/viewport/symbols.ts
@@ -1,14 +1,7 @@
/** Everything here draws in the item's own units at its origin; `placeNode` puts it into the world. */
import { type Container, type Graphics, Mesh, MeshGeometry, Rectangle, Texture } from 'pixi.js';
import { determinant, IDENTITY, type Mat } from '../core/model/matrix';
-import {
- BEHAVIOR_SYMBOL,
- CONTAINER_SYMBOL,
- PARTICLES_SYMBOL,
- placementOf,
- RIGIDBODY_SYMBOL,
- textAnchor,
-} from '../core/properties/bounds';
+import { placementOf, textAnchor } from '../core/properties/bounds';
import {
type ColliderShape,
colliderShape,
@@ -23,6 +16,7 @@ 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. */
@@ -137,46 +131,13 @@ export function drawItem(
node
.rect(-size.w / 2, -size.h / 2, size.w, size.h)
.stroke({ width: 2 / scale, color: Theme.colors.textMuted });
- node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted);
- node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
+ drawSymbolIcon(node, 'camera');
return;
- case 'behavior': {
- const r = BEHAVIOR_SYMBOL / 2;
- node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior);
- node
- .moveTo(-6, -3)
- .lineTo(-1, -3)
- .lineTo(-1, 3)
- .lineTo(6, 3)
- .stroke({ width: 2, color: Theme.colors.accentText });
- node.circle(-6, -3, 2).fill(Theme.colors.accentText);
- node.circle(6, 3, 2).fill(Theme.colors.accentText);
+ case 'behavior':
+ case 'container':
+ case 'rigidbody':
+ drawSymbolIcon(node, item.type);
return;
- }
- case 'container': {
- const r = CONTAINER_SYMBOL / 2;
- node
- .roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3)
- .fill({ color: Theme.colors.container, alpha: 0.25 })
- .stroke({ width: 2, color: Theme.colors.container });
- for (const [bx, by] of [
- [-7, -7],
- [1, -7],
- [-7, 1],
- [1, 1],
- ] as const)
- node.rect(bx, by, 6, 6).fill(Theme.colors.container);
- return;
- }
- // A weight: a ring with an arrow pointing the way gravity pulls.
- case 'rigidbody': {
- const r = RIGIDBODY_SYMBOL / 2;
- const color = Theme.colors.collider;
- node.circle(0, 0, r).fill({ color, alpha: 0.25 }).stroke({ width: 2, color });
- node.moveTo(0, -6).lineTo(0, 4).stroke({ width: 2, color });
- node.moveTo(-5, 1).lineTo(0, 7).lineTo(5, 1).closePath().fill(color);
- return;
- }
case 'text':
node.hitArea = new Rectangle(
-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w,
@@ -212,32 +173,9 @@ export function drawItem(
}
}
-/**
- * The icon of a particle source (#89), drawn at its position in world units like the other symbols.
- * Unlike them it marks something that draws itself, so the ring stays hollow and the sparks sit
- * outside it: the effect it belongs to shows through its middle.
- */
-export function drawSourceIcon(node: Graphics): void {
- const r = PARTICLES_SYMBOL / 2;
- const color = Theme.colors.particles;
- node.circle(0, 0, r - 2).stroke({ width: 2, color });
- for (const [ux, uy] of DIAGONALS) {
- node.moveTo(ux * r, uy * r).lineTo(ux * (r + 4), uy * (r + 4));
- node.stroke({ width: 2, color });
- node.circle(ux * (r + 6), uy * (r + 6), 2).fill(color);
- }
-}
-
/** How far the landing cone of a one-way side reaches, in screen pixels (#159). */
export const CONE_LENGTH = 24;
-const DIAGONALS = [
- [Math.SQRT1_2, Math.SQRT1_2],
- [-Math.SQRT1_2, Math.SQRT1_2],
- [Math.SQRT1_2, -Math.SQRT1_2],
- [-Math.SQRT1_2, -Math.SQRT1_2],
-] as const;
-
interface Bounds {
left: number;
top: number;
diff --git a/tests/viewport/symbol-icons.test.ts b/tests/viewport/symbol-icons.test.ts
new file mode 100644
index 00000000..3ed7a198
--- /dev/null
+++ b/tests/viewport/symbol-icons.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { SYMBOL_SVG, tintSvg } from '../../src/viewport/symbolIcons';
+
+/** Opening tags as name + attributes; the files are flat, so no real XML parser is needed. */
+function tags(svg: string): { name: string; attrs: Record }[] {
+ return [...svg.matchAll(/<([a-z]+)([^>]*?)\/?>/g)].map(([, name, rest]) => ({
+ name: name!,
+ attrs: Object.fromEntries([...rest!.matchAll(/([a-zA-Z-]+)="([^"]*)"/g)].map(([, k, v]) => [k!, v!])),
+ }));
+}
+
+describe('scene view symbol icons (#175)', () => {
+ const icons = Object.entries(SYMBOL_SVG);
+
+ it('has a file for every symbol', () => {
+ expect(Object.keys(SYMBOL_SVG).sort()).toEqual([
+ 'behavior',
+ 'camera',
+ 'container',
+ 'particles',
+ 'rigidbody',
+ ]);
+ });
+
+ // Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only
+ // shows the file right elsewhere – centred on the origin, it matches what the scene view draws.
+ it.each(icons)('%s is centred on its origin', (_, svg) => {
+ const root = tags(svg)[0]!;
+ expect(root.name).toBe('svg');
+ const [x, y, w, h] = root.attrs.viewBox!.split(' ').map(Number);
+ expect([x, y]).toEqual([-w! / 2, -h! / 2]);
+ expect([Number(root.attrs.width), Number(root.attrs.height)]).toEqual([w, h]);
+ });
+
+ it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => {
+ for (const { name, attrs } of tags(svg).slice(1)) {
+ // polygon and polyline read their points as whole numbers; groups would carry transforms,
+ // which the parser ignores.
+ expect(['path', 'circle', 'rect', 'ellipse', 'line']).toContain(name);
+ expect(attrs.transform).toBeUndefined();
+ // Without a fill a browser fills black, Pixi does not fill at all once there is a stroke.
+ expect(attrs.fill, `${name} states its fill`).toBeDefined();
+ for (const paint of [attrs.fill, attrs.stroke].filter((p) => p !== undefined))
+ expect(paint).toMatch(/^(none|currentColor|#[0-9a-f]{6})$/);
+ }
+ });
+
+ it('puts the theme colour where the file says currentColor', () => {
+ expect(tintSvg('', 0x0000ff)).toBe(
+ '',
+ );
+ for (const [, svg] of icons) expect(tintSvg(svg, 0xb066ff)).not.toContain('currentColor');
+ });
+});
diff --git a/tools/e2e/05-drawing.mjs b/tools/e2e/05-drawing.mjs
index f9275f19..113acf06 100644
--- a/tools/e2e/05-drawing.mjs
+++ b/tools/e2e/05-drawing.mjs
@@ -1,3 +1,5 @@
+import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
+
/** Draw order: symbols always above sprites, `z` above hierarchy order. */
export async function drawing(t) {
const { check, json, shot, click, locate, typeText, press, undo, mutate, select, object } = t;
@@ -48,4 +50,57 @@ export async function drawing(t) {
await shot('14-z-order');
await undo();
check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`);
+
+ // 5c. The symbols are SVG files (#175): one of each in a lane of its own, read where each has a
+ // solid colour of the theme. Offsets are in world units from the symbol's centre.
+ const PLACED = { 's-beh': 'mover', 's-box': 's-box-a', 's-rb': 's-rb-a', 's-cam': 'cam', 's-pt': 's-pt-a' };
+ const SPOTS = [
+ ['s-beh', 8, 0, '#b066ff'],
+ ['s-beh', -6, -3, '#ffffff'],
+ ['s-box', -4, -4, '#3fb8c9'],
+ ['s-box', 4, 4, '#3fb8c9'],
+ ['s-rb', 0, 3, '#4fd18b'],
+ ['s-rb', 0, -4, '#4fd18b'],
+ ['s-cam', 0, 0, '#8a90a0'],
+ ['s-cam', 17, 0, '#8a90a0'],
+ ['s-pt', 12.728, 12.728, '#c26a1f'],
+ ['s-pt', -12.728, -12.728, '#c26a1f'],
+ ];
+ const ids = Object.keys(PLACED);
+ await mutate(`
+ const p = __gm.state.project;
+ const scene = __gm.state.activeScene;
+ ${MAKE_OBJECT}
+ p.assets.push(
+ { id: 's-box-a', type: 'container', name: 's-box-a', props: { enabled: true }, objects: [] },
+ { id: 's-rb-a', type: 'rigidbody', name: 's-rb-a', props: { bodyType: 'static' } },
+ { id: 's-pt-a', type: 'particles', name: 's-pt-a', props: {}, graph: { nodes: [], edges: [] } },
+ );
+ Object.entries(${JSON.stringify(PLACED)}).forEach(([id, assetId], i) => {
+ const o = make(id, assetId, '', ${LANES.symbols}, id);
+ o.position.x = (i - 2) * 120;
+ scene.objects.push(o);
+ });
+ __gm.state.select(null);
+ __gm.lookAt(0, ${LANES.symbols});`);
+ const wrong = await json(`(() => {
+ const zoom = __gm.objectRect('s-beh').w / 28;
+ return ${JSON.stringify(SPOTS)}.flatMap(([id, dx, dy, want]) => {
+ const r = __gm.objectRect(id);
+ const got = __gm.pixelAt(Math.floor(r.x + dx * zoom), Math.floor(r.y + dy * zoom));
+ return got === want ? [] : [id + ' ' + dx + ',' + dy + ': ' + got + ' != ' + want];
+ });
+ })()`);
+ check(
+ 'every symbol draws its SVG in the theme colour',
+ wrong.length === 0,
+ wrong.join('; ') || `${SPOTS.length} spots`,
+ );
+ await shot('15-symbols');
+ await mutate(`
+ const p = __gm.state.project;
+ const scene = __gm.state.activeScene;
+ scene.objects = scene.objects.filter((o) => !${JSON.stringify(ids)}.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);`);
}
diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs
index 541e3e8b..e1a60150 100644
--- a/tools/e2e/lib/scaffold.mjs
+++ b/tools/e2e/lib/scaffold.mjs
@@ -24,6 +24,7 @@ export const LANES = {
physics: -3500,
shapes: -3900,
queries: -4300,
+ symbols: -4700,
};
/**