refactor(litegraph): decouple render-time state from models for reroutes and links\n\nIntroduce RenderedLinkSegment; compute reroute render params without mutating model; render into ephemeral segments instead of writing to Reroute/LLink.

This commit is contained in:
Benjamin Lu
2025-08-08 16:17:07 -04:00
parent 3fd0a8b125
commit d7ed1d36ed
3 changed files with 139 additions and 18 deletions

View File

@@ -8,6 +8,7 @@ import { LGraphGroup } from './LGraphGroup'
import { LGraphNode, type NodeId, type NodeProperty } from './LGraphNode'
import { LLink, type LinkId } from './LLink'
import { Reroute, type RerouteId } from './Reroute'
import { RenderedLinkSegment } from './canvas/RenderedLinkSegment'
import { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots'
import { strokeShape } from './draw'
import type {
@@ -5470,7 +5471,6 @@ export class LGraphCanvas
const endPos = node.getInputPos(link.target_slot)
const endDirection = node.inputs[link.target_slot]?.dir
firstReroute._dragging = true
this.#renderAllLinkSegments(
ctx,
link,
@@ -5490,7 +5490,6 @@ export class LGraphCanvas
const endPos = reroute.pos
const startDirection = node.outputs[link.origin_slot]?.dir
link._dragging = true
this.#renderAllLinkSegments(
ctx,
link,
@@ -5550,6 +5549,10 @@ export class LGraphCanvas
// Has reroutes
if (reroutes.length) {
const lastReroute = reroutes[reroutes.length - 1]
const floatingType = lastReroute?.floating?.slotType
const skipFirstSegment = floatingType === 'input'
const skipLastSegment = floatingType === 'output'
let startControl: Point | undefined
const l = reroutes.length
@@ -5558,7 +5561,6 @@ export class LGraphCanvas
// Only render once
if (!renderedPaths.has(reroute)) {
renderedPaths.add(reroute)
visibleReroutes.push(reroute)
reroute._colour =
link.color ||
@@ -5567,10 +5569,16 @@ export class LGraphCanvas
const prevReroute = graph.getReroute(reroute.parentId)
const rerouteStartPos = prevReroute?.pos ?? startPos
reroute.calculateAngle(this.last_draw_time, graph, rerouteStartPos)
const params = reroute.computeRenderParams(graph, rerouteStartPos)
// Skip the first segment if it is being dragged
if (!reroute._dragging) {
if (!(skipFirstSegment && j === 0)) {
const rendered = new RenderedLinkSegment({
id: reroute.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
parentId: reroute.parentId
})
this.renderLink(
ctx,
rerouteStartPos,
@@ -5583,35 +5591,45 @@ export class LGraphCanvas
LinkDirection.CENTER,
{
startControl,
endControl: reroute.controlPoint,
reroute,
disabled
endControl: params.controlPoint,
disabled,
renderTarget: rendered
}
)
renderedPaths.add(rendered)
}
}
if (!startControl && reroutes.at(-1)?.floating?.slotType === 'input') {
if (!startControl && skipFirstSegment) {
// Floating link connected to an input
startControl = [0, 0]
} else {
// Calculate start control for the next iter control point
const nextPos = reroutes[j + 1]?.pos ?? endPos
const prevR = graph.getReroute(reroute.parentId)
const startPosForParams = prevR?.pos ?? startPos
const params = reroute.computeRenderParams(graph, startPosForParams)
const dist = Math.min(
Reroute.maxSplineOffset,
distance(reroute.pos, nextPos) * 0.25
)
startControl = [dist * reroute.cos, dist * reroute.sin]
startControl = [dist * params.cos, dist * params.sin]
}
}
// Skip the last segment if it is being dragged
if (link._dragging) return
// For floating links from output, skip the last segment
if (skipLastSegment) return
// Use runtime fallback; TypeScript cannot evaluate this correctly.
const segmentStartPos = points.at(-2) ?? startPos
// Render final link segment
const rendered = new RenderedLinkSegment({
id: link.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
parentId: link.parentId
})
this.renderLink(
ctx,
segmentStartPos,
@@ -5622,10 +5640,17 @@ export class LGraphCanvas
null,
LinkDirection.CENTER,
end_dir,
{ startControl, disabled }
{ startControl, disabled, renderTarget: rendered }
)
renderedPaths.add(rendered)
// Skip normal render when link is being dragged
} else if (!link._dragging) {
const rendered = new RenderedLinkSegment({
id: link.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
parentId: link.parentId
})
this.renderLink(
ctx,
startPos,
@@ -5635,10 +5660,11 @@ export class LGraphCanvas
0,
null,
start_dir,
end_dir
end_dir,
{ renderTarget: rendered }
)
renderedPaths.add(rendered)
}
renderedPaths.add(link)
// event triggered rendered on top
if (link?._last_time && now - link._last_time < 1000) {
@@ -5687,7 +5713,8 @@ export class LGraphCanvas
endControl,
reroute,
num_sublines = 1,
disabled = false
disabled = false,
renderTarget
}: {
/** When defined, render data will be saved to this reroute instead of the {@link link}. */
reroute?: Reroute
@@ -5699,6 +5726,8 @@ export class LGraphCanvas
num_sublines?: number
/** Whether this is a floating link segment */
disabled?: boolean
/** Where to store the drawn path for hit testing if not using a reroute */
renderTarget?: RenderedLinkSegment
} = {}
): void {
const linkColour =
@@ -5729,13 +5758,13 @@ export class LGraphCanvas
const path = new Path2D()
/** The link or reroute we're currently rendering */
const linkSegment = reroute ?? link
const linkSegment = reroute ?? renderTarget
if (linkSegment) linkSegment.path = path
const innerA = LGraphCanvas.#lTempA
const innerB = LGraphCanvas.#lTempB
/** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */
/** Reference to render-time centre point of this segment. */
const pos: Point = linkSegment?._pos ?? [0, 0]
for (let i = 0; i < num_sublines; i++) {

View File

@@ -537,6 +537,66 @@ export class Reroute
}
}
/**
* Computes render-time parameters for this reroute without mutating the model.
* Returns the bezier end control-point offset and the direction cos/sin.
*/
computeRenderParams(
network: ReadonlyLinkNetwork,
linkStart: Point
): { cos: number; sin: number; controlPoint: Point } {
const thisPos = this.#pos
const angles: number[] = []
let sum = 0
// Collect angles of all links passing through this reroute
addAngles(this.linkIds, network.links)
addAngles(this.floatingLinkIds, network.floatingLinks)
// Default values when invalid
if (!angles.length) {
return { cos: 0, sin: 0, controlPoint: [0, 0] }
}
sum /= angles.length
const originToReroute = Math.atan2(
thisPos[1] - linkStart[1],
thisPos[0] - linkStart[0]
)
let diff = (originToReroute - sum) * 0.5
if (Math.abs(diff) > Math.PI * 0.5) diff += Math.PI
const dist = Math.min(
Reroute.maxSplineOffset,
distance(linkStart, thisPos) * 0.25
)
const originDiff = originToReroute - diff
const cos = Math.cos(originDiff)
const sin = Math.sin(originDiff)
const controlPoint: Point = [dist * -cos, dist * -sin]
return { cos, sin, controlPoint }
function addAngles(
linkIds: Iterable<LinkId>,
links: ReadonlyMap<LinkId, LLink>
) {
for (const linkId of linkIds) {
const link = links.get(linkId)
const pos = getNextPos(network, link, thisObj.id)
if (!pos) continue
const angle = getDirection(thisPos, pos)
angles.push(angle)
sum += angle
}
}
// Preserve lexical `this` values inside helper
// eslint-disable-next-line @typescript-eslint/no-this-alias
const thisObj = this
}
/**
* Renders the reroute on the canvas.
* @param ctx Canvas context to draw on

View File

@@ -0,0 +1,32 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { LinkSegment } from '@/lib/litegraph/src/interfaces'
/**
* Lightweight, render-only representation of a link segment used for hit testing and tooltips.
* Decouples canvas state from the LLink data model.
*/
export class RenderedLinkSegment implements LinkSegment {
readonly id: LinkId | RerouteId
readonly origin_id: NodeId
readonly origin_slot: number
readonly parentId?: RerouteId
path?: Path2D
readonly _pos: Float32Array = new Float32Array(2)
_centreAngle?: number
_dragging?: boolean
constructor(args: {
id: LinkId | RerouteId
origin_id: NodeId
origin_slot: number
parentId?: RerouteId
}) {
this.id = args.id
this.origin_id = args.origin_id
this.origin_slot = args.origin_slot
this.parentId = args.parentId
}
}