Put new graph nodes, comments and groups on the grid (#439)
feuerwerk / gate (push) Successful in 6m13s
feuerwerk / suite (push) Failing after 25m55s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-31 00:13:09 +01:00
1 parent beb95c17a7
commit dc6ab17df9
4 files changed
+185 -7

No files matched your search

+41
View File
@@ -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<Box, 'id'>,
step: number,
min: { w: number; h: number },
): Omit<Box, 'id'> {
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<Box, 'id'>, step: number): Omit<Box, 'id'> {
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 { export interface Band {
x1: number; x1: number;
y1: number; y1: number;
+33 -7
View File
@@ -13,6 +13,7 @@ import {
edgesOutOf, edgesOutOf,
FUNCTION_PARAM, FUNCTION_PARAM,
ghostProblem, ghostProblem,
MIN_ANNOTATION_SIZE,
type Graph, type Graph,
type GraphNode, type GraphNode,
type PortDef, type PortDef,
@@ -67,7 +68,16 @@ import {
nodeSelection, nodeSelection,
} from '../state'; } from '../state';
import { canPasteGraph, copySelection, pasteGraph } from '../clipboard'; 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 { import {
ANNOTATION_TITLE_HEIGHT, ANNOTATION_TITLE_HEIGHT,
annotationBoxes, annotationBoxes,
@@ -87,7 +97,7 @@ import { splitEntries } from './splitMenu';
import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView'; import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
import type { NodeFace } from './nodeFace'; 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 { GRAPH_SNAPS_AT_FIRST } from './SnapToggle';
import { arrangeCommand } from './graphLayout'; import { arrangeCommand } from './graphLayout';
import { measuredNodes } from './measureNodes'; import { measuredNodes } from './measureNodes';
@@ -466,11 +476,25 @@ export class GraphCanvas extends Widget {
if (command) this.state.execute(command); 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 { groupSelectedNodes(): void {
const ids = new Set(this.state.selectedNodeIds); const ids = new Set(this.state.selectedNodeIds);
const box = groupBoxAround(this.nodeBoxes().filter((b) => ids.has(b.id))); 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', label: 'Add group',
hint: 'box', hint: 'box',
match: 'group box frame', match: 'group box frame',
onSelect: () => this.addAnnotation('group', { ...at, ...NEW_GROUP_SIZE }, 'Group'), onSelect: () => this.addNewAnnotation('group', at, 'Group'),
}, },
{ {
id: 'add-comment', id: 'add-comment',
label: 'Add comment', label: 'Add comment',
hint: 'text', hint: 'text',
match: 'comment note 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) : []; 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 { 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 { private addNode(entry: PaletteEntry, at: { x: number; y: number }): void {
+51
View File
@@ -7,6 +7,9 @@ import {
frameBoxes, frameBoxes,
resizeSize, resizeSize,
revealBoxes, revealBoxes,
snapBoxOutward,
snapNewBox,
snapPoint,
} from '../../src/editor/framing'; } from '../../src/editor/framing';
const LIMITS = { min: 0.25, max: 2.5 }; 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', () => { describe('fit', () => {
/** Where a graph point lands on screen under a framing. */ /** Where a graph point lands on screen under a framing. */
const toScreen = (f: { zoom: number; panX: number; panY: number }, x: number, y: number) => ({ const toScreen = (f: { zoom: number; panX: number; panY: number }, x: number, y: number) => ({
+60
View File
@@ -180,6 +180,66 @@ export async function graphGroups(t) {
snapped.w > held.w, snapped.w > held.w,
JSON.stringify({ x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }), 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); await click(magnet.x, magnet.y);
// 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour: // 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour: