From 1c212b1cccd1e7620c4d646f49436a1e661d22e9 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 19 Sep 2026 17:07:56 +0200 Subject: [PATCH] Scene view symbols come from SVG files: the behavior, container, rigidbody, camera and particles icons live in src/viewport/icons/ and are changed by editing the file and running npm run build; they are bundled, parsed once and drawn into a Graphics by the new module symbolIcons.ts, currentColor becomes the theme color of the symbol, the origin sits in the middle with a centered viewBox and the same units as before, so the symbols are pixel for pixel unchanged; what depends on the object (camera frame, collider outline, one-way cone) stays in code; unit tests keep the files to what Pixi's SVG parser and a browser draw alike (no transform, no polygon, fill always given), e2e 5c reads the five symbols in the theme color (#175) Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/editor/viewport/SceneViewport.ts | 5 +- src/viewport/icons/behavior.svg | 6 +++ src/viewport/icons/camera.svg | 4 ++ src/viewport/icons/container.svg | 7 +++ src/viewport/icons/particles.svg | 9 ++++ src/viewport/icons/rigidbody.svg | 6 +++ src/viewport/symbolIcons.ts | 42 +++++++++++++++ src/viewport/symbols.ts | 76 +++------------------------- tests/viewport/symbol-icons.test.ts | 54 ++++++++++++++++++++ tools/e2e/05-drawing.mjs | 55 ++++++++++++++++++++ tools/e2e/lib/scaffold.mjs | 1 + 12 files changed, 195 insertions(+), 72 deletions(-) create mode 100644 src/viewport/icons/behavior.svg create mode 100644 src/viewport/icons/camera.svg create mode 100644 src/viewport/icons/container.svg create mode 100644 src/viewport/icons/particles.svg create mode 100644 src/viewport/icons/rigidbody.svg create mode 100644 src/viewport/symbolIcons.ts create mode 100644 tests/viewport/symbol-icons.test.ts 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, }; /**