Add floating reroutes (#773)

### Floating reroutes

Native reroutes can now be kept in a disconnected state.

Link chains may be kept provided they are connected to _either_ an input
or an output. By design, reroutes will be automatically removed if both
sides are disconnected.
This commit is contained in:
filtered
2025-03-14 06:56:57 +11:00
committed by GitHub
parent d0e1998415
commit e6a914117b
7 changed files with 312 additions and 84 deletions

View File

@@ -883,7 +883,7 @@ export class LGraph implements LinkNetwork, Serialisable<SerialisableGraph> {
// disconnect inputs
if (inputs) {
for (const [i, slot] of inputs.entries()) {
if (slot.link != null) node.disconnectInput(i)
if (slot.link != null) node.disconnectInput(i, true)
}
}
@@ -1373,6 +1373,31 @@ export class LGraph implements LinkNetwork, Serialisable<SerialisableGraph> {
this.canvasAction(c => c.setDirty(fg, bg))
}
addFloatingLink(link: LLink): LLink {
if (link.id === -1) {
link.id = ++this.#lastFloatingLinkId
}
this.#floatingLinks.set(link.id, link)
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.floatingLinkIds.add(link.id)
}
return link
}
removeFloatingLink(link: LLink): void {
this.#floatingLinks.delete(link.id)
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.floatingLinkIds.delete(link.id)
if (reroute.floatingLinkIds.size === 0) {
delete reroute.floating
}
}
}
/**
* Configures a reroute on the graph where ID is already known (probably deserialisation).
* Creates the object if it does not exist.
@@ -1444,8 +1469,12 @@ export class LGraph implements LinkNetwork, Serialisable<SerialisableGraph> {
}
// Remove floating links with no reroutes
if (parentId === undefined) this.#floatingLinks.delete(linkId)
else if (link.parentId === id) link.parentId = parentId
const floatingReroutes = LLink.getReroutes(this, link)
if (!(floatingReroutes.length > 0)) {
this.#floatingLinks.delete(linkId)
} else if (link.parentId === id) {
link.parentId = parentId
}
}
reroutes.delete(id)
@@ -1460,7 +1489,7 @@ export class LGraph implements LinkNetwork, Serialisable<SerialisableGraph> {
if (!link) return
const node = this.getNodeById(link.target_id)
node?.disconnectInput(link.target_slot)
node?.disconnectInput(link.target_slot, false)
link.disconnect(this)
}

View File

@@ -2250,10 +2250,16 @@ export class LGraphCanvas implements ConnectionColorContext {
e.altKey &&
!e.shiftKey)
) {
node.disconnectInput(i)
node.disconnectInput(i, true)
} else if (e.shiftKey || this.allow_reconnect_links) {
linkConnector.moveInputLink(graph, input)
}
} else {
for (const link of graph.floatingLinks.values()) {
if (link.target_id === node.id && link.target_slot === i) {
graph.removeFloatingLink(link)
}
}
}
// Dragging a new link from input to output
@@ -4596,6 +4602,10 @@ export class LGraphCanvas implements ConnectionColorContext {
}
}
if (graph.floatingLinks.size > 0) {
this.#renderFloatingLinks(ctx, graph, visibleReroutes, now)
}
// Render the reroute circles
for (const reroute of visibleReroutes) {
if (
@@ -4610,6 +4620,39 @@ export class LGraphCanvas implements ConnectionColorContext {
ctx.globalAlpha = 1
}
#renderFloatingLinks(ctx: CanvasRenderingContext2D, graph: LGraph, visibleReroutes: Reroute[], now: number) {
// Floating reroutes
for (const link of graph.floatingLinks.values()) {
const reroutes = LLink.getReroutes(graph, link)
const firstReroute = reroutes[0]
const reroute = reroutes.at(-1)
if (!firstReroute || !reroute?.floating) continue
// Input not connected
if (reroute.floating.slotType === "input") {
const node = graph.getNodeById(link.target_id)
if (!node) continue
const startPos = firstReroute.pos
const endPos = node.getInputPos(link.target_slot)
const endDirection = node.inputs[link.target_slot]?.dir
firstReroute._dragging = true
this.#renderAllLinkSegments(ctx, link, startPos, endPos, visibleReroutes, now, LinkDirection.CENTER, endDirection)
} else {
const node = graph.getNodeById(link.origin_id)
if (!node) continue
const startPos = node.getOutputPos(link.origin_slot)
const endPos = reroute.pos
const startDirection = node.outputs[link.origin_slot]?.dir
link._dragging = true
this.#renderAllLinkSegments(ctx, link, startPos, endPos, visibleReroutes, now, startDirection, LinkDirection.CENTER)
}
}
}
#renderAllLinkSegments(
ctx: CanvasRenderingContext2D,
link: LLink,
@@ -4687,10 +4730,15 @@ export class LGraphCanvas implements ConnectionColorContext {
}
}
// Calculate start control for the next iter control point
const nextPos = reroutes[j + 1]?.pos ?? endPos
const dist = Math.min(80, distance(reroute.pos, nextPos) * 0.25)
startControl = [dist * reroute.cos, dist * reroute.sin]
if (!startControl && reroutes.at(-1)?.floating?.slotType === "input") {
// Floating link connected to an input
startControl = [0, 0] satisfies Point
} else {
// Calculate start control for the next iter control point
const nextPos = reroutes[j + 1]?.pos ?? endPos
const dist = Math.min(80, distance(reroute.pos, nextPos) * 0.25)
startControl = [dist * reroute.cos, dist * reroute.sin]
}
}
// Skip the last segment if it is being dragged
@@ -7091,7 +7139,7 @@ export class LGraphCanvas implements ConnectionColorContext {
if (info.output) {
node.disconnectOutput(info.slot)
} else if (info.input) {
node.disconnectInput(info.slot)
node.disconnectInput(info.slot, true)
}
node.graph.afterChange()
return

View File

@@ -1471,7 +1471,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
* remove an existing input slot
*/
removeInput(slot: number): void {
this.disconnectInput(slot)
this.disconnectInput(slot, true)
const { inputs } = this
const slot_info = inputs.splice(slot, 1)
@@ -2503,8 +2503,22 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
inputNode.inputs[inputIndex].link = link.id
// Reroutes
for (const reroute of LLink.getReroutes(graph, link)) {
reroute?.linkIds.add(link.id)
const reroutes = LLink.getReroutes(graph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) delete reroute.floating
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = graph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
graph.removeFloatingLink(link)
}
}
}
graph._version++
@@ -2551,6 +2565,12 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
return false
}
for (const link of this.graph?.floatingLinks.values() ?? []) {
if (link.origin_id === this.id && link.origin_slot === slot) {
this.graph?.removeFloatingLink(link)
}
}
// get output slot
const output = this.outputs[slot]
if (!output || !output.links || output.links.length == 0) return false
@@ -2578,7 +2598,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
input.link = null
// remove the link from the links pool
link_info.disconnect(graph)
link_info.disconnect(graph, "input")
graph._version++
// link_info hasn't been modified so its ok
@@ -2625,7 +2645,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
)
}
// remove the link from the links pool
link_info.disconnect(graph)
link_info.disconnect(graph, "input")
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
@@ -2691,7 +2711,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable {
}
}
link_info.disconnect(this.graph, keepReroutes)
link_info.disconnect(this.graph, keepReroutes ? "output" : undefined)
if (this.graph) this.graph._version++
this.onConnectionsChange?.(

View File

@@ -104,12 +104,12 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
}
/**
* Gets all reroutes from the output slot to this segment. If this segment is a reroute, it will be the last element.
* Gets all reroutes from the output slot to this segment. If this segment is a reroute, it will not be included.
* @returns An ordered array of all reroutes from the node output to
* this reroute or the reroute before it. Otherwise, an empty array.
*/
static getReroutes(
network: ReadonlyLinkNetwork,
network: Pick<ReadonlyLinkNetwork, "reroutes">,
linkSegment: LinkSegment,
): Reroute[] {
if (!linkSegment.parentId) return []
@@ -119,7 +119,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
}
static getFirstReroute(
network: LinkNetwork,
network: Pick<ReadonlyLinkNetwork, "reroutes">,
linkSegment: LinkSegment,
): Reroute | undefined {
return LLink.getReroutes(network, linkSegment).at(0)
@@ -166,15 +166,44 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
/**
* Disconnects a link and removes it from the graph, cleaning up any reroutes that are no longer used
* @param network The container (LGraph) where reroutes should be updated
* @param keepReroutes If `true`, reroutes will not be garbage collected.
* @param keepReroutes If `undefined`, reroutes will be automatically removed if no links remain.
* If `input` or `output`, reroutes will not be automatically removed, and retain a connection to the input or output, respectively.
*/
disconnect(network: LinkNetwork, keepReroutes?: boolean): void {
disconnect(network: LinkNetwork, keepReroutes?: "input" | "output"): void {
const reroutes = LLink.getReroutes(network, this)
const lastReroute = reroutes.at(-1)
// When floating from output, 1-to-1 ratio of floating link to final reroute (tree-like)
const outputFloating = keepReroutes === "output" &&
lastReroute?.linkIds.size === 1 &&
lastReroute.floatingLinkIds.size === 0
// When floating from inputs, the final (input side) reroute may have many floating links
if (outputFloating || (keepReroutes === "input" && lastReroute)) {
const newLink = LLink.create(this)
newLink.id = -1
if (keepReroutes === "input") {
newLink.origin_id = -1
newLink.origin_slot = -1
lastReroute.floating = { slotType: "input" }
} else {
newLink.target_id = -1
newLink.target_slot = -1
lastReroute.floating = { slotType: "output" }
}
network.addFloatingLink(newLink)
}
for (const reroute of reroutes) {
reroute.linkIds.delete(this.id)
if (!keepReroutes && !reroute.linkIds.size)
if (!keepReroutes && !reroute.linkIds.size && !reroute.floatingLinkIds.size) {
network.reroutes.delete(reroute.id)
}
}
network.links.delete(this.id)
}

View File

@@ -1,5 +1,6 @@
import type {
CanvasColour,
INodeInputSlot,
LinkNetwork,
LinkSegment,
Point,
@@ -7,7 +8,7 @@ import type {
ReadonlyLinkNetwork,
ReadOnlyRect,
} from "./interfaces"
import type { NodeId } from "./LGraphNode"
import type { LGraphNode, NodeId } from "./LGraphNode"
import type { Serialisable, SerialisableReroute } from "./types/serialisation"
import { type LinkId, LLink } from "./LLink"
@@ -17,10 +18,6 @@ export type RerouteId = number
/** The input or output slot that an incomplete reroute link is connected to. */
export interface FloatingRerouteSlot {
/** The ID of the node that the slot belongs to */
nodeId: NodeId
/** The index of the slot on the node */
slot: number
/** Floating connection to an input or output */
slotType: "input" | "output"
}
@@ -52,7 +49,7 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
this.#parentId = value
}
/** Set when the reroute has no complete links but is still on the canvas. */
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
floating?: FloatingRerouteSlot
#pos = this.#malloc.subarray(0, 2)
@@ -110,29 +107,34 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
*/
#lastRenderTime: number = -Infinity
/** @inheritdoc */
get origin_id(): NodeId | undefined {
// if (!this.linkIds.size) return this.#network.deref()?.reroutes.get(this.parentId)
const nextId = this.linkIds.values().next().value
return nextId === undefined
get firstLink(): LLink | undefined {
const linkId = this.linkIds.values().next().value
return linkId === undefined
? undefined
: this.#network
.deref()
?.links
.get(nextId)
?.origin_id
.get(linkId)
}
get firstFloatingLink(): LLink | undefined {
const linkId = this.floatingLinkIds.values().next().value
return linkId === undefined
? undefined
: this.#network
.deref()
?.floatingLinks
.get(linkId)
}
/** @inheritdoc */
get origin_id(): NodeId | undefined {
return this.firstLink?.origin_id
}
/** @inheritdoc */
get origin_slot(): number | undefined {
const nextId = this.linkIds.values().next().value
return nextId === undefined
? undefined
: this.#network
.deref()
?.links
.get(nextId)
?.origin_slot
return this.firstLink?.origin_slot
}
/**
@@ -151,8 +153,9 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
floatingLinkIds?: Iterable<LinkId>,
) {
this.#network = new WeakRef(network)
this.update(parentId, pos, linkIds)
this.linkIds ??= new Set()
this.parentId = parentId
if (pos) this.pos = pos
this.linkIds = new Set(linkIds)
this.floatingLinkIds = new Set(floatingLinkIds)
}
@@ -243,10 +246,9 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
findSourceOutput() {
const network = this.#network.deref()
const originId = this.linkIds.values().next().value
if (!network || !originId) return
if (!network) return
const link = network.links.get(originId)
const link = this.firstLink ?? this.firstFloatingLink
if (!link) return
const node = network.getNodeById(link.origin_id)
@@ -260,6 +262,39 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
}
}
/**
* Finds the first input slot for links or floating links passing through this reroute.
*/
findTargetInputs(): { node: LGraphNode, input: INodeInputSlot, inputIndex: number, link: LLink }[] | undefined {
const network = this.#network.deref()
if (!network) return
const results: {
node: LGraphNode
input: INodeInputSlot
inputIndex: number
link: LLink
}[] = []
addAllResults(network, this.linkIds, network.links)
addAllResults(network, this.floatingLinkIds, network.floatingLinks)
return results
function addAllResults(network: ReadonlyLinkNetwork, linkIds: Iterable<LinkId>, links: ReadonlyMap<LinkId, LLink>) {
for (const linkId of linkIds) {
const link = links.get(linkId)
if (!link) continue
const node = network.getNodeById(link.target_id)
const input = node?.inputs[link.target_slot]
if (!input) continue
results.push({ node, input, inputIndex: link.target_slot, link })
}
}
}
/** @inheritdoc */
move(deltaX: number, deltaY: number) {
this.#pos[0] += deltaX
@@ -276,31 +311,36 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
return true
}
remove() {
const network = this.#network.deref()
if (!network) return
network.removeReroute(this.id)
}
calculateAngle(lastRenderTime: number, network: ReadonlyLinkNetwork, linkStart: Point): void {
// Ensure we run once per render
if (!(lastRenderTime > this.#lastRenderTime)) return
this.#lastRenderTime = lastRenderTime
const { links } = network
const { linkIds, id } = this
const { links, floatingLinks } = network
const { id, linkIds, floatingLinkIds, pos: thisPos } = this
const angles: number[] = []
let sum = 0
for (const linkId of linkIds) {
const link = links.get(linkId)
// Remove the linkId or just ignore?
if (!link) continue
const pos = LLink.findNextReroute(network, link, id)?.pos ??
network.getNodeById(link.target_id)
?.getInputPos(link.target_slot)
if (!pos) continue
// Add all link angles
calculateLinks(linkIds, links)
calculateLinks(floatingLinkIds, floatingLinks)
// TODO: Store points/angles, check if changed, skip calcs.
const angle = Math.atan2(pos[1] - this.#pos[1], pos[0] - this.#pos[0])
angles.push(angle)
sum += angle
// Invalid - reset
if (!angles.length) {
this.cos = 0
this.sin = 0
this.controlPoint[0] = 0
this.controlPoint[1] = 0
return
}
if (!angles.length) return
sum /= angles.length
@@ -321,6 +361,23 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
this.sin = sin
this.controlPoint[0] = dist * -cos
this.controlPoint[1] = dist * -sin
/**
* Calculates the direction of each link and adds it to the array.
* @param linkIds The IDs of the links to calculate
* @param links The link container from the link network.
*/
function calculateLinks(linkIds: Iterable<LinkId>, links: ReadonlyMap<LinkId, LLink>) {
for (const linkId of linkIds) {
const link = links.get(linkId)
const pos = getNextPos(network, link, id)
if (!pos) continue
const angle = getDirection(thisPos, pos)
angles.push(angle)
sum += angle
}
}
}
/**
@@ -357,23 +414,36 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
/** @inheritdoc */
asSerialisable(): SerialisableReroute {
const { id, parentId, pos, linkIds } = this
const floating = floatingToSerialisable(this.floating)
return {
id,
parentId,
pos: [pos[0], pos[1]],
linkIds: [...linkIds],
floating,
floating: this.floating ? { slotType: this.floating.slotType } : undefined,
}
}
}
function floatingToSerialisable(floating: FloatingRerouteSlot | undefined): FloatingRerouteSlot | undefined {
return floating
? {
nodeId: floating.nodeId,
slot: floating.slot,
slotType: floating.slotType,
}
: undefined
/**
* Retrieves the position of the next reroute in the chain, or the destination input slot on this link.
* @param network The network of links
* @param link The link representing the current reroute chain
* @param id The ID of "this" reroute
* @returns The position of the next reroute or the input slot target, otherwise `undefined`.
*/
function getNextPos(network: ReadonlyLinkNetwork, link: LLink | undefined, id: RerouteId) {
if (!link) return
const linkPos = LLink.findNextReroute(network, link, id)?.pos
if (linkPos) return linkPos
// Floating link with no input to find
if (link.target_id === -1 || link.target_slot === -1) return
return network.getNodeById(link.target_id)?.getInputPos(link.target_slot)
}
/** Returns the direction from one point to another in radians. */
function getDirection(fromPos: Point, toPos: Point) {
return Math.atan2(toPos[1] - fromPos[1], toPos[0] - fromPos[0])
}

View File

@@ -113,7 +113,7 @@ export class LinkConnector {
renderLinks.push(renderLink)
this.listenUntilReset("input-moved", (e) => {
e.detail.link.disconnect(network, true)
e.detail.link.disconnect(network, "output")
})
} catch (error) {
console.warn(`Could not create render link for link id: [${link.id}].`, link, error)
@@ -215,13 +215,7 @@ export class LinkConnector {
dragFromReroute(network: LinkNetwork, reroute: Reroute): void {
if (this.isConnecting) throw new Error("Already dragging links.")
const { state } = this
// Connect new link from reroute
const linkId = reroute.linkIds.values().next().value
if (linkId == null) return
const link = network.links.get(linkId)
const link = reroute.firstLink ?? reroute.firstFloatingLink
if (!link) return
const outputNode = network.getNodeById(link.origin_id)
@@ -234,7 +228,7 @@ export class LinkConnector {
renderLink.fromDirection = LinkDirection.NONE
this.renderLinks.push(renderLink)
state.connectingTo = "input"
this.state.connectingTo = "input"
}
dragFromLinkSegment(network: LinkNetwork, linkSegment: LinkSegment): void {
@@ -278,7 +272,7 @@ export class LinkConnector {
// Get reroute if no node is found
const reroute = locator.getRerouteOnPos(canvasX, canvasY)
// Drop output->input link on reroute is not impl.
if (reroute && this.state.connectingTo === "output") {
if (reroute) {
this.dropOnReroute(reroute, event)
} else {
this.dropOnNothing(event)
@@ -329,6 +323,43 @@ export class LinkConnector {
const mayContinue = this.events.dispatch("dropped-on-reroute", { reroute, event })
if (mayContinue === false) return
if (this.state.connectingTo === "input") {
const results = reroute.findTargetInputs()
if (!results?.length) return
for (const { node: inputNode, input, link: resultLink } of results) {
for (const renderLink of this.renderLinks) {
if (renderLink.toType !== "input") continue
if (renderLink instanceof MovingRenderLink) {
const { outputNode, inputSlot, outputSlot, fromReroute } = renderLink
// Link is already connected here
if (inputSlot === input) continue
const newLink = outputNode.connectSlots(outputSlot, inputNode, input, fromReroute?.id)
if (newLink) this.events.dispatch("input-moved", renderLink)
} else {
const reroutes = reroute.getReroutes()
if (reroutes === null) throw new Error("Reroute loop detected.")
if (reroutes) {
for (const reroute of reroutes.slice(0, -1)) {
reroute.remove()
}
}
const { node: outputNode, fromSlot, fromReroute } = renderLink
// Set the parentId of the reroute we dropped on, to the reroute we dragged from
reroute.parentId = fromReroute?.id
const newLink = outputNode.connectSlots(fromSlot, inputNode, input, resultLink.parentId)
this.events.dispatch("link-created", newLink)
}
}
}
return
}
for (const link of this.renderLinks) {
if (link.toType !== "output") continue

View File

@@ -124,7 +124,8 @@ export interface ReadonlyLinkNetwork {
export interface LinkNetwork extends ReadonlyLinkNetwork {
readonly links: Map<LinkId, LLink>
readonly reroutes: Map<RerouteId, Reroute>
getNodeById(id: NodeId): LGraphNode | null
addFloatingLink(link: LLink): LLink
removeReroute(id: number): unknown
}
/**