From d7ed1d36ed9ec55b4d6e2b94020ff4d50ed35910 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 8 Aug 2025 16:17:07 -0400 Subject: [PATCH] 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. --- src/lib/litegraph/src/LGraphCanvas.ts | 65 ++++++++++++++----- src/lib/litegraph/src/Reroute.ts | 60 +++++++++++++++++ .../src/canvas/RenderedLinkSegment.ts | 32 +++++++++ 3 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 src/lib/litegraph/src/canvas/RenderedLinkSegment.ts diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index cbb644cbf..86c3c14c2 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -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++) { diff --git a/src/lib/litegraph/src/Reroute.ts b/src/lib/litegraph/src/Reroute.ts index 886930227..d462e620f 100644 --- a/src/lib/litegraph/src/Reroute.ts +++ b/src/lib/litegraph/src/Reroute.ts @@ -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, + links: ReadonlyMap + ) { + 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 diff --git a/src/lib/litegraph/src/canvas/RenderedLinkSegment.ts b/src/lib/litegraph/src/canvas/RenderedLinkSegment.ts new file mode 100644 index 000000000..753231488 --- /dev/null +++ b/src/lib/litegraph/src/canvas/RenderedLinkSegment.ts @@ -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 + } +}