From 82556f02a90d19850a973fbd8f3ad7b199c5ab81 Mon Sep 17 00:00:00 2001 From: woctordho Date: Sat, 14 Mar 2026 02:40:51 +0800 Subject: [PATCH] fix: respect 'always snap to grid' when auto-scale layout from nodes 1.0 to 2.0 (#9332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Previously when I switch from nodes 1.0 to 2.0, positions and sizes of nodes do not follow 'always snap to grid'. You can guess what a mess it is for people relying on snap to grid to retain sanity. This PR fixes it. ## Changes In `ensureCorrectLayoutScale`, we call `snapPoint` after the position and the size are updated. We also need to ensure that the snapped size is larger than the minimal size required by the content, so I've added 'ceil' mode to `snapPoint`, and the patch is larger than I thought first. I'd happily try out nodes 2.0 once this is addressed :) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9332-fix-respect-always-snap-to-grid-when-auto-scale-layout-from-nodes-1-0-to-2-0-3176d73d365081f5b6bcc035a8ffa648) by [Unito](https://www.unito.io) --- src/lib/litegraph/src/LGraph.ts | 16 ++++++++-- src/lib/litegraph/src/LGraphCanvas.ts | 5 +++ src/lib/litegraph/src/measure.test.ts | 28 ++++++++++++++++ src/lib/litegraph/src/measure.ts | 10 ++++-- .../vueNodes/composables/useNodeSnap.ts | 2 +- .../layout/ensureCorrectLayoutScale.ts | 32 +++++++++++++++++-- src/scripts/app.ts | 5 +++ 7 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index eafefbeba5..c768e54f12 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -45,7 +45,8 @@ import { LiteGraph, SubgraphNode } from './litegraph' import { alignOutsideContainer, alignToContainer, - createBounds + createBounds, + snapPoint } from './measure' import { SubgraphInput } from './subgraph/SubgraphInput' import { SubgraphInputNode } from './subgraph/SubgraphInputNode' @@ -2594,7 +2595,18 @@ export class LGraph // configure nodes afterwards so they can reach each other for (const [id, nodeData] of nodeDataMap) { - this.getNodeById(id)?.configure(nodeData) + const node = this.getNodeById(id) + node?.configure(nodeData) + + if (LiteGraph.alwaysSnapToGrid && node) { + const snapTo = this.getSnapToGridSize() + if (node.snapToGrid(snapTo)) { + // snapToGrid mutates the internal _pos array in-place, bypassing the setter + // This reassignment triggers the pos setter to sync to the Vue layout store + node.pos = [node.pos[0], node.pos[1]] + } + snapPoint(node.size, snapTo, 'ceil') + } } } diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index d52475a4ed..9815f36cd8 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -2959,6 +2959,11 @@ export class LGraphCanvas implements CustomEventDispatcher // Enforce minimum size const min = node.computeSize() + if (this._snapToGrid) { + // Previously newBounds.size is snapped with 'round' + // Now the minimum size is snapped with 'ceil' to avoid clipping + snapPoint(min, this._snapToGrid, 'ceil') + } if (newBounds.width < min[0]) { // If resizing from left, adjust position to maintain right edge if (resizeDirection.includes('W')) { diff --git a/src/lib/litegraph/src/measure.test.ts b/src/lib/litegraph/src/measure.test.ts index dc588a09f4..f642c0c7a9 100644 --- a/src/lib/litegraph/src/measure.test.ts +++ b/src/lib/litegraph/src/measure.test.ts @@ -130,6 +130,34 @@ test('snapPoint correctly snaps points to grid', ({ expect }) => { expect(point3).toEqual([20, 20]) }) +test('snapPoint correctly snaps points to grid using ceil', ({ expect }) => { + const point: Point = [12.3, 18.7] + expect(snapPoint(point, 5, 'ceil')).toBe(true) + expect(point).toEqual([15, 20]) + + const point2: Point = [15, 20] + expect(snapPoint(point2, 5, 'ceil')).toBe(true) + expect(point2).toEqual([15, 20]) + + const point3: Point = [15.1, -18.7] + expect(snapPoint(point3, 10, 'ceil')).toBe(true) + expect(point3).toEqual([20, -10]) +}) + +test('snapPoint correctly snaps points to grid using floor', ({ expect }) => { + const point: Point = [12.3, 18.7] + expect(snapPoint(point, 5, 'floor')).toBe(true) + expect(point).toEqual([10, 15]) + + const point2: Point = [15, 20] + expect(snapPoint(point2, 5, 'floor')).toBe(true) + expect(point2).toEqual([15, 20]) + + const point3: Point = [15.1, -18.7] + expect(snapPoint(point3, 10, 'floor')).toBe(true) + expect(point3).toEqual([10, -20]) +}) + test('createBounds correctly creates bounding box', ({ expect }) => { const objects = [ { boundingRect: [0, 0, 10, 10] as Rect }, diff --git a/src/lib/litegraph/src/measure.ts b/src/lib/litegraph/src/measure.ts index d6d81c83ab..08ce8a50bb 100644 --- a/src/lib/litegraph/src/measure.ts +++ b/src/lib/litegraph/src/measure.ts @@ -351,11 +351,15 @@ export function createBounds( * @returns `true` if snapTo is truthy, otherwise `false` * @remarks `NaN` propagates through this function and does not affect return value. */ -export function snapPoint(pos: Point | Rect, snapTo: number): boolean { +export function snapPoint( + pos: Point | Rect, + snapTo: number, + method: 'round' | 'ceil' | 'floor' = 'round' +): boolean { if (!snapTo) return false - pos[0] = snapTo * Math.round(pos[0] / snapTo) - pos[1] = snapTo * Math.round(pos[1] / snapTo) + pos[0] = snapTo * Math[method](pos[0] / snapTo) + pos[1] = snapTo * Math[method](pos[1] / snapTo) return true } diff --git a/src/renderer/extensions/vueNodes/composables/useNodeSnap.ts b/src/renderer/extensions/vueNodes/composables/useNodeSnap.ts index 4b07621bed..03476156ee 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodeSnap.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodeSnap.ts @@ -57,7 +57,7 @@ export function useNodeSnap() { if (!gridSizeValue) return { ...size } const sizeArray: [number, number] = [size.width, size.height] - if (snapPoint(sizeArray, gridSizeValue)) { + if (snapPoint(sizeArray, gridSizeValue, 'ceil')) { return { width: sizeArray[0], height: sizeArray[1] } } return { ...size } diff --git a/src/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale.ts b/src/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale.ts index ef4d353a67..2584b7e1bb 100644 --- a/src/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale.ts +++ b/src/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale.ts @@ -1,4 +1,6 @@ import type { LGraph, RendererType } from '@/lib/litegraph/src/LGraph' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import { snapPoint } from '@/lib/litegraph/src/measure' import type { Point as LGPoint } from '@/lib/litegraph/src/interfaces' import type { Point } from '@/renderer/core/layout/types' import { @@ -15,7 +17,7 @@ interface Positioned { size: LGPoint } -function unprojectPosSize(item: Positioned, anchor: Point) { +function unprojectPosSize(item: Positioned, anchor: Point, graph: LGraph) { const c = unprojectBounds( { x: item.pos[0], @@ -30,6 +32,14 @@ function unprojectPosSize(item: Positioned, anchor: Point) { item.pos[1] = c.y item.size[0] = c.width item.size[1] = c.height + + if (LiteGraph.alwaysSnapToGrid) { + const snapTo = graph.getSnapToGridSize?.() + if (snapTo) { + snapPoint(item.pos, snapTo, 'round') + snapPoint(item.size, snapTo, 'ceil') + } + } } /** @@ -60,6 +70,18 @@ export function ensureCorrectLayoutScale( const anchor = getGraphRenderAnchor(graph) + const applySnap = ( + pos: [number, number], + method: 'round' | 'ceil' | 'floor' = 'round' + ) => { + if (LiteGraph.alwaysSnapToGrid) { + const snapTo = graph.getSnapToGridSize?.() + if (snapTo) { + snapPoint(pos, snapTo, method) + } + } + } + for (const node of graph.nodes) { const c = unprojectBounds( { @@ -75,6 +97,9 @@ export function ensureCorrectLayoutScale( node.pos[1] = c.y node.size[0] = c.width node.size[1] = c.height + + applySnap(node.pos) + applySnap(node.size, 'ceil') } for (const reroute of graph.reroutes.values()) { @@ -84,10 +109,11 @@ export function ensureCorrectLayoutScale( RENDER_SCALE_FACTOR ) reroute.pos = [p.x, p.y] + applySnap(reroute.pos) } for (const group of graph.groups) { - unprojectPosSize(group, anchor) + unprojectPosSize(group, anchor, graph) } if ('inputNode' in graph && 'outputNode' in graph) { @@ -96,7 +122,7 @@ export function ensureCorrectLayoutScale( graph.outputNode as SubgraphOutputNode ]) { if (ioNode) { - unprojectPosSize(ioNode, anchor) + unprojectPosSize(ioNode, anchor, graph) } } } diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 5eda2903fd..361a8b7a1d 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -17,6 +17,7 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph' +import { snapPoint } from '@/lib/litegraph/src/measure' import type { Vector2 } from '@/lib/litegraph/src/litegraph' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { isCloud } from '@/platform/distribution/types' @@ -1309,10 +1310,14 @@ export class ComfyApp { console.error(error) return } + const snapTo = LiteGraph.alwaysSnapToGrid + ? this.rootGraph.getSnapToGridSize() + : 0 forEachNode(this.rootGraph, (node) => { const size = node.computeSize() size[0] = Math.max(node.size[0], size[0]) size[1] = Math.max(node.size[1], size[1]) + snapPoint(size, snapTo, 'ceil') node.setSize(size) if (node.widgets) { // If you break something in the backend and want to patch workflows in the frontend