From dc6ab17df9c6063f8492c2468e33919ea7f715d9 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 31 Oct 2026 00:13:09 +0100 Subject: [PATCH] Put new graph nodes, comments and groups on the grid (#439) With the magnet on, only boxes dragged by hand landed on the grid. Anything new stayed where it was dropped and had to be nudged. - A node from the palette, from a dropped link or from a dragged behavior snaps its corner to the nearest grid point. All three ways go through newNode. - A comment or group from the palette snaps corner and size to whole grid steps and keeps its minimum size. A new group is now 312 x 192, a new comment 216 x 96. - "Group these N nodes" moves every edge outward to the next grid line, so the margin around the nodes only grows. - The two helpers snapNewBox and snapBoxOutward live in framing.ts. - Without the magnet nothing changes. Tests: - graph-framing 36/36, 7 new. Two broken variants (rounding instead of outward, missing minimum size) both red. - e2e 36 ALL PASSED, 21 checks, new 36f3. Against the old build exactly the two new checks were red. - Gate on atx: 339 files, 5261 tests. Co-Authored-By: Claude Opus 5.5 --- src/editor/framing.ts | 41 ++++++++++++++++++++ src/editor/graph/GraphCanvas.ts | 40 ++++++++++++++++---- tests/editor/graph-framing.test.ts | 51 +++++++++++++++++++++++++ tools/e2e/36-graph-groups.mjs | 60 ++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 7 deletions(-) diff --git a/src/editor/framing.ts b/src/editor/framing.ts index 5b46108f..c28a7a27 100644 --- a/src/editor/framing.ts +++ b/src/editor/framing.ts @@ -55,6 +55,47 @@ export function resizeSize( }; } +/** A point put on the nearest grid point – where a node made with snapping on lands. */ +export function snapPoint(p: Point, step: number): Point { + if (!(step > 0)) return { x: Math.round(p.x), y: Math.round(p.y) }; + return { x: snapToStep(p.x, step), y: snapToStep(p.y, step) }; +} + +/** + * A group or comment just made, with snapping on: its corner on the nearest grid point, its size + * rounded to whole steps but never under `min`, so the far corner lands on the grid as well. + */ +export function snapNewBox( + box: Omit, + step: number, + min: { w: number; h: number }, +): Omit { + if (!(step > 0)) return box; + const { x, y } = snapPoint(box, step); + return { + x, + y, + w: growToMin(Math.max(step, snapToStep(box.w, step)), min.w, step), + h: growToMin(Math.max(step, snapToStep(box.h, step)), min.h, step), + }; +} + +/** + * A box drawn around things, put on the grid by growing it: each edge moves out to the next grid + * line, so it never comes closer to what it holds than it did. + */ +export function snapBoxOutward(box: Omit, step: number): Omit { + if (!(step > 0)) return box; + const x = Math.floor(box.x / step) * step; + const y = Math.floor(box.y / step) * step; + return { + x, + y, + w: Math.ceil((box.x + box.w) / step) * step - x, + h: Math.ceil((box.y + box.h) / step) * step - y, + }; +} + export interface Band { x1: number; y1: number; diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index c6a04f4b..5142f1fb 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -13,6 +13,7 @@ import { edgesOutOf, FUNCTION_PARAM, ghostProblem, + MIN_ANNOTATION_SIZE, type Graph, type GraphNode, type PortDef, @@ -67,7 +68,16 @@ import { nodeSelection, } from '../state'; import { canPasteGraph, copySelection, pasteGraph } from '../clipboard'; -import { type Box, boxesInBand, boxesWithinBand, frameBoxes, revealBoxes } from '../framing'; +import { + type Box, + boxesInBand, + boxesWithinBand, + frameBoxes, + revealBoxes, + snapBoxOutward, + snapNewBox, + snapPoint, +} from '../framing'; import { ANNOTATION_TITLE_HEIGHT, annotationBoxes, @@ -87,7 +97,7 @@ import { splitEntries } from './splitMenu'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView'; import type { NodeFace } from './nodeFace'; -import { drawGraphGrid, FIT_MARGIN, inView, ZOOM_LIMITS, zoomedAbout } from './graphView'; +import { drawGraphGrid, FIT_MARGIN, GRID_STEP, inView, ZOOM_LIMITS, zoomedAbout } from './graphView'; import { GRAPH_SNAPS_AT_FIRST } from './SnapToggle'; import { arrangeCommand } from './graphLayout'; import { measuredNodes } from './measureNodes'; @@ -466,11 +476,25 @@ export class GraphCanvas extends Widget { if (command) this.state.execute(command); } - /** A group drawn around the selected nodes, with room for the title bar above them. */ + /** + * A group drawn around the selected nodes, with room for the title bar above them. With snapping + * on its edges move out to the grid, so the room around the nodes only grows. + */ groupSelectedNodes(): void { const ids = new Set(this.state.selectedNodeIds); const box = groupBoxAround(this.nodeBoxes().filter((b) => ids.has(b.id))); - if (box) this.addAnnotation('group', box, 'Group'); + if (box) this.addAnnotation('group', snapBoxOutward(box, this.gridStep()), 'Group'); + } + + /** The grid a new node or box lands on: none with snapping off. */ + private gridStep(): number { + return this.snapping ? GRID_STEP : 0; + } + + /** A group or comment from the palette: where it was opened, on the grid with snapping on. */ + private addNewAnnotation(kind: AnnotationKind, at: { x: number; y: number }, text: string): void { + const size = kind === 'group' ? NEW_GROUP_SIZE : NEW_NOTE_SIZE; + this.addAnnotation(kind, snapNewBox({ ...at, ...size }, this.gridStep(), MIN_ANNOTATION_SIZE), text); } /** @@ -992,14 +1016,14 @@ export class GraphCanvas extends Widget { label: 'Add group', hint: 'box', match: 'group box frame', - onSelect: () => this.addAnnotation('group', { ...at, ...NEW_GROUP_SIZE }, 'Group'), + onSelect: () => this.addNewAnnotation('group', at, 'Group'), }, { id: 'add-comment', label: 'Add comment', hint: 'text', match: 'comment note text', - onSelect: () => this.addAnnotation('note', { ...at, ...NEW_NOTE_SIZE }, 'Comment'), + onSelect: () => this.addNewAnnotation('note', at, 'Comment'), }, ]; } @@ -1009,8 +1033,10 @@ export class GraphCanvas extends Widget { return asset ? paletteEntries(this.state.project, asset) : []; } + /** A node at `at`, its corner on the nearest grid point with snapping on – as a dragged one lands. */ private newNode(entry: PaletteEntry, at: { x: number; y: number }): AddNodeCommand { - return new AddNodeCommand(this.assetId, entry.def.type, Math.round(at.x), Math.round(at.y), entry.params); + const { x, y } = snapPoint(at, this.gridStep()); + return new AddNodeCommand(this.assetId, entry.def.type, x, y, entry.params); } private addNode(entry: PaletteEntry, at: { x: number; y: number }): void { diff --git a/tests/editor/graph-framing.test.ts b/tests/editor/graph-framing.test.ts index 1354dba5..1bf000a6 100644 --- a/tests/editor/graph-framing.test.ts +++ b/tests/editor/graph-framing.test.ts @@ -7,6 +7,9 @@ import { frameBoxes, resizeSize, revealBoxes, + snapBoxOutward, + snapNewBox, + snapPoint, } from '../../src/editor/framing'; const LIMITS = { min: 0.25, max: 2.5 }; @@ -100,6 +103,54 @@ describe('resize size', () => { }); }); +describe('made on the grid', () => { + const STEP = 24; + const MIN = { w: 80, h: 48 }; + + it('puts a new node on the nearest grid point, and only rounds it without snapping', () => { + expect(snapPoint({ x: 37.4, y: 11.6 }, STEP)).toEqual({ x: 48, y: 0 }); + expect(snapPoint({ x: 37.4, y: 11.6 }, 0)).toEqual({ x: 37, y: 12 }); + }); + + it('puts a new group on the grid, corner and size alike', () => { + // The palette's group is 320 × 200: neither is a whole number of steps. + expect(snapNewBox({ x: 37, y: 13, w: 320, h: 200 }, STEP, MIN)).toEqual({ x: 48, y: 24, w: 312, h: 192 }); + }); + + it('puts a new comment on the grid', () => { + expect(snapNewBox({ x: -13, y: 100, w: 220, h: 96 }, STEP, MIN)).toEqual({ + x: -24, + y: 96, + w: 216, + h: 96, + }); + }); + + it('keeps a new box at its minimum, in whole steps', () => { + expect(snapNewBox({ x: 0, y: 0, w: 70, h: 30 }, STEP, MIN)).toEqual({ x: 0, y: 0, w: 96, h: 48 }); + }); + + it('leaves a new box as it is without snapping', () => { + const box = { x: 37, y: 13, w: 320, h: 200 }; + expect(snapNewBox(box, 0, MIN)).toEqual(box); + }); + + it('grows a group around nodes out to the grid, so no edge comes closer to them', () => { + const around = { x: 28, y: 52, w: 240, h: 144 }; + const snapped = snapBoxOutward(around, STEP); + expect(snapped).toEqual({ x: 24, y: 48, w: 264, h: 168 }); + expect(snapped.x + snapped.w).toBeGreaterThanOrEqual(around.x + around.w); + expect(snapped.y + snapped.h).toBeGreaterThanOrEqual(around.y + around.h); + }); + + it('leaves a group around nodes alone when it is on the grid already, or snapping is off', () => { + const onGrid = { x: -24, y: 48, w: 240, h: 96 }; + expect(snapBoxOutward(onGrid, STEP)).toEqual(onGrid); + const off = { x: 28, y: 52, w: 240, h: 144 }; + expect(snapBoxOutward(off, 0)).toEqual(off); + }); +}); + 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/tools/e2e/36-graph-groups.mjs b/tools/e2e/36-graph-groups.mjs index e8da0c20..636d325c 100644 --- a/tools/e2e/36-graph-groups.mjs +++ b/tools/e2e/36-graph-groups.mjs @@ -180,6 +180,66 @@ export async function graphGroups(t) { snapped.w > held.w, JSON.stringify({ x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }), ); + + // 36f3. With snapping on, what is made lands on the grid straight away: a comment, a group and a + // node from the palette, and a group around a selection – size and all. The comment from + // 36b moves aside first (36j moves it anyway), so the free spot is free of it too. + await mutate(` + const g = __gm.state.project.assets.find((a) => a.id === 'gg-logic').graph; + Object.assign(g.notes[0], { x: -700, y: -500 });`); + const onGrid = (b) => b !== null && [b.x, b.y, b.w ?? 0, b.h ?? 0].every((v) => v % 24 === 0); + const pickedBox = async () => { + const id = (await json('__gm.state.selectedAnnotationIds ?? []'))[0]; + return (await boxes()).find((a) => a.id === id) ?? null; + }; + const madeAt = await json('__gm.graph.emptySpot()'); + await click(madeAt.x, madeAt.y, 'right'); + await clickText('Add comment'); + const snappedNote = await pickedBox(); + await t.undo(); + await click(madeAt.x, madeAt.y, 'right'); + await clickText('Add group'); + const snappedGroup = await pickedBox(); + await t.undo(); + await click(madeAt.x, madeAt.y, 'right'); + await typeText('self'); + await press('Enter'); + const madeNode = await nodeAt((await json('__gm.state.selectedNodeIds ?? []'))[0] ?? ''); + await t.undo(); + check( + 'with snapping on a comment, a group and a node from the palette land on the grid, sizes too', + onGrid(snappedNote) && + snappedNote.kind === 'note' && + snappedNote.w === 216 && + snappedNote.h === 96 && + onGrid(snappedGroup) && + snappedGroup.kind === 'group' && + snappedGroup.w === 312 && + snappedGroup.h === 192 && + onGrid(madeNode), + JSON.stringify({ note: snappedNote, group: snappedGroup, node: madeNode }), + ); + await evaluate( + "__gm.state.select({ kind: 'node', assetId: 'gg-logic', nodeIds: ['gg-start', 'gg-self'] })", + ); + const startAgain = await json('__gm.graph.nodeAt("gg-start")'); + await click(startAgain.x, startAgain.y, 'right'); + await clickText('Group these 2 nodes'); + const around = await pickedBox(); + await t.undo(); + // `group` is the box 36c drew around the same two nodes with snapping off: the snapped one holds it. + check( + 'with snapping on a group around a selection lands on the grid and only grows its room', + onGrid(around) && + around.kind === 'group' && + around.x <= group.x && + around.y <= group.y && + around.x + around.w >= group.x + group.w && + around.y + around.h >= group.y + group.h && + around.x > group.x - 24 && + around.y > group.y - 24, + JSON.stringify({ around, unsnapped: { x: group.x, y: group.y, w: group.w, h: group.h } }), + ); await click(magnet.x, magnet.y); // 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour: