diff --git a/README.md b/README.md index c75f9657..e6df06a2 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,11 @@ shown on the node and editable in the inspector. **Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody finds), **Fit** (which zooms out far enough to hold every node, measuring each by its own drawn height – `editor/framing.ts`), and the size of the graph as "N nodes · M links". +- **Snap** (header checkbox, off by default): a drag drops each node it carries on the nearest + 24 px grid line – the same grid the background draws. It takes effect on the next drag only, so + switching it on never shuffles a graph that is already laid out, and a node nobody drags never + moves. Every node of a selection snaps **for itself**, so dragging a group is how you line one + up; the offset is a decision about rectangles and lives with the others in `editor/framing.ts`. - **Selection-following, not selection-bound**: selecting a behavior asset, a placed behavior or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at all) leaves the tab on the behavior you were editing, because checking a sprite's position @@ -599,7 +604,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru # code is lost (tail's is reported), so a red run reads as green npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # optional: run the browser elsewhere; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 483 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 491 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index e7ede923..acca6066 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -204,6 +204,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E // drawing area. center: () => centreOf(graphEditor.canvas), pickerWidth: () => graphEditor.pickerWidth, + snapping: () => graphEditor.canvas.snapEnabled, }, /** The open image picker: what it offers, what is picked, and where a tile sits. */ imagePicker: () => currentImagePicker()?.debugInfo() ?? null, diff --git a/src/editor/framing.ts b/src/editor/framing.ts index 45edf391..20844d5e 100644 --- a/src/editor/framing.ts +++ b/src/editor/framing.ts @@ -1,9 +1,30 @@ /** - * Geometry of the graph view: which nodes a rubber band caught, and what view frames them all. - * Both are decisions about rectangles, so they live here rather than in the canvas – the "fit" - * that left nodes hanging out of the view was a sum that no test could reach. + * Geometry of the graph view: which nodes a rubber band caught, what view frames them all, and + * where a drag puts them. All three are decisions about rectangles, so they live here rather than + * in the canvas – the "fit" that left nodes hanging out of the view was a sum that no test could + * reach. */ +import { snapToStep } from '../ui/math'; + +export interface Point { + x: number; + y: number; +} + +/** + * The offset a drag adds to one node that started at `start`. A positive `step` lands that node on + * the grid – applied per node, so a whole selection lines up instead of carrying its old spacing + * along. `step <= 0` is "no snapping". A node nobody drags never moves either way. + */ +export function dragOffset(start: Point, dx: number, dy: number, step: number): Point { + if (!(step > 0)) return { x: Math.round(dx), y: Math.round(dy) }; + return { + x: snapToStep(start.x + dx, step) - start.x, + y: snapToStep(start.y + dy, step) - start.y, + }; +} + export interface Box { id: string; x: number; diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index a8302060..806a84d9 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -60,11 +60,12 @@ import { foldMarkLabel, foldMarkRadius, } from './folding'; -import { type Box, boxesInBand, frameBoxes } from '../framing'; +import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing'; import { NodePalette } from './NodePalette'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView'; +/** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */ const GRID_STEP = 24; /** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */ const MIN_GRID_STEP = 8; @@ -126,6 +127,7 @@ export class GraphCanvas extends Widget { pointer: { x: number; y: number }; moved: boolean; } | null = null; + private snapping = false; /** A rubber-band selection in progress, in graph coordinates. */ private marquee: { start: { x: number; y: number }; @@ -199,6 +201,15 @@ export class GraphCanvas extends Widget { this.fitView(); } + /** Takes effect on the next drag only – turning it on must not shuffle the graph. */ + setSnapping(on: boolean): void { + this.snapping = on; + } + + get snapEnabled(): boolean { + return this.snapping; + } + /** Drops the node in the middle of the view, since a button press names no position. */ addNodeFrom(anchor: Widget): void { if (!this.assetId || this.state.mode !== 'edit') return; @@ -416,11 +427,13 @@ export class GraphCanvas extends Widget { this.dragging.moved = true; const graph = this.asset()?.graph; if (!graph) return; + const step = this.snapping ? GRID_STEP : 0; for (const item of this.dragging.items) { const node = graph.nodes.find((n) => n.id === item.nodeId); if (!node) continue; - node.x = Math.round(item.start.x + dx); - node.y = Math.round(item.start.y + dy); + const offset = dragOffset(item.start, dx, dy, step); + node.x = item.start.x + offset.x; + node.y = item.start.y + offset.y; } this.state.preview(); } else if (this.connecting) { diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 271cb777..bbf6c1cd 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -1,5 +1,5 @@ import type { Asset, BehaviorAsset } from '../../core/model/Asset'; -import { Button } from '../../ui'; +import { Button, Checkbox, Label } from '../../ui'; import { AssetTabEditor } from '../AssetTabEditor'; import type { EditorState } from '../state'; import { GraphCanvas } from './GraphCanvas'; @@ -9,11 +9,13 @@ const INFO_WIDTH = 150; export class GraphEditor extends AssetTabEditor { private readonly addButton: Button; + private readonly snapBox = new Checkbox(); constructor(state: EditorState) { super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH }); this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) }); - this.mount(new GraphCanvas(state), [this.addButton]); + this.snapBox.onChange = (on) => this.canvas.setSnapping(on); + this.mount(new GraphCanvas(state), [this.addButton, new Label('Snap', { style: 'muted' }), this.snapBox]); // Adding nodes is an edit, and edits made while playing would be thrown away with the run. state.on('mode', () => this.refresh()); } @@ -33,6 +35,8 @@ export class GraphEditor extends AssetTabEditor { } protected override syncExtras(asset: BehaviorAsset | null): void { - this.addButton.setEnabled(Boolean(asset) && this.state.mode === 'edit'); + const editable = Boolean(asset) && this.state.mode === 'edit'; + this.addButton.setEnabled(editable); + this.snapBox.setEnabled(editable); } } diff --git a/tests/editor/graph-framing.test.ts b/tests/editor/graph-framing.test.ts index 10702e1c..51efd867 100644 --- a/tests/editor/graph-framing.test.ts +++ b/tests/editor/graph-framing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { boundsOf, type Box, boxesInBand, frameBoxes } from '../../src/editor/framing'; +import { boundsOf, type Box, boxesInBand, dragOffset, frameBoxes } from '../../src/editor/framing'; const LIMITS = { min: 0.25, max: 2.5 }; @@ -28,6 +28,39 @@ describe('rubber band', () => { }); }); +describe('drag offset', () => { + const STEP = 24; + + it('rounds to whole pixels without snapping', () => { + expect(dragOffset({ x: 10, y: 10 }, 12.4, -7.6, 0)).toEqual({ x: 12, y: -8 }); + }); + + it('puts a dragged node on the grid, wherever it started', () => { + // Started off-grid at 10: the drop is a multiple of 24, not 10 plus a multiple. + const off = dragOffset({ x: 10, y: 10 }, 50, 50, STEP); + expect({ x: 10 + off.x, y: 10 + off.y }).toEqual({ x: 72, y: 72 }); + }); + + it('gives two differently placed nodes different offsets, so both land on the grid', () => { + // The same gesture over a selection: 10 and 17 are 7 apart and both end up on a grid line. + const a = dragOffset({ x: 10, y: 0 }, 50, 0, STEP); + const b = dragOffset({ x: 17, y: 0 }, 50, 0, STEP); + expect(a.x).not.toBe(b.x); + expect(10 + a.x).toBe(72); + expect(17 + b.x).toBe(72); + }); + + it('takes the nearest grid line, in both directions', () => { + expect(dragOffset({ x: 0, y: 0 }, 11, 13, STEP)).toEqual({ x: 0, y: 24 }); + expect(dragOffset({ x: 0, y: 0 }, -11, -13, STEP)).toEqual({ x: 0, y: -24 }); + }); + + it('still moves a node that starts on the grid', () => { + // A snapped drag shorter than half a step is the one case that could deadlock into "no move". + expect(dragOffset({ x: 48, y: 48 }, 20, 4, STEP)).toEqual({ x: 24, y: 0 }); + }); +}); + describe('fit', () => { /** Where a graph point lands on screen under a framing. */ const toScreen = (f: { zoom: number; panX: number; panY: number }, x: number, y: number) => ({ diff --git a/todo-notes/done/graph-snap-grid.md b/todo-notes/done/graph-snap-grid.md new file mode 100644 index 00000000..7bcb0c76 --- /dev/null +++ b/todo-notes/done/graph-snap-grid.md @@ -0,0 +1,19 @@ +beim graph bräuchte ich einen toggle der grid snapping aktiviert. +alle vorhandenen nodes soll da bleiben wo sie sind, erst wenn man ein node bewegt soll es greifen +die grid-größe kannst du selbst entscheiden, am besten ein sinnvoller wert + +--- + +Gebaut. Zwei Dinge, die weder oben stehen noch aus dem Commit hervorgehen: + +**Die Rastergröße ist 24 px – dieselbe Zahl, die den Hintergrund zeichnet** (`GRID_STEP`). Es gibt +also keinen zweiten, unsichtbaren Raster: was einrastet, rastet auf einer Linie ein, die man sieht. +Bei starkem Herauszoomen blendet das Raster aus (`MIN_GRID_STEP`), der Snap-Schritt bleibt aber +derselbe – er hängt an Weltkoordinaten, nicht am Zoom. + +**Bei Mehrfachauswahl richtet sich jeder Node für sich aus** (Nachtrag des Users während der +Arbeit). Die erste Fassung verschob die Auswahl um *einen* gemeinsamen Versatz, was ihre alten, +krummen Abstände mitschleppte: nur der Ankerknoten landete auf einer Linie. Jetzt ist `dragOffset` +pro Node definiert – jeder bekommt seinen eigenen Versatz, die Auswahl fluchtet danach wirklich. +Der Preis ist bewusst: die relativen Abstände innerhalb der Auswahl können sich um bis zu einen +halben Schritt ändern. diff --git a/todo-notes/graph-snap-grid.md b/todo-notes/graph-snap-grid.md deleted file mode 100644 index 71de8a02..00000000 --- a/todo-notes/graph-snap-grid.md +++ /dev/null @@ -1,3 +0,0 @@ -beim graph bräuchte ich einen toggle der grid snapping aktiviert. -alle vorhandenen nodes soll da bleiben wo sie sind, erst wenn man ein node bewegt soll es greifen -die grid-größe kannst du selbst entscheiden, am besten ein sinnvoller wert diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index ff5459fc..8f22239b 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -389,6 +389,86 @@ export async function graph(t) { await undo(); check('one undo takes the group move back', (await positions()) === before13i, await positions()); + // 13k2. Grid snapping. The graph keeps a zoom of 1 here (13k proved it with an exact +40/+30), + // so a screen delta is a graph delta. + 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); + await sleep(150); + check( + 'the toggle switches it on without moving a single node', + (await json('__gm.graph.snapping()')) === true && (await positions()) === before13i, + await positions(), + ); + + // A single node: dropped between two grid lines, it lands on the nearer one. + await click(corner.x, corner.y); + await sleep(150); + const snapPre = await nodeOf('n-axis'); + const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`); + await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50); + const snapped = await nodeOf('n-axis'); + check( + 'a dragged node lands on the grid, not where the pointer let go', + onGrid(snapped) && + Math.abs(snapped.x - (snapPre.x + 50)) <= GRID / 2 && + Math.abs(snapped.y - (snapPre.y + 50)) <= GRID / 2, + `${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`, + ); + const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis'); + check( + 'the nodes it was dragged past stay off the grid', + others.some((n) => !onGrid(n)), + JSON.stringify(others.map((n) => `${n.id}:${n.x},${n.y}`)), + ); + + // A group: every selected node snaps for itself, so the whole selection ends up on grid lines + // – off-grid gaps inside it close, rather than travelling along. + await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); + const groupPre = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y })); + check( + 'the selection is not on the grid yet', + groupPre.some((n) => !onGrid(n)), + JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)), + ); + const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`); + await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30); + const groupPost = (await asset('mover')).graph.nodes; + check( + 'every selected node lines up on the grid for itself, none by more than half a step', + groupPost.every(onGrid) && + groupPre.every((n) => { + const now = groupPost.find((m) => m.id === n.id); + return Math.abs(now.x - (n.x + 30)) <= GRID / 2 && Math.abs(now.y - (n.y + 30)) <= GRID / 2; + }), + await positions(), + ); + await undo(); + await undo(); + 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 sleep(150); + await click(corner.x, corner.y); + await sleep(150); + const offPre = await nodeOf('n-axis'); + const offFrom = await json(`__gm.graph.nodeAt('n-axis')`); + await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50); + const offPost = await nodeOf('n-axis'); + check( + 'switched off, a node lands exactly where it was dropped again', + (await json('__gm.graph.snapping()')) === false && + offPost.x === offPre.x + 50 && + offPost.y === offPre.y + 50, + `${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`, + ); + await undo(); + await sleep(150); + // 13l. Delete acts on the selection, not just the primary node. await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); const beforeDelete = await graphCounts();