From 90627ccf4721e51f653433d1e5ec1fb3a0a9d4eb Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 21 Sep 2026 06:25:56 +0200 Subject: [PATCH] Snap is a magnet button in every graph and vector art tab (#194) The snap toggle was a checkbox with the word "Snap" next to Fit, and the vector art tab had its own "Grid" button for the same idea. Both now share one square icon button, so snapping looks and works the same wherever it exists. - SnapToggle is a square toggle button with a magnet icon and no text. It lights up blue when snapping is on, like the other toggle buttons. - The magnet is drawn with cubic curves instead of arcs, because the Pixi SVG parser draws arcs as jagged edges. - The vector art tab drops its Grid button and uses the same SnapToggle. It still snaps to 8 px, the graph tabs to 24 px. - SnapToggle accepts anything with setSnapping(on). The vector art canvas method is renamed from setSnap to setSnapping for that. - Every tab exposes the button as __gm..snapButton(). Modules 06, 26 and 36 click through that hook instead of the label "Snap" plus 30 px. Tests: - ./check.sh: 129 files, 1903 tests (was 128 / 1897) - 3 new tests in tests/editor/snap-toggle.test.ts, 3 in the icon tests - GM_STEPS=1-6,26,36,47 fresh: ALL PASSED, 346 checks - GM_STEPS=47: ALL PASSED, 32 checks (was 29) - Counter-checks: toggle not switching its target fails 2 unit tests, the canvas ignoring the switch fails module 47 - Check total 1188 in README and wiki is computed (1184 + 4), the full run is left to CI Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/editor/debugHook.ts | 13 +++++ src/editor/graph/GraphEditor.ts | 2 +- src/editor/graph/SnapToggle.ts | 41 ++++++++++---- src/editor/panels/headerIcons.ts | 4 ++ src/editor/panels/icons/magnet.svg | 7 +++ src/editor/particles/ParticlesEditor.ts | 2 +- src/editor/shader/ShaderEditor.ts | 2 +- src/editor/statemachine/StateMachineEditor.ts | 2 +- src/editor/vectorart/VectorArtCanvas.ts | 2 +- src/editor/vectorart/VectorArtEditor.ts | 18 ++----- tests/editor/snap-toggle.test.ts | 54 +++++++++++++++++++ tests/ui/svg-icons.test.ts | 17 ++++++ tools/e2e/06-graph.mjs | 14 +++-- tools/e2e/26-state-machine.mjs | 13 +++-- tools/e2e/36-graph-groups.mjs | 7 ++- tools/e2e/47-vectorart.mjs | 24 +++++++++ 17 files changed, 181 insertions(+), 43 deletions(-) create mode 100644 src/editor/panels/headerIcons.ts create mode 100644 src/editor/panels/icons/magnet.svg create mode 100644 tests/editor/snap-toggle.test.ts diff --git a/README.md b/README.md index 54659dd4..3e55b857 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,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, 1184 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1188 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 9442ec54..68b4a0c7 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -33,6 +33,7 @@ import { currentSoundPicker } from './sound/SoundPicker'; import { currentImagePicker } from './texture/ImagePicker'; import { currentTilemapWidgets } from './panels/TilemapPanel'; import { currentShapeList } from './inspector/shapeSection'; +import type { SnapToggle } from './graph/SnapToggle'; import type { EditorState, TilemapTool } from './state'; import { rectOf, textProbe } from './textProbe'; @@ -56,6 +57,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const p = widget.getGlobalPosition(); return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; }; + /** A tab's magnet (#194): where to click it, how big it is drawn, and whether it is on. */ + const snapButton = (snap: SnapToggle) => ({ + ...centreOf(snap.button), + w: snap.button.w, + h: snap.button.h, + toggled: snap.button.toggled, + }); const { eachText, locate, locateAll } = textProbe(app); /** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */ const textColor = (text: string): string | null => { @@ -338,6 +346,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E // drawing area. center: () => centreOf(graphEditor.canvas), snapping: () => graphEditor.canvas.snapEnabled, + snapButton: () => snapButton(graphEditor.snap), zoom: () => graphEditor.canvas.zoomLevel, crumbs: () => graphEditor.breadcrumb.crumbsDrawn(), }, @@ -349,6 +358,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(), center: () => centreOf(editor.stateMachineEditor.canvas), snapping: () => editor.stateMachineEditor.canvas.snapEnabled, + snapButton: () => snapButton(editor.stateMachineEditor.snap), /** Where a state's hidden pin sits; it cannot be grabbed – `portGrabbable` is the check. */ portAt: (nodeId: string, port: string, isOutput: boolean) => editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput), @@ -372,6 +382,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E particles: { current: () => editor.particlesEditor.currentAssetId, info: () => editor.particlesEditor.shownInfo, + snapButton: () => snapButton(editor.particlesEditor.snap), nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId), nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId), nodeRect: (nodeId: string) => editor.particlesEditor.canvas.nodeRect(nodeId), @@ -405,6 +416,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E emptySpot: () => editor.shaderEditor.canvas.emptySpotGlobal(), center: () => centreOf(editor.shaderEditor.canvas), snapping: () => editor.shaderEditor.canvas.snapEnabled, + snapButton: () => snapButton(editor.shaderEditor.snap), }, /** The console panel: its lines as kept, its drawn height, and the divider above it. */ console: { @@ -456,6 +468,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E rect: () => vectorArtEditor.canvasRect(), tool: () => vectorArtEditor.canvas.currentTool, snapping: () => vectorArtEditor.canvas.snapping, + snapButton: () => snapButton(vectorArtEditor.snap), /** Whether the pen has a line open that the next click would add a point to. */ drawing: () => vectorArtEditor.canvas.drawing, /** The shapes as the model holds them, so a check can read points and handles exactly. */ diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 5a1fb4b6..8943fb70 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -11,7 +11,7 @@ import { backTo, descend, follow, type Trail } from './trail'; const INFO_WIDTH = 150; export class GraphEditor extends AssetTabEditor { - private readonly snap = new SnapToggle(() => this.canvas); + readonly snap = new SnapToggle(() => this.canvas); readonly breadcrumb = new Breadcrumb(); private trail: Trail = []; /** A call followed into another behavior: the tab goes on to that document (#171). */ diff --git a/src/editor/graph/SnapToggle.ts b/src/editor/graph/SnapToggle.ts index d8df8837..cb3abfc4 100644 --- a/src/editor/graph/SnapToggle.ts +++ b/src/editor/graph/SnapToggle.ts @@ -1,17 +1,40 @@ -import { Checkbox, Label, type Widget } from '../../ui'; -import type { GraphCanvas } from './GraphCanvas'; +import { Button, Theme } from '../../ui'; +import { HEADER_ICON_SVG } from '../panels/headerIcons'; -/** The "Snap" checkbox every tab on the graph canvas carries in its header. */ +/** Whatever a tab snaps: the graph canvas, the vector art canvas. */ +export interface Snappable { + setSnapping(on: boolean): void; +} + +/** The magnet every tab that snaps carries in its header (#194): an icon toggle, off at first. */ export class SnapToggle { - private readonly box = new Checkbox(); - readonly widgets: readonly Widget[]; + readonly button: Button; + private on = false; - constructor(canvas: () => GraphCanvas) { - this.box.onChange = (on) => canvas().setSnapping(on); - this.widgets = [new Label('Snap', { style: 'muted' }), this.box]; + constructor(private readonly target: () => Snappable) { + this.button = new Button('', { + width: Theme.controlHeight, + icon: HEADER_ICON_SVG.snap, + toggle: true, + onClick: () => this.set(!this.on), + }); + } + + get widgets(): readonly Button[] { + return [this.button]; + } + + get snapping(): boolean { + return this.on; + } + + set(on: boolean): void { + this.on = on; + this.target().setSnapping(on); + this.button.setToggled(on); } setEnabled(on: boolean): void { - this.box.setEnabled(on); + this.button.setEnabled(on); } } diff --git a/src/editor/panels/headerIcons.ts b/src/editor/panels/headerIcons.ts new file mode 100644 index 00000000..3eb939d8 --- /dev/null +++ b/src/editor/panels/headerIcons.ts @@ -0,0 +1,4 @@ +/** Icons of the tab headers' switches (#194): a picture instead of a caption. */ +import magnet from './icons/magnet.svg?raw'; + +export const HEADER_ICON_SVG = { snap: magnet }; diff --git a/src/editor/panels/icons/magnet.svg b/src/editor/panels/icons/magnet.svg new file mode 100644 index 00000000..dd7a8451 --- /dev/null +++ b/src/editor/panels/icons/magnet.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/editor/particles/ParticlesEditor.ts b/src/editor/particles/ParticlesEditor.ts index daa7d283..e88a51f9 100644 --- a/src/editor/particles/ParticlesEditor.ts +++ b/src/editor/particles/ParticlesEditor.ts @@ -27,7 +27,7 @@ const HOLD_WIDTH = 52; */ export class ParticlesEditor extends AssetTabEditor { readonly preview: ParticlePreview; - private readonly snap = new SnapToggle(() => this.canvas); + readonly snap = new SnapToggle(() => this.canvas); /** Public for the check scripts: the one control they have to hit rather than read. */ readonly slider = new Slider(0, 0, MIN_SPAN, 120); private readonly holdButton = new Button('Hold', { diff --git a/src/editor/shader/ShaderEditor.ts b/src/editor/shader/ShaderEditor.ts index 7032a80c..4fd43acc 100644 --- a/src/editor/shader/ShaderEditor.ts +++ b/src/editor/shader/ShaderEditor.ts @@ -14,7 +14,7 @@ const INFO_WIDTH = 220; * on every change (see `ShaderLibrary`); the readout says whether it did. */ export class ShaderEditor extends AssetTabEditor { - private readonly snap = new SnapToggle(() => this.canvas); + readonly snap = new SnapToggle(() => this.canvas); constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); diff --git a/src/editor/statemachine/StateMachineEditor.ts b/src/editor/statemachine/StateMachineEditor.ts index a6a7f417..c292d227 100644 --- a/src/editor/statemachine/StateMachineEditor.ts +++ b/src/editor/statemachine/StateMachineEditor.ts @@ -14,7 +14,7 @@ const INFO_WIDTH = 90; * and undo are all the graph's. */ export class StateMachineEditor extends AssetTabEditor { - private readonly snap = new SnapToggle(() => this.canvas); + readonly snap = new SnapToggle(() => this.canvas); constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index 6b5cde46..383ce766 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -171,7 +171,7 @@ export class VectorArtCanvas extends Widget { return this.tool; } - setSnap(on: boolean): void { + setSnapping(on: boolean): void { this.snap = on; } diff --git a/src/editor/vectorart/VectorArtEditor.ts b/src/editor/vectorart/VectorArtEditor.ts index ce5b6da0..e6e9c7f1 100644 --- a/src/editor/vectorart/VectorArtEditor.ts +++ b/src/editor/vectorart/VectorArtEditor.ts @@ -2,6 +2,7 @@ import type { Asset, VectorArtAsset } from '../../core/model/Asset'; import { vectorCanvasSize } from '../../core/model/vectorart'; import { Button } from '../../ui'; import { AssetTabEditor } from '../AssetTabEditor'; +import { SnapToggle } from '../graph/SnapToggle'; import type { EditorState } from '../state'; import { VectorArtCanvas, type VectorTool } from './VectorArtCanvas'; @@ -14,20 +15,16 @@ const INFO_WIDTH = 190; * a panel of its own is #191. */ export class VectorArtEditor extends AssetTabEditor { - /** Public for the check scripts: the two tools and the grid switch. */ + /** Public for the check scripts: the two tools and the magnet. */ readonly selectButton: Button; readonly penButton: Button; - readonly snapButton: Button; + readonly snap = new SnapToggle(() => this.canvas); constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') }); this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') }); - this.snapButton = new Button('Grid', { - toggle: true, - onClick: () => this.setSnap(!this.canvas.snapping), - }); - this.mount(new VectorArtCanvas(state), [this.selectButton, this.penButton, this.snapButton]); + this.mount(new VectorArtCanvas(state), [this.selectButton, this.penButton, ...this.snap.widgets]); this.setTool('select'); } @@ -37,11 +34,6 @@ export class VectorArtEditor extends AssetTabEditor ({ drawSvg: () => undefined, tintSvg: (svg: string) => svg })); + +beforeAll(() => { + CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; +}); + +function target() { + const calls: boolean[] = []; + return { calls, setSnapping: (on: boolean) => calls.push(on) }; +} + +describe('SnapToggle', () => { + it('is a square icon button without a caption', () => { + const snap = new SnapToggle(() => target()); + expect(snap.widgets).toEqual([snap.button]); + expect(snap.button.measure().w).toBe(Theme.controlHeight); + expect(snap.button.text).toBe(''); + }); + + it('starts off and switches the target on and off again, lit while on', () => { + const canvas = target(); + const snap = new SnapToggle(() => canvas); + expect(snap.snapping).toBe(false); + expect(snap.button.toggled).toBe(false); + + snap.button.onClick?.(); + expect(snap.snapping).toBe(true); + expect(snap.button.toggled).toBe(true); + + snap.button.onClick?.(); + expect(snap.snapping).toBe(false); + expect(snap.button.toggled).toBe(false); + expect(canvas.calls).toEqual([true, false]); + }); + + it('asks for the target at the click, so a canvas mounted later is the one switched', () => { + let canvas = target(); + const snap = new SnapToggle(() => canvas); + const later = target(); + canvas = later; + snap.set(true); + expect(later.calls).toEqual([true]); + }); +}); diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index a77156c6..9c632b1e 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -6,6 +6,7 @@ import { LIST_ICON_SVG, PLUGINS_ICON_SVG, } from '../../src/editor/panels/assetIcons'; +import { HEADER_ICON_SVG } from '../../src/editor/panels/headerIcons'; import { TOOLBAR_ICON_SVG } from '../../src/editor/panels/toolbarIcons'; import { tintSvg } from '../../src/ui/svg'; import { SYMBOL_SVG } from '../../src/viewport/symbolIcons'; @@ -41,8 +42,24 @@ describe('icon files', () => { ['game tab', GAME_ICON_SVG], ['plugins tab', PLUGINS_ICON_SVG], ...Object.entries(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, svg]), + ...Object.entries(HEADER_ICON_SVG).map(([name, svg]) => [`header ${name}`, svg]), ]; + // The magnet stands alone in the header, without the "Snap" it replaced (#194): a picture another + // button already wears would say that button's thing. + it('draws the snap switch as a magnet of its own, bent without arcs (#194)', () => { + const others = [ + ...Object.values(ASSET_ICON_SVG), + ...Object.values(TOOLBAR_ICON_SVG), + LIST_ICON_SVG, + GAME_ICON_SVG, + PLUGINS_ICON_SVG, + ]; + expect(others).not.toContain(HEADER_ICON_SVG.snap); + expect(HEADER_ICON_SVG.snap).toContain('C'); + for (const { attrs } of tags(HEADER_ICON_SVG.snap).slice(1)) expect(attrs.d ?? '').not.toMatch(/[Aa]/); + }); + it('has one icon per button of the top bar, each its own picture (#183)', () => { expect(Object.keys(TOOLBAR_ICON_SVG).sort()).toEqual(['menu', 'pause', 'play', 'redo', 'stop', 'undo']); expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(6); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 1eda95e2..87850d5b 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -444,14 +444,20 @@ export async function graph(t) { const GRID = 24; const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0; check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false); - const snapLabel = await locate('Snap'); - // The checkbox sits right of the "Snap" label; locate() gives the label's text centre. - await click(snapLabel.x + 30, snapLabel.y); + // The switch is a magnet (#194): a square icon button that stays lit while snapping is on. + const magnet = await json('__gm.graph.snapButton()'); + await click(magnet.x, magnet.y); check( 'the toggle switches it on without moving a single node', (await json('__gm.graph.snapping()')) === true && (await positions()) === before13i, await positions(), ); + const magnetOn = await json('__gm.graph.snapButton()'); + check( + 'the magnet is a square icon without a caption, and lit while on', + magnetOn.toggled && magnetOn.w === magnetOn.h && (await json(`__gm.locate('Snap')`)) === null, + JSON.stringify(magnetOn), + ); // A single node: dropped between two grid lines, it lands on the nearer one. await click(corner.x, corner.y); @@ -501,7 +507,7 @@ export async function graph(t) { check('undo takes both snapped moves back', (await positions()) === before13i, await positions()); // Switching it off has to give the pointer its exact drop position back. - await click(snapLabel.x + 30, snapLabel.y); + await click(magnet.x, magnet.y); await click(corner.x, corner.y); const offPre = await nodeOf('n-axis'); const offFrom = await json(`__gm.graph.nodeAt('n-axis')`); diff --git a/tools/e2e/26-state-machine.mjs b/tools/e2e/26-state-machine.mjs index ebfaf40a..971e7f39 100644 --- a/tools/e2e/26-state-machine.mjs +++ b/tools/e2e/26-state-machine.mjs @@ -106,16 +106,15 @@ export async function stateMachine(t) { ); await shot('01-two-states'); - // 26b2. The header's Snap checkbox is the graph's: on, a dragged state lands on the 24 px grid. + // 26b2. The header's magnet is the graph's: on, a dragged state lands on the 24 px grid. const GRID = 24; const stateXY = async (id) => (await states()).find((s) => s.id === id) ?? { x: NaN, y: NaN }; check('snapping is off until it is asked for', (await json('__gm.stateMachine.snapping()')) === false); - const snapLabel = await locate('Snap'); + const magnet = await json('__gm.stateMachine.snapButton()'); const preSnap = JSON.stringify(await states()); - // The checkbox sits right of the "Snap" label; locate() gives the label's text centre. - await click(snapLabel.x + 30, snapLabel.y); + await click(magnet.x, magnet.y); check( - 'the Snap toggle switches it on without moving a state', + 'the magnet switches it on without moving a state', (await json('__gm.stateMachine.snapping()')) === true && JSON.stringify(await states()) === preSnap, JSON.stringify(await states()), ); @@ -137,8 +136,8 @@ export async function stateMachine(t) { backAt.x === wasAt.x && backAt.y === wasAt.y, `${backAt.x},${backAt.y}`, ); - await click(snapLabel.x + 30, snapLabel.y); - check('the toggle switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false); + await click(magnet.x, magnet.y); + check('the magnet switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false); // 26c. The inspector renames the selected state; the node follows. const nameField = await json("__gm.inspectorField('Name', true)"); diff --git a/tools/e2e/36-graph-groups.mjs b/tools/e2e/36-graph-groups.mjs index a9a6714e..ed2f317e 100644 --- a/tools/e2e/36-graph-groups.mjs +++ b/tools/e2e/36-graph-groups.mjs @@ -163,9 +163,8 @@ export async function graphGroups(t) { // 36f2. With snapping on, the corner obeys the grid like a dragged node does – and it is the far // edge that lands on it, because the near one is exactly what a resize must not move. - const snapLabel = await locate('Snap'); - // The checkbox sits right of the "Snap" label; locate() gives the label's text centre. - await click(snapLabel.x + 30, snapLabel.y); + const magnet = await json('__gm.graph.snapButton()'); + await click(magnet.x, magnet.y); const held = await one('group'); const snapCorner = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)}, true)`); // 53 and 37 are deliberately ragged: a multiple of 24 afterwards cannot come from the gesture. @@ -181,7 +180,7 @@ export async function graphGroups(t) { snapped.w > held.w, JSON.stringify({ x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }), ); - await click(snapLabel.x + 30, snapLabel.y); + await click(magnet.x, magnet.y); // 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour: // the text flips to black on a light fill, which is what makes a free picker safe. diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 23892ce1..1dc7ac49 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -87,6 +87,7 @@ export async function vectorArt(t) { shot, showTab, typeText, + undo, } = t; const Y = LANES.vectorArt; @@ -225,6 +226,29 @@ export async function vectorArt(t) { JSON.stringify(moved), ); + // The magnet is the graph's (#194): the tab has no Grid button of its own any more, and with it + // on a ragged drop lands on the 8 px grid. Undone, so the point is back where the next steps expect it. + const magnet = await json('__gm.vectorArt.snapButton()'); + check( + 'the tab carries the magnet in place of a Grid button', + magnet.w === magnet.h && !magnet.toggled && (await json(`__gm.locate('Grid')`)) === null, + JSON.stringify(magnet), + ); + await click(magnet.x, magnet.y); + const snapFrom = await json(`__gm.vectorArt.pointAt(180, 40)`); + const snapTo = await json(`__gm.vectorArt.pointAt(187, 45)`); + await drag(snapFrom.x, snapFrom.y, snapTo.x, snapTo.y); + const onGrid = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1]; + check( + 'with the magnet on, a dragged point lands on the grid', + (await json('__gm.vectorArt.snapping()')) === true && onGrid.x === 184 && onGrid.y === 48, + JSON.stringify(onGrid), + ); + await shot('47-magnet'); + await undo(); + await click(magnet.x, magnet.y); + check('the magnet switches snapping off again', (await json('__gm.vectorArt.snapping()')) === false); + const pulled = await json(`__gm.vectorArt.pointAt(180, 40)`); await click(pulled.x, pulled.y, 'left', { alt: true }); const smooth = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];