Fix grid cell matching and alignment references

This commit is contained in:
Tenney
2026-11-05 20:16:43 +08:00
parent 7b5ec9a4e7
commit 0aaa2e955f
5 changed files with 169 additions and 11 deletions
+9
View File
@@ -50,6 +50,15 @@ ComfyUI-NodeAligner is a lightweight ComfyUI layout plugin that includes feature
```
2. 将 `ComfyUI-NodeAligner` 文件夹复制到 `ComfyUI/custom_nodes` 目录下。
## 1.0.3 网格匹配与对齐基准 Grid matching and reference nodes
- 修复同一网格中偏移较大的节点被当作独立行/列,导致左对齐后再上对齐仍不整齐的问题。
- 通过跨行/列的有序匹配找回对应单元,允许缺格,不将不完整的行直接压缩到左侧。
- 在识别出的网格内,左对齐以每列最上方节点的左边为基准,上对齐以每行最左方节点的上边为基准。
- 33 项回归检查通过,包括用户提供的六节点错位排列、缺格、选择顺序、先左后上/先上后左和重复执行。本机前端 1.57.6 已按该六节点位置实测完整的“左对齐→上对齐”操作,结果符合行列基准。
Matches cells across rows and columns, including missing cells. Grid left alignment follows the top node of each column; grid top alignment follows the left node of each row.
## 1.0.2 行列布局修复 Grid layout fixes
- 修复选中 4 个及以上节点时,偏移节点被自动拆组、未参与对齐的问题。
+6
View File
@@ -29,3 +29,9 @@
- A fixed seed of 2,500 sparse three-column layouts across eight actions (20,000 checks) has no selection-order differences or repeated-action drift.
- Real frontend validation includes all four offset nodes participating in left alignment, single-step undo, and a two-row/three-column layout retaining its tracks. In Nodes 2.0, the actual rendered right edges align within each column and repeated clicks preserve the result.
- Temporary workflows were discarded and the original renderer setting restored. Pointer events from workspace tabs and sidebars no longer reposition the temporary toolbar.
## 1.0.3 reference and missing-cell correction
The user's six-node red-line example exposes a gap in independent one-axis clustering. Match cells across geometrically established rows/columns, allowing skipped cells and newly encountered tracks. Use the topmost node of each column as the grid's left-alignment reference, and the leftmost node of each row as its top-alignment reference.
The regression suite now has 33 passing checks, including the exact reported positions, missing cells, both action orders, selection-order invariance, and repeated execution. The reported positions were also loaded into an isolated real frontend workflow: left alignment moved the lower-right node to its upper reference, then top alignment put all six nodes on the intended two rows and three columns. The temporary workflow was discarded after verification.
+117 -7
View File
@@ -481,11 +481,115 @@ const ButtonManager = {
|| a.pos[1 - axis] - b.pos[1 - axis]
|| String(a.id).localeCompare(String(b.id));
},
median(values) {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
},
getTrackTolerance(nodes, axis) {
const sizes = nodes.map(node => this.getNodeSize(node, axis)).sort((a, b) => a - b);
const middle = Math.floor(sizes.length / 2);
const median = sizes.length % 2 ? sizes[middle] : (sizes[middle - 1] + sizes[middle]) / 2;
return median / 4;
return this.median(nodes.map(node => this.getNodeSize(node, axis))) / 4;
},
getReferenceNode(group, axis) {
return group.reduce((first, node) => this.compareNodes(node, first, axis) < 0 ? node : first);
},
// 同一行/列保留共同的投影区间,避免通过相邻节点链式合并。
getOverlappingGroups(nodes, axis) {
const groups = [];
const maximumSpan = this.getTrackTolerance(nodes, axis) * 4;
let commonEnd = -Infinity;
for (const node of [...nodes].sort((a, b) => this.compareNodes(a, b, axis))) {
const group = groups[groups.length - 1];
const start = node.pos[axis];
const end = start + this.getNodeSize(node, axis);
if (group && start < commonEnd && start - group[0].pos[axis] <= maximumSpan) {
group.push(node);
commonEnd = Math.min(commonEnd, end);
} else {
groups.push([node]);
commonEnd = end;
}
}
return groups;
},
// 跨行/列做有序匹配;允许缺格和新增列,不把短行向左压缩。
matchGridTracks(groups, axis, nodes) {
const seed = groups.reduce((longest, group) => group.length > longest.length ? group : longest);
let tracks = [...seed].sort((a, b) => this.compareNodes(a, b, axis)).map(node => [node]);
const typicalSize = this.getTrackTolerance(nodes, axis) * 4;
const gapCost = 0.5;
for (const group of groups) {
if (group === seed) continue;
const ordered = [...group].sort((a, b) => this.compareNodes(a, b, axis));
const references = tracks.map(track => this.getReferenceNode(track, 1 - axis));
const gaps = references.slice(1).map((node, index) => node.pos[axis] - references[index].pos[axis])
.filter(gap => gap > 0);
const scale = Math.max(1, typicalSize, this.median(gaps));
const path = Array.from({ length: tracks.length + 1 }, () => new Uint8Array(ordered.length + 1));
let previous = Float64Array.from({ length: ordered.length + 1 }, (_, index) => index * gapCost);
let current = new Float64Array(ordered.length + 1);
for (let j = 1; j <= ordered.length; j++) path[0][j] = 3;
for (let i = 1; i <= tracks.length; i++) {
current[0] = i * gapCost;
path[i][0] = 2;
for (let j = 1; j <= ordered.length; j++) {
const match = previous[j - 1]
+ Math.abs(references[i - 1].pos[axis] - ordered[j - 1].pos[axis]) / scale;
const missing = previous[j] + gapCost;
const inserted = current[j - 1] + gapCost;
if (match <= missing && match <= inserted) {
current[j] = match;
path[i][j] = 1;
} else if (missing <= inserted) {
current[j] = missing;
path[i][j] = 2;
} else {
current[j] = inserted;
path[i][j] = 3;
}
}
[previous, current] = [current, previous];
}
const matched = [];
let i = tracks.length;
let j = ordered.length;
while (i || j) {
const direction = path[i][j];
if (direction === 1) matched.push([...tracks[--i], ordered[--j]]);
else if (direction === 2) matched.push(tracks[--i]);
else matched.push([ordered[--j]]);
}
tracks = matched.reverse();
}
return tracks;
},
getNodeGrid(nodes) {
if (nodes.length < 4) return null;
let best = null;
let bestCount = 0;
let bestSpread = Infinity;
for (const axis of [1, 0]) {
const crossTolerance = this.getTrackTolerance(nodes, 1 - axis);
const scale = Math.max(1, this.getTrackTolerance(nodes, axis) * 4);
for (const groups of [this.getNodeGroups(nodes, axis), this.getOverlappingGroups(nodes, axis)]) {
const repeated = groups.filter(group => group.length > 1
&& Math.max(...group.map(node => node.pos[1 - axis]))
- Math.min(...group.map(node => node.pos[1 - axis])) > crossTolerance);
if (repeated.length < 2) continue;
const count = repeated.reduce((sum, group) => sum + group.length, 0);
const spread = repeated.reduce((sum, group) => sum
+ (Math.max(...group.map(node => node.pos[axis]))
- Math.min(...group.map(node => node.pos[axis]))) / scale, 0);
if (count > bestCount || (count === bestCount && spread < bestSpread)) {
best = { axis, groups };
bestCount = count;
bestSpread = spread;
}
}
}
if (!best) return null;
const matched = this.matchGridTracks(best.groups, 1 - best.axis, nodes);
return best.axis === 1 ? { rows: best.groups, columns: matched } : { rows: matched, columns: best.groups };
},
// 用起点、中心或终点识别已经成形的行列,避免节点宽高不同造成拆组。
getNodeGroups(nodes, axis, preferredAnchor = 0) {
@@ -530,12 +634,16 @@ const ButtonManager = {
const nodes = this.getSelectedNodes();
if (nodes.length === 0) return;
const trackGap = Math.max(20, this.getTrackTolerance(nodes, axis) + 1);
const grid = this.getNodeGrid(nodes);
const groups = grid ? (axis === 0 ? grid.columns : grid.rows) : this.getNodeGroups(nodes, axis, anchor);
let previousEnd = -Infinity;
for (const group of this.getNodeGroups(nodes, axis, anchor)) {
for (const group of groups) {
const minimum = Math.min(...group.map(node => node.pos[axis]));
const maximum = Math.max(...group.map(node => node.pos[axis] + this.getNodeSize(node, axis)));
const maximumSize = Math.max(...group.map(node => this.getNodeSize(node, axis)));
let target = minimum + (maximum - minimum) * anchor;
let target = grid && anchor === 0
? this.getReferenceNode(group, 1 - axis).pos[axis]
: minimum + (maximum - minimum) * anchor;
// 宽高不同的相邻行列可能互相覆盖,整条轨道向后让出间距。
target += Math.max(0, previousEnd + trackGap - (target - maximumSize * anchor));
group.forEach(node => {
@@ -573,7 +681,9 @@ const ButtonManager = {
},
distributeNodes(axis) {
const nodes = this.getSelectedNodes();
for (const group of this.getNodeGroups(nodes, 1 - axis)) {
const grid = this.getNodeGrid(nodes);
const groups = grid ? (axis === 0 ? grid.rows : grid.columns) : this.getNodeGroups(nodes, 1 - axis);
for (const group of groups) {
if (group.length < 2) continue;
group.sort((a, b) => this.compareNodes(a, b, axis));
const start = group[0].pos[axis];
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-nodealigner"
description = "ComfyUI-NodeAligner is a lightweight ComfyUI layout plugin that includes features such as node alignment, distribution, and resizing. This plugin is designed to simplify layout adjustments in visual node editors or custom UI components, making node arrangement more convenient and efficient."
version = "1.0.2"
version = "1.0.3"
license = {file = "LICENSE"}
[project.urls]
+36 -3
View File
@@ -277,10 +277,10 @@ test('preserves grid tracks, selection order and repeated alignment or distribut
data: [[100, 100, 120, 80], [630, 110, 180, 100], [1210, 90, 100, 120],
[120, 520, 160, 110], [610, 500, 120, 140], [1200, 530, 200, 90]],
expected: {
alignLeft: [0, [100, 610, 1200, 100, 610, 1200]],
alignLeft: [0, [100, 630, 1210, 100, 630, 1210]],
alignRight: [0, [160, 630, 1300, 120, 690, 1200]],
alignCenterVertically: [0, [130, 620, 1250, 110, 650, 1200]],
alignTop: [1, [90, 90, 90, 500, 500, 500]],
alignTop: [1, [100, 100, 100, 520, 520, 520]],
alignBottom: [1, [130, 110, 90, 530, 500, 550]],
alignCenterHorizontally: [1, [110, 100, 90, 515, 500, 525]],
horizontalDistribution: [0, [100, 625, 1210, 120, 680, 1200]],
@@ -337,6 +337,39 @@ test('keeps a tall singleton from changing the spacing of other columns', () =>
}
});
test('matches offset and incomplete grid rows using the top and left reference nodes', () => {
const layouts = [
{ name: 'screenshot 2x3', data: [[90, 110], [590, 110], [1515, 190], [90, 470], [590, 550], [1175, 550]],
xs: [90, 590, 1515, 90, 590, 1515], ys: [110, 110, 110, 470, 470, 470] },
{ name: 'screenshot missing B', data: [[90, 110], [1515, 190], [90, 470], [590, 550], [1175, 550]],
xs: [90, 1515, 90, 590, 1515], ys: [110, 110, 470, 470, 470] },
{ name: 'two incomplete rows', data: [[90, 110], [590, 110], [590, 470], [1515, 470]],
xs: [90, 590, 590, 1515], ys: [110, 110, 470, 470] },
];
for (const layout of layouts) {
const indices = layout.data.map((_, index) => index);
const orders = [indices, [...indices].reverse(),
indices.filter(index => index % 2 === 0).reverse().concat(indices.filter(index => index % 2))];
for (const order of orders) {
for (const sequence of [['alignLeft', 'alignTop'], ['alignTop', 'alignLeft']]) {
const nodes = layout.data.map(([x, y]) => new LGraphNode(x, y, 280, 100));
const { manager, history } = load(order.map(index => nodes[index]));
for (const action of [...sequence, ...sequence]) {
const axis = action === 'alignLeft' ? 0 : 1;
const other = nodes.map(node => node.pos[1 - axis]);
const start = history.length;
const message = `${layout.name}, ${sequence}, selection ${order}, ${action}`;
manager.runAction(manager[action]);
assert.deepEqual(nodes.map(node => node.pos[axis]), axis === 0 ? layout.xs : layout.ys, message);
assert.deepEqual(nodes.map(node => node.pos[1 - axis]), other, message);
assert.deepEqual(nodes.map(node => node.size), nodes.map(() => [280, 100]), message);
assert.deepEqual(history.slice(start), ['before', 'after'], message);
}
}
}
}
});
test('expands overlapping heterogeneous nodes forward with a stable minimum gap', () => {
for (const axis of [0, 1]) {
const nodes = [[0, 0, 600], [10, 5, 50], [20, -4, 800]].map(([position, other, size]) =>
@@ -391,7 +424,7 @@ test('keeps heterogeneous single columns together and separates overlapping colu
const firstEnd = Math.max(...nodes.slice(0, 2).map(node => node.pos[axis] + node.size[axis]));
const secondStart = Math.min(...nodes.slice(2).map(node => node.pos[axis]));
assert.ok(secondStart - firstEnd >= 20, `${action}, overlapping track bounds`);
if (anchor === 0) assert.deepEqual(nodes.map(node => node.pos[axis]), [-100, -100, 363.5, 363.5], action);
if (anchor === 0) assert.deepEqual(nodes.map(node => node.pos[axis]), [50, 50, 513.5, 513.5], action);
assert.deepEqual(nodes.map(node => node.pos[1 - axis]), data.map(node => node[1 - axis]), action);
const first = nodes.map(node => [...node.pos]);
manager.runAction(manager[action]);