diff --git a/src/editor/container/ContainerEditor.ts b/src/editor/container/ContainerEditor.ts index c926c056..c30088ce 100644 --- a/src/editor/container/ContainerEditor.ts +++ b/src/editor/container/ContainerEditor.ts @@ -22,6 +22,11 @@ export class ContainerEditor extends AssetTabEditor { + return this.canvas.objectRect(objectId); + } + override selectedId(): string { return this.state.selectedContainerId ?? ''; } diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index a0d8811e..e1a143de 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -191,6 +191,7 @@ export function sceneHooks({ state, editor }: HookContext) { rect: () => containerEditor.canvasRect(), /** Where a template object is drawn, so a check can click or sample it. */ objectScreen: (id: string) => containerEditor.objectScreen(id), + objectRect: (id: string) => containerEditor.objectRect(id), dropPreview: () => containerEditor.dropInfo(), }, }; diff --git a/src/editor/panels/icons/rigidbody.svg b/src/editor/panels/icons/rigidbody.svg index 0e7b8a69..499974f7 100644 --- a/src/editor/panels/icons/rigidbody.svg +++ b/src/editor/panels/icons/rigidbody.svg @@ -1,6 +1,6 @@ - + diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 4548bc78..a12dc976 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -3,7 +3,7 @@ * copy, so live positions are visible and stay editable. A container's template is the same * work on the same shape of data, so the Container tab subclasses this and swaps `holder()`. */ -import { type FederatedPointerEvent, Graphics, Rectangle, Text } from 'pixi.js'; +import { Container, type FederatedPointerEvent, Graphics, Rectangle, Text } from 'pixi.js'; import { oneStep } from '../../core/commands/CompoundCommand'; import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; import { hasSceneIcon } from '../../core/model/AssetTypes'; @@ -13,10 +13,10 @@ import { apply as applyPoint, applyVector, type Extent, - IDENTITY, invert, linearOf, type Mat, + translation, } from '../../core/model/matrix'; import { placementRejection } from '../../core/model/placement'; import type { Position, Vec2 } from '../../core/model/Properties'; @@ -32,9 +32,9 @@ import { collectHolderItems, type DrawItem } from '../../viewport/items'; import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick'; import { openObjectMenu } from '../objectMenu'; import { type LayerFocus, layerAlpha } from '../../viewport/tilemap'; -import { colliderShape } from '../../core/properties/collider'; -import { colliderColor, dashedLine } from '../../viewport/symbols'; -import { drawSymbolIcon } from '../../viewport/symbolIcons'; +import { dashedLine } from '../../viewport/symbols'; +import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons'; +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'; import { FreeViewport } from './FreeViewport'; @@ -82,10 +82,12 @@ export interface GizmoInfo { const OFF_GIZMO_ALPHA = 0.45; -/** World units a collider's icon stands left of the collider's centre. */ -const ICON_CLEARANCE = 28; -/** Half the side of a collider icon's clickable square, in world units – the SVG's view box. */ -const ICON_HALF = 13; +/** Side of a symbol's icon in world units: it grows and shrinks with the zoom like the scene. */ +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; +/** 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']); /** Owner links in the colour of the icon they leave; anything else in the muted default. */ const LINK_COLORS: Partial> = { @@ -100,7 +102,11 @@ export class SceneViewport extends FreeViewport { private readonly gizmos = new Graphics(); private readonly labels = new Map(); private readonly icons = new Map(); - private readonly iconColors = new Map(); + /** Names under icons, icons above everything else in the overlay – outlines and frames included. */ + private readonly labelLayer = new Container(); + private readonly iconLayer = new Container(); + /** Where each icon stood at the last redraw, in world units (see `layoutSymbols`). */ + private spots = new Map(); private readonly solids = new TilemapSolidOutlines(this.overlay); private readonly host: SceneViewHost = { state: this.state, @@ -132,6 +138,8 @@ export class SceneViewport extends FreeViewport { this.world.addChildAt(this.links, 1); this.content.addChild(this.gizmos); this.links.eventMode = 'none'; + this.labelLayer.eventMode = 'none'; + this.overlay.addChild(this.labelLayer, this.iconLayer); this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); @@ -436,7 +444,7 @@ export class SceneViewport extends FreeViewport { return hits; } - /** Whether the pointer is on the clickable icon of this object – a collider's. */ + /** Whether the pointer is on this object's icon, wherever `placeIcons` put it. */ private onIcon(id: string, global: Vec2): boolean { const icon = this.icons.get(id); if (!icon?.visible || icon.eventMode === 'none' || !icon.hitArea) return false; @@ -484,8 +492,9 @@ export class SceneViewport extends FreeViewport { return this.lastGizmos; } - /** The links are in world units, so a pan leaves them where they are. */ + /** Icons and links are in world units, so a pan leaves them where they are. */ protected override afterItems(items: DrawItem[], view: View): void { + this.placeIcons(items, view.scale); this.drawParentLinks(items, view.scale); } @@ -521,10 +530,15 @@ export class SceneViewport extends FreeViewport { } } - protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, extent: Extent): void { + protected override syncDecorations( + item: DrawItem, + onScreen: boolean, + scale: number, + _extent: Extent, + ): void { this.syncIcon(item, onScreen); this.solids.sync(item, onScreen, scale); - this.syncLabel(item, onScreen, scale, extent); + this.syncLabel(item, onScreen, scale); } protected override disposeDecorations(id: string): void { @@ -532,7 +546,6 @@ export class SceneViewport extends FreeViewport { this.labels.delete(id); this.icons.get(id)?.destroy(); this.icons.delete(id); - this.iconColors.delete(id); this.solids.dispose(id); } @@ -541,10 +554,7 @@ export class SceneViewport extends FreeViewport { return this.solids.info(this.lastItems.find((i) => i.id === id)); } - /** - * The icon of a particle source or collider, in world units, the name beside it and whether it - * hangs off its owner (test hook). - */ + /** The icon of a symbol in world units, the name beside it and whether it hangs off its owner (test hook). */ iconInfo(id: string): { layer: string; visible: boolean; @@ -552,7 +562,6 @@ export class SceneViewport extends FreeViewport { link: boolean; x: number; y: number; - color: number | null; screen: Vec2; clickable: boolean; } | null { @@ -561,31 +570,27 @@ export class SceneViewport extends FreeViewport { const label = this.labels.get(id); const screen = icon.getGlobalPosition(); return { - layer: icon.parent === this.overlay ? 'overlay' : 'objects', + layer: icon.parent === this.iconLayer ? 'overlay' : 'objects', visible: icon.visible, label: label?.visible ? label.text : '', link: this.lastLinks.includes(id), x: icon.position.x, y: icon.position.y, - color: this.iconColors.get(id) ?? null, screen: { x: screen.x, y: screen.y }, clickable: icon.eventMode !== 'none', }; } /** - * A source draws only its particles, so without a mark of its own an empty one is invisible and - * cannot be found (#89). The mark is a decoration rather than a symbol: `GameViewport` never syncs - * decorations, so it is editor-only without the source leaving the running game. A collider gets - * one too, like every other symbol: a thin outline around a sprite is easily missed. Its icon is - * clicked like the outline – a source's square already covers its own – and stands left of the - * collider's centre: the middle of the sprite it sits on stays free for grabbing the sprite, and - * a rigidbody's icon on the same centre stays uncovered (its name stands to its right). + * Every symbol shows the asset panel's icon of its type, above everything in the scene, and is + * picked by it: a behavior, container or rigidbody is nothing else, and a thin collider outline + * or an empty particle source is easily missed (#89). The icons are decorations, so + * `GameViewport` – which never syncs any – leaves them out, and a source's particles still reach + * the running game. Where an icon stands is decided for all of them at once (`placeIcons`). */ private syncIcon(item: DrawItem, show: boolean): void { let icon = this.icons.get(item.id); - const type = item.type === 'particles' || item.type === 'collider' ? item.type : null; - if (!show || !type) { + if (!show || !hasSceneIcon(item.type)) { if (icon) { icon.visible = false; icon.eventMode = 'none'; @@ -594,29 +599,41 @@ export class SceneViewport extends FreeViewport { } if (!icon) { icon = new Graphics(); - icon.eventMode = 'none'; - if (type === 'collider') { - icon.hitArea = new Rectangle(-ICON_HALF, -ICON_HALF, ICON_HALF * 2, ICON_HALF * 2); - icon.cursor = 'move'; - icon.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e)); - } + drawSymbolIcon(icon, item.type as SymbolIcon); + icon.scale.set(SYMBOL_SIZE / SYMBOL_GRID); + const half = SYMBOL_GRID / 2 + SYMBOL_OUTLINE; + icon.hitArea = new Rectangle(-half, -half, half * 2, half * 2); + icon.cursor = 'move'; + icon.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e)); this.icons.set(item.id, icon); - this.overlay.addChild(icon); - } - if (type === 'collider') icon.eventMode = 'static'; - // A collider's icon takes the colour of its outline: a trigger's, or red for a shape that - // collides with nothing. - const color = - type === 'collider' - ? colliderColor(colliderShape(item.props, IDENTITY, item.frame), item.props) - : Theme.colors.particles; - if (this.iconColors.get(item.id) !== color) { - icon.clear(); - drawSymbolIcon(icon, type, color); - this.iconColors.set(item.id, color); + this.iconLayer.addChild(icon); } icon.visible = true; - icon.position.set(type === 'collider' ? item.x - ICON_CLEARANCE : item.x, item.y); + icon.eventMode = 'static'; + } + + /** + * Puts every icon where `layoutSymbols` says – on its object, or on a ring round a spot several + * want – and each name beside its icon, on the outer side of a ring. Switched-off objects draw no + * icon and take no room; ones off screen do, so an icon does not jump while the view pans. + */ + private placeIcons(items: DrawItem[], scale: number): void { + const last = this.overlay.children.length - 1; + this.overlay.setChildIndex(this.labelLayer, last); + this.overlay.setChildIndex(this.iconLayer, last); + const anchors = items + .filter((item) => item.enabled && hasSceneIcon(item.type)) + .map((item) => ({ id: item.id, x: item.x, y: item.y })); + this.spots = layoutSymbols(anchors, SYMBOL_SPACING); + const gap = SYMBOL_SIZE / 2 + 4 / scale; + for (const [id, spot] of this.spots) { + this.icons.get(id)?.position.set(spot.x, spot.y); + const label = this.labels.get(id); + if (!label?.visible) 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); + } } private syncGrid(): void { @@ -649,13 +666,16 @@ export class SceneViewport extends FreeViewport { if (!parent || !parent.enabled) continue; this.lastLinks.push(item.id); const color = LINK_COLORS[item.type] ?? Theme.colors.textMuted; - dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale); + const from = this.spots.get(item.id) ?? item; + const to = this.spots.get(parent.id) ?? parent; + dashedLine(this.links, from.x, from.y, to.x, to.y, 8 / scale, 6 / scale); this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 }); - this.links.circle(parent.x, parent.y, 3 / scale).fill(color); + this.links.circle(to.x, to.y, 3 / scale).fill(color); } } - private syncLabel(item: DrawItem, show: boolean, scale: number, extent: Extent): void { + /** The name of a symbol; `placeIcons` puts it beside the icon once every icon has its spot. */ + private syncLabel(item: DrawItem, show: boolean, scale: number): void { let label = this.labels.get(item.id); if (!show || !hasSceneIcon(item.type)) { if (label) label.visible = false; @@ -665,14 +685,11 @@ export class SceneViewport extends FreeViewport { label = new Text({ text: '', style: textStyle('viewportLabelMuted') }); label.eventMode = 'none'; this.labels.set(item.id, label); - this.overlay.addChild(label); + this.labelLayer.addChild(label); } label.visible = true; label.text = item.name; label.scale.set(1 / scale); - if (item.type === 'camera' || item.type === 'collider') - label.position.set(extent.left, extent.top - 16 / scale); - else label.position.set(extent.right + 4 / scale, item.y - 8 / scale); } /** @@ -710,9 +727,12 @@ export class SceneViewport extends FreeViewport { for (const id of s.objectIds) { const item = items.find((i) => i.id === id); if (!item) continue; - // Two screen pixels outside the footprint, whichever way it is turned. + // Two screen pixels outside the footprint, whichever way it is turned. A symbol that is + // only its icon is outlined where the icon stands, which need not be its position. + const spot = ICON_ONLY.has(item.type) ? this.spots.get(id) : undefined; + const m = spot ? translation(spot.x, spot.y) : item.m; const corners = this.screenCorners( - footprintCorners(item.type, item.props, item.m, 2 / view.scale, item.frame), + footprintCorners(item.type, item.props, m, 2 / view.scale, item.frame), view, ); // A switched-off object draws nothing, so its outline is the only way to see where it diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 8de46c15..0e67bfe4 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -39,7 +39,10 @@ export const Theme = { danger: 0xe0553f, viewport: 0x121316, gizmo: 0x4c8dff, - /** Behavior symbols and owner links. */ + /** Every symbol in the scene view: white, held off any ground by a dark outline. */ + symbol: 0xffffff, + symbolOutline: 0x0b0c0f, + /** Owner links of behaviors. */ behavior: 0xb066ff, /** Collider outlines: solid (blocking) and trigger (events only). */ collider: 0x4fd18b, diff --git a/src/ui/index.ts b/src/ui/index.ts index 0436d30e..c0a2b274 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -71,4 +71,4 @@ export { toHex, } from './color'; export { pressCount } from './clicks'; -export { drawSvg, tintSvg } from './svg'; +export { drawSvg, haloSvg, tintSvg } from './svg'; diff --git a/src/ui/svg.ts b/src/ui/svg.ts index c5f9e6ca..3b1f5cd5 100644 --- a/src/ui/svg.ts +++ b/src/ui/svg.ts @@ -9,6 +9,32 @@ export function tintSvg(svg: string, color: number): string { return svg.replaceAll('currentColor', `#${color.toString(16).padStart(6, '0')}`); } +const SHAPE = /<(rect|circle|ellipse|path|polygon|polyline|line)\b([^>]*?)\s*\/>/g; + +/** + * The same file as a silhouette `width` units wider on every side: each shape keeps its fill and + * gets a stroke of its own stroke plus twice `width`, all in `currentColor`. Drawn in a dark colour + * under the file itself it is an outline that keeps a light icon readable on any ground. + * A stroke that ends flat (`butt`, the default) would leave its ends without an outline, so the + * halo caps it square – a little long by half the own stroke. An icon that wants an exact outline + * at an open end draws that part as a filled shape. + */ +export function haloSvg(svg: string, width: number): string { + return svg.replace(SHAPE, (_, tag: string, attrs: string) => { + const fill = /\sfill="([^"]*)"/.exec(attrs)?.[1] ?? 'none'; + const stroke = /\sstroke="([^"]*)"/.exec(attrs)?.[1] ?? 'none'; + const own = stroke === 'none' ? 0 : Number(/\sstroke-width="([^"]*)"/.exec(attrs)?.[1] ?? 1); + const cap = /\sstroke-linecap="([^"]*)"/.exec(attrs)?.[1] ?? 'butt'; + const geometry = attrs.replace( + /\s(fill|stroke|stroke-width|stroke-linecap|fill-opacity|stroke-opacity)="[^"]*"/g, + '', + ); + const paint = fill === 'none' ? 'none' : 'currentColor'; + const ends = cap === 'butt' ? 'square' : cap; + return `<${tag}${geometry} fill="${paint}" stroke="currentColor" stroke-width="${own + 2 * width}" stroke-linecap="${ends}" />`; + }); +} + /** Parsed once per file and colour: a scene symbol is redrawn with every frame. */ const parsed = new Map>(); diff --git a/src/viewport/icons/behavior.svg b/src/viewport/icons/behavior.svg deleted file mode 100644 index 7af4eeb0..00000000 --- a/src/viewport/icons/behavior.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/viewport/icons/camera.svg b/src/viewport/icons/camera.svg deleted file mode 100644 index df84f935..00000000 --- a/src/viewport/icons/camera.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/viewport/icons/collider.svg b/src/viewport/icons/collider.svg deleted file mode 100644 index 8742733a..00000000 --- a/src/viewport/icons/collider.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/viewport/icons/container.svg b/src/viewport/icons/container.svg deleted file mode 100644 index f5bf8245..00000000 --- a/src/viewport/icons/container.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/viewport/icons/particles.svg b/src/viewport/icons/particles.svg deleted file mode 100644 index 4c38b121..00000000 --- a/src/viewport/icons/particles.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/viewport/icons/rigidbody.svg b/src/viewport/icons/rigidbody.svg deleted file mode 100644 index a314b5eb..00000000 --- a/src/viewport/icons/rigidbody.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/viewport/symbolIcons.ts b/src/viewport/symbolIcons.ts index e45c94e6..8230c75e 100644 --- a/src/viewport/symbolIcons.ts +++ b/src/viewport/symbolIcons.ts @@ -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 = { rigidbody, }; -const SYMBOL_COLOR: Record = { - 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(); + +/** 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); } diff --git a/src/viewport/symbolLayout.ts b/src/viewport/symbolLayout.ts new file mode 100644 index 00000000..99402a49 --- /dev/null +++ b/src/viewport/symbolLayout.ts @@ -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 { + 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(); + 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; +} diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 8b43c6ca..1d61f4ea 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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 { diff --git a/tests/viewport/symbol-layout.test.ts b/tests/viewport/symbol-layout.test.ts new file mode 100644 index 00000000..ab27a493 --- /dev/null +++ b/tests/viewport/symbol-layout.test.ts @@ -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 = [ + '', + ' ', + ' ', + '', + ].join('\n'); + const halo = haloSvg(svg, 1.25); + expect(halo).toContain( + '', + ); + expect(halo).toContain( + '', + ); + expect(halo.startsWith('')).toBe(true); + }); + + it('outlines the open ends of a stroke too, keeping a round cap round', () => { + const svg = [ + '', + ' ', + ' ', + '', + ].join('\n'); + const halo = haloSvg(svg, 1); + expect(halo).toContain( + '', + ); + expect(halo).toContain( + '', + ); + }); +}); diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs index 8fd9bc8a..ceefc5db 100644 --- a/tools/e2e/02-inspector.mjs +++ b/tools/e2e/02-inspector.mjs @@ -130,8 +130,9 @@ export async function inspector(t) { ); // 2g. Drag the red crate in the viewport. It is at world (-150,0); the view is centred - // on the origin, so compute its screen position from the viewport centre. - await drag(center.x - 150, center.y, center.x - 50, center.y - 70); + // on the origin, so compute its screen position from the viewport centre. Pressed right of + // its middle: the crate wears a collider, whose icon stands on the middle. + await drag(center.x - 120, center.y, center.x - 20, center.y - 70); const o1Now = (await object('o1')).position; check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now)); await shot('06-dragged'); diff --git a/tools/e2e/05-drawing.mjs b/tools/e2e/05-drawing.mjs index 7eca23e0..8b0042f9 100644 --- a/tools/e2e/05-drawing.mjs +++ b/tools/e2e/05-drawing.mjs @@ -51,21 +51,11 @@ export async function drawing(t) { 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. + // 5c. The symbols are the asset panel's icons, white in a dark outline and without a plate + // behind them: one of each in a lane of its own, the pixels read on a grid over each icon + // (±12 world units round its centre). Each must show white and the outline, and none of the + // colours the symbols had before. 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; @@ -83,18 +73,31 @@ export async function drawing(t) { }); __gm.state.select(null); __gm.lookAt(0, ${LANES.symbols});`); - const wrong = await json(`(() => { + const looks = 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]; + const old = ['#b066ff', '#3fb8c9', '#4fd18b', '#c26a1f']; + const rgb = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); + const near = (a, b, max) => Math.hypot(...rgb(a).map((v, i) => v - rgb(b)[i])) < max; + return ${JSON.stringify(ids)}.map((id) => { + const icon = __gm.sceneIcon(id); + const seen = { id, white: 0, outline: 0, old: [] }; + for (let dx = -12; dx <= 12; dx += 1.5) + for (let dy = -12; dy <= 12; dy += 1.5) { + const got = __gm.pixelAt(Math.floor(icon.screen.x + dx * zoom), Math.floor(icon.screen.y + dy * zoom)); + if (got === '#ffffff') seen.white++; + // The outline is thin, so at this zoom it is mostly an edge pixel blended into the + // background (#111216 and the like), rarely the exact #0b0c0f. + else if (near(got, '#0b0c0f', 20)) seen.outline++; + else if (old.includes(got)) seen.old.push(got); + } + return seen; }); })()`); + const wrong = looks.filter((s) => s.white < 4 || s.outline < 4 || s.old.length > 0); check( - 'every symbol draws its SVG in the theme colour', + 'every symbol draws its icon white in a dark outline, in none of the old colours', wrong.length === 0, - wrong.join('; ') || `${SPOTS.length} spots`, + JSON.stringify(wrong.length ? wrong : looks.map((s) => `${s.id} ${s.white}/${s.outline}`)), ); await shot('15-symbols'); await mutate(` diff --git a/tools/e2e/16-container.mjs b/tools/e2e/16-container.mjs index cba8ea2c..94675637 100644 --- a/tools/e2e/16-container.mjs +++ b/tools/e2e/16-container.mjs @@ -70,9 +70,11 @@ export async function containerTab(t) { ); // 16c. Clicking a template object selects it against the container as its holder – the same - // id set as the scene would give, but a different holder. + // id set as the scene would give, but a different holder. Pressed right of its middle: the + // crate wears a collider, whose icon stands on the middle. const root = template.find((o) => o.parentId === ''); - const at = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`); + const rect = await json(`__gm.container.objectRect(${JSON.stringify(root.id)})`); + const at = { x: rect.x + rect.w * 0.3, y: rect.y }; await click(at.x, at.y); let s = await state(); check( diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs index a45b24bd..3a3723d8 100644 --- a/tools/e2e/31-particles.mjs +++ b/tools/e2e/31-particles.mjs @@ -515,13 +515,15 @@ export async function particles(t) { // 31n. (#89) The ring is hollow on purpose – the mark may not hide the effect it marks. Scanned // outward from the source's own point, because a 2-unit stroke at an exact radius would be a - // test of rounding rather than of drawing. - const ICON_COLOUR = '#c26a1f'; + // test of rounding rather than of drawing. The ring is white like every symbol's icon, with + // a radius of 3.94 world units: its thin stroke starts at about 3.1, so the scan takes the + // first white from 2.5 units out, and the dark outline leaves the middle two units free. + const ICON_COLOUR = '#ffffff'; const unit = (await json(`__gm.objectRect(${JSON.stringify(SOURCE)})`)).w / 24; const row = []; for (let dx = 0; dx <= Math.ceil(19 * unit); dx++) row.push(await json(`__gm.pixelAt(${Math.round(spotOn.x + dx)}, ${Math.round(spotOn.y)})`)); - const ringAt = row.findIndex((c, dx) => dx > 4 * unit && colourDistance(c, ICON_COLOUR) < 12); + const ringAt = row.findIndex((c, dx) => dx >= 2.5 * unit && colourDistance(c, ICON_COLOUR) < 12); check( 'the icon rings the source and leaves its middle to the particles', ringAt > 0 && colourDistance(row[0], ICON_COLOUR) > 60 && row[0] !== '#22252c', diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index c23c1fd9..de888d2f 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -154,55 +154,58 @@ export async function physics(t) { await evaluate(`__gm.lookAt(0, ${Y + 100})`); await settle(); await shot('41-rigidbody'); - // A collider has an icon like every other symbol. It stands left of the collider's centre, so the - // middle of its sprite stays free, and so does a rigidbody's icon on the same spot. + // Every symbol has an icon and wants its object's position. One with room stands there; icons + // that want the same spot share it on a ring round it, whoever owns them – two stand left and + // right of it, in hierarchy order: the seeded collider first, the rigidbody added above second. const icons = { ground: await json(`__gm.sceneIcon('phys-ground-col')`), crate: await json(`__gm.sceneIcon('phys-crate-col')`), + crateBody: await json(`__gm.sceneIcon('${crateBody?.id}')`), ball: await json(`__gm.sceneIcon('phys-ball-col')`), + ballBody: await json(`__gm.sceneIcon('${ballBody?.id}')`), }; + const near = (icon, x, y) => + icon?.visible === true && Math.abs(icon.x - x) < 0.01 && Math.abs(icon.y - y) < 0.01; check( - 'a collider without a rigidbody shows its icon left of its centre', - icons.ground?.visible === true && - icons.ground.layer === 'overlay' && - icons.ground.x === -28 && - icons.ground.y === Y + 200 && - icons.ground.color !== null, + 'a symbol with room around it stands on its object, in the overlay', + near(icons.ground, 0, Y + 200) && icons.ground.layer === 'overlay', JSON.stringify(icons.ground), ); check( - "a collider's icon steps aside from the rigidbody on the same body, in the same colour", - [ - [icons.crate, -100], - [icons.ball, 100], - ].every(([icon, x]) => icon?.visible === true && icon.x === x - 28 && icon.y === Y) && - icons.crate.color === icons.ground.color, + 'icons on one spot stand left and right of it, a symbol apart', + near(icons.crate, -110.5, Y) && + near(icons.crateBody, -89.5, Y) && + near(icons.ball, 89.5, Y) && + near(icons.ballBody, 110.5, Y), JSON.stringify(icons), ); - // The icon is clicked like the outline: it selects the collider, even over its sprite. On the - // middle of the sprite, beside the icon, the sprite is picked as before. + // An icon is clicked like its object: it selects it, even over a sprite. Beside the icon the + // sprite under it is picked as before. const selectedId = async () => { const s = await state(); return s.selection?.kind === 'object' ? s.selection.objectId : null; }; + const zoom = (icons.crateBody.screen.x - icons.crate.screen.x) / 21; const picks = {}; for (const [name, id, dx] of [ ['groundIcon', 'phys-ground-col', 0], ['crateIcon', 'phys-crate-col', 0], - ['groundMiddle', 'phys-ground', null], + ['crateBodyIcon', crateBody?.id, 0], + ['groundBeside', 'phys-ground-col', 40], ]) { await select(null); - const at = await json(dx === null ? `__gm.objectScreen('${id}')` : `__gm.sceneIcon('${id}')?.screen`); - await click(at.x + (dx ?? 0), at.y); + const at = await json(`__gm.sceneIcon('${id}')?.screen`); + await click(at.x + dx * zoom, at.y); picks[name] = { selected: await selectedId() }; } check( - "a click on a collider's icon selects the collider, on the sprite's middle the sprite", + 'a click on an icon selects its object, beside it the sprite below', icons.ground.clickable === true && picks.groundIcon.selected === 'phys-ground-col' && picks.crateIcon.selected === 'phys-crate-col' && - picks.groundMiddle.selected === 'phys-ground', - JSON.stringify(picks), + picks.crateBodyIcon.selected === crateBody?.id && + picks.groundBeside.selected === 'phys-ground', + JSON.stringify({ zoom, picks }), ); await selectObject('phys-ball-col');