Skip to content

Commit 5171dec

Browse files
benceruleanluclaude
andcommitted
feat: Add slot registration and spatial indexing for hit detection
- Implement slot registration for all nodes (Vue and LiteGraph) - Add spatial indexes for slots and reroutes to improve hit detection performance - Register slots when nodes are drawn via new registerSlots() method - Update LayoutStore to use spatial indexing for O(log n) queries instead of O(n) Resolves #5125 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 5a74c01 commit 5171dec

File tree

9 files changed

+562
-26
lines changed

9 files changed

+562
-26
lines changed

src/lib/litegraph/src/LGraph.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@/lib/litegraph/src/constants'
77
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
88
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
9+
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
910

1011
import type { DragAndScaleState } from './DragAndScale'
1112
import { LGraphCanvas } from './LGraphCanvas'
@@ -2245,6 +2246,8 @@ export class LGraph
22452246
// Drop broken links, and ignore reroutes with no valid links
22462247
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
22472248
this.reroutes.delete(reroute.id)
2249+
// Clean up layout store
2250+
layoutStore.deleteRerouteLayout(String(reroute.id))
22482251
}
22492252
}
22502253

src/lib/litegraph/src/LGraphCanvas.ts

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type LinkRenderContext,
77
LitegraphLinkAdapter
88
} from '@/renderer/core/canvas/litegraph/LitegraphLinkAdapter'
9+
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
910

1011
import { CanvasPointer } from './CanvasPointer'
1112
import type { ContextMenu } from './ContextMenu'
@@ -2197,11 +2198,14 @@ export class LGraphCanvas
21972198
this.processSelect(node, e, true)
21982199
} else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
21992200
// Reroutes
2200-
const reroute = graph.getRerouteOnPos(
2201-
e.canvasX,
2202-
e.canvasY,
2203-
this.#visibleReroutes
2204-
)
2201+
// Try layout store first, fallback to old method
2202+
const rerouteLayout = layoutStore.queryRerouteAtPoint({
2203+
x: e.canvasX,
2204+
y: e.canvasY
2205+
})
2206+
const reroute = rerouteLayout
2207+
? graph.getReroute(Number(rerouteLayout.id))
2208+
: graph.getRerouteOnPos(e.canvasX, e.canvasY, this.#visibleReroutes)
22052209
if (reroute) {
22062210
if (e.altKey) {
22072211
pointer.onClick = (upEvent) => {
@@ -2402,16 +2406,21 @@ export class LGraphCanvas
24022406
this.ctx.lineWidth = this.connections_width + 7
24032407
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
24042408

2409+
// Try layout store for link hit testing first
2410+
const hitLinkId = layoutStore.queryLinkAtPoint({ x, y }, this.ctx)
2411+
24052412
for (const linkSegment of this.renderedPaths) {
24062413
const centre = linkSegment._pos
24072414
if (!centre) continue
24082415

2416+
// Check if this link was hit (using layout store or fallback to old method)
2417+
const isLinkHit = hitLinkId
2418+
? String(linkSegment.id) === hitLinkId
2419+
: linkSegment.path &&
2420+
this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi)
2421+
24092422
// If we shift click on a link then start a link from that input
2410-
if (
2411-
(e.shiftKey || e.altKey) &&
2412-
linkSegment.path &&
2413-
this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi)
2414-
) {
2423+
if ((e.shiftKey || e.altKey) && isLinkHit) {
24152424
this.ctx.lineWidth = lineWidth
24162425

24172426
if (e.shiftKey && !e.altKey) {
@@ -3139,8 +3148,27 @@ export class LGraphCanvas
31393148
// For input/output hovering
31403149
// to store the output of isOverNodeInput
31413150
const pos: Point = [0, 0]
3142-
const inputId = isOverNodeInput(node, x, y, pos)
3143-
const outputId = isOverNodeOutput(node, x, y, pos)
3151+
3152+
// Try to use layout store for hit testing first, fallback to old method
3153+
let inputId: number = -1
3154+
let outputId: number = -1
3155+
3156+
const slotLayout = layoutStore.querySlotAtPoint({ x, y })
3157+
if (slotLayout && slotLayout.nodeId === String(node.id)) {
3158+
if (slotLayout.type === 'input') {
3159+
inputId = slotLayout.index
3160+
pos[0] = slotLayout.position.x
3161+
pos[1] = slotLayout.position.y
3162+
} else {
3163+
outputId = slotLayout.index
3164+
pos[0] = slotLayout.position.x
3165+
pos[1] = slotLayout.position.y
3166+
}
3167+
} else {
3168+
// Fallback to old method
3169+
inputId = isOverNodeInput(node, x, y, pos)
3170+
outputId = isOverNodeOutput(node, x, y, pos)
3171+
}
31443172
const overWidget = node.getWidgetOnPos(x, y, true) ?? undefined
31453173

31463174
if (!node.mouseOver) {
@@ -5026,6 +5054,7 @@ export class LGraphCanvas
50265054
node._setConcreteSlots()
50275055
if (!node.collapsed) {
50285056
node.arrange()
5057+
node.registerSlots() // Register slots for hit detection
50295058
}
50305059
// Skip all node body/widget/title rendering. Vue overlay handles visuals.
50315060
return
@@ -5117,6 +5146,7 @@ export class LGraphCanvas
51175146
node._setConcreteSlots()
51185147
if (!node.collapsed) {
51195148
node.arrange()
5149+
node.registerSlots() // Register slots for hit detection
51205150
node.drawSlots(ctx, {
51215151
fromSlot: this.linkConnector.renderLinks[0]?.fromSlot as
51225152
| INodeOutputSlot
@@ -5131,6 +5161,7 @@ export class LGraphCanvas
51315161

51325162
this.drawNodeWidgets(node, null, ctx)
51335163
} else if (this.render_collapsed_slots) {
5164+
node.registerSlots() // Register slots for collapsed nodes too
51345165
node.drawCollapsedSlots(ctx)
51355166
}
51365167

@@ -5522,6 +5553,19 @@ export class LGraphCanvas
55225553
}
55235554
reroute.draw(ctx, this._pattern)
55245555

5556+
// Register reroute layout with layout store for hit testing
5557+
layoutStore.updateRerouteLayout(String(reroute.id), {
5558+
id: String(reroute.id),
5559+
position: { x: reroute.pos[0], y: reroute.pos[1] },
5560+
radius: 8, // Reroute.radius
5561+
bounds: {
5562+
x: reroute.pos[0] - 8,
5563+
y: reroute.pos[1] - 8,
5564+
width: 16,
5565+
height: 16
5566+
}
5567+
})
5568+
55255569
// Never draw slots when the pointer is down
55265570
if (!this.pointer.isDown) reroute.drawSlots(ctx)
55275571
}
@@ -6040,6 +6084,8 @@ export class LGraphCanvas
60406084
: segment.id
60416085
if (linkId !== undefined) {
60426086
graph.removeLink(linkId)
6087+
// Clean up layout store
6088+
layoutStore.deleteLinkLayout(String(linkId))
60436089
}
60446090
break
60456091
}
@@ -8117,11 +8163,18 @@ export class LGraphCanvas
81178163

81188164
// Check for reroutes
81198165
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
8120-
const reroute = this.graph.getRerouteOnPos(
8121-
event.canvasX,
8122-
event.canvasY,
8123-
this.#visibleReroutes
8124-
)
8166+
// Try layout store first, fallback to old method
8167+
const rerouteLayout = layoutStore.queryRerouteAtPoint({
8168+
x: event.canvasX,
8169+
y: event.canvasY
8170+
})
8171+
const reroute = rerouteLayout
8172+
? this.graph.getReroute(Number(rerouteLayout.id))
8173+
: this.graph.getRerouteOnPos(
8174+
event.canvasX,
8175+
event.canvasY,
8176+
this.#visibleReroutes
8177+
)
81258178
if (reroute) {
81268179
menu_info.unshift(
81278180
{

src/lib/litegraph/src/LGraphNode.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
type SlotPositionContext,
44
calculateInputSlotPos,
55
calculateInputSlotPosFromSlot,
6-
calculateOutputSlotPos
6+
calculateOutputSlotPos,
7+
registerNodeSlots
78
} from '@/renderer/core/canvas/litegraph/SlotCalculations'
89

910
import type { DragAndScale } from './DragAndScale'
@@ -4109,6 +4110,13 @@ export class LGraphNode
41094110
this.#arrangeWidgetInputSlots()
41104111
}
41114112

4113+
/**
4114+
* Register all slots with the layout store for hit detection
4115+
*/
4116+
registerSlots(): void {
4117+
registerNodeSlots(String(this.id), this.#getSlotPositionContext())
4118+
}
4119+
41124120
/**
41134121
* Draws a progress bar on the node.
41144122
* @param ctx The canvas context to draw on

src/lib/litegraph/src/LLink.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
SUBGRAPH_INPUT_ID,
33
SUBGRAPH_OUTPUT_ID
44
} from '@/lib/litegraph/src/constants'
5+
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
56

67
import type { LGraphNode, NodeId } from './LGraphNode'
78
import type { Reroute, RerouteId } from './Reroute'
@@ -459,9 +460,13 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
459460
reroute.linkIds.delete(this.id)
460461
if (!keepReroutes && !reroute.totalLinks) {
461462
network.reroutes.delete(reroute.id)
463+
// Clean up layout store
464+
layoutStore.deleteRerouteLayout(String(reroute.id))
462465
}
463466
}
464467
network.links.delete(this.id)
468+
// Clean up layout store
469+
layoutStore.deleteLinkLayout(String(this.id))
465470
}
466471

467472
/**

src/renderer/core/canvas/litegraph/LitegraphLinkAdapter.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
calculateOutputSlotPos
3939
} from '@/renderer/core/canvas/litegraph/SlotCalculations'
4040
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
41+
import type { LinkLayout } from '@/renderer/core/layout/types'
4142

4243
export interface LinkRenderContext {
4344
// Canvas settings
@@ -139,6 +140,25 @@ export class LitegraphLinkAdapter {
139140

140141
// Store path for hit detection
141142
link.path = path
143+
144+
// Register link layout with layout store
145+
if (path && linkData.centerPos) {
146+
const linkLayout: LinkLayout = {
147+
id: String(link.id),
148+
path,
149+
bounds: this.calculateLinkBounds(
150+
linkData.startPoint,
151+
linkData.endPoint,
152+
linkData.controlPoints
153+
),
154+
centerPos: linkData.centerPos,
155+
sourceNodeId: String(link.origin_id),
156+
targetNodeId: String(link.target_id),
157+
sourceSlot: link.origin_slot,
158+
targetSlot: link.target_slot
159+
}
160+
layoutStore.updateLinkLayout(String(link.id), linkLayout)
161+
}
142162
}
143163

144164
/**
@@ -434,6 +454,25 @@ export class LitegraphLinkAdapter {
434454
linkSegment._centreAngle = linkData.centerAngle
435455
}
436456
}
457+
458+
// Register link layout with layout store if this is a real link
459+
if (link && path && linkData.centerPos) {
460+
const linkLayout: LinkLayout = {
461+
id: String(link.id),
462+
path,
463+
bounds: this.calculateLinkBounds(
464+
linkData.startPoint,
465+
linkData.endPoint,
466+
linkData.controlPoints
467+
),
468+
centerPos: linkData.centerPos,
469+
sourceNodeId: String(link.origin_id),
470+
targetNodeId: String(link.target_id),
471+
sourceSlot: link.origin_slot,
472+
targetSlot: link.target_slot
473+
}
474+
layoutStore.updateLinkLayout(String(link.id), linkLayout)
475+
}
437476
}
438477
}
439478

@@ -522,4 +561,38 @@ export class LitegraphLinkAdapter {
522561
// Render using pure renderer
523562
this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext)
524563
}
564+
565+
/**
566+
* Calculate bounding box for a link
567+
*/
568+
private calculateLinkBounds(
569+
startPoint: Point,
570+
endPoint: Point,
571+
controlPoints?: Point[]
572+
): { x: number; y: number; width: number; height: number } {
573+
// Start with endpoints
574+
let minX = Math.min(startPoint.x, endPoint.x)
575+
let maxX = Math.max(startPoint.x, endPoint.x)
576+
let minY = Math.min(startPoint.y, endPoint.y)
577+
let maxY = Math.max(startPoint.y, endPoint.y)
578+
579+
// Include control points if present (for spline links)
580+
if (controlPoints) {
581+
for (const cp of controlPoints) {
582+
minX = Math.min(minX, cp.x)
583+
maxX = Math.max(maxX, cp.x)
584+
minY = Math.min(minY, cp.y)
585+
maxY = Math.max(maxY, cp.y)
586+
}
587+
}
588+
589+
// Add some padding for hit detection
590+
const padding = 10
591+
return {
592+
x: minX - padding,
593+
y: minY - padding,
594+
width: maxX - minX + 2 * padding,
595+
height: maxY - minY + 2 * padding
596+
}
597+
}
525598
}

src/renderer/core/canvas/litegraph/SlotCalculations.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import type {
1313
} from '@/lib/litegraph/src/interfaces'
1414
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
1515
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
16+
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
17+
import type { SlotLayout } from '@/renderer/core/layout/types'
1618

1719
export interface SlotPositionContext {
1820
/** Node's X position in graph coordinates */
@@ -229,3 +231,60 @@ function calculateVueSlotPosition(
229231

230232
return [nodeX + slotCenterX, nodeY + slotCenterY]
231233
}
234+
235+
/**
236+
* Register slot layout with the layout store for hit testing
237+
* @param nodeId The node ID
238+
* @param slotIndex The slot index
239+
* @param isInput Whether this is an input slot
240+
* @param position The slot position in graph coordinates
241+
*/
242+
export function registerSlotLayout(
243+
nodeId: string,
244+
slotIndex: number,
245+
isInput: boolean,
246+
position: Point
247+
): void {
248+
const slotKey = `${nodeId}-${isInput ? 'in' : 'out'}-${slotIndex}`
249+
250+
// Calculate bounds for the slot (20x20 area around center)
251+
const slotSize = 20
252+
const halfSize = slotSize / 2
253+
254+
const slotLayout: SlotLayout = {
255+
nodeId,
256+
index: slotIndex,
257+
type: isInput ? 'input' : 'output',
258+
position: { x: position[0], y: position[1] },
259+
bounds: {
260+
x: position[0] - halfSize,
261+
y: position[1] - halfSize,
262+
width: slotSize,
263+
height: slotSize
264+
}
265+
}
266+
267+
layoutStore.updateSlotLayout(slotKey, slotLayout)
268+
}
269+
270+
/**
271+
* Register all slots for a node
272+
* @param nodeId The node ID
273+
* @param context The slot position context
274+
*/
275+
export function registerNodeSlots(
276+
nodeId: string,
277+
context: SlotPositionContext
278+
): void {
279+
// Register input slots
280+
context.inputs.forEach((_, index) => {
281+
const position = calculateInputSlotPos(context, index)
282+
registerSlotLayout(nodeId, index, true, position)
283+
})
284+
285+
// Register output slots
286+
context.outputs.forEach((_, index) => {
287+
const position = calculateOutputSlotPos(context, index)
288+
registerSlotLayout(nodeId, index, false, position)
289+
})
290+
}

0 commit comments

Comments
 (0)