Add Reroutes (#301)

* Add Reroute

- Initial Reroute implementation
- LLink and Reroute both implement the new LinkSegment interface
- LinkSegments can have a parentId, which always points to a Reroute

* Narrow TS type of schema v0.4 extras

* Add reroutes to schema 0.4

Use extras.reroutes to store additional data

* Add Reroute POC to LLink

* Add Reroute rendering

* Add Reroute context menu - Delete Reroute

* Update delete selected - include reroutes & groups

* Add Reroute select & move

* Include reroutes in area-select

* Move disconnect link logic to LLink

* Add Reroute connect

* nit

* Add Reroute support - connecting links

* Add Add Reroute from link menu (menu)

* nit

* Add shift-drag from reroute to add new link

* Prevent Reroutes from disappearing

Add keepReroutes option to prevent Reroute GC

* Add fourth param to connectInputToOutput

* Allow both connecting in/out to be null

* Move ConnectingLink start pos to Reroute

* Add link render options

* Refactor renderLink - spline / bezier

* Refactor renderLink - linear, straight

* Fix centre points on all link types

Improves link render time

* [Refactor] Generic recursive interface / flat set

* nit

* Allow Reroutes to be members of groups

* Start links from the closest reroute

For the "shift-click drag link from link" feature

* Add Reroutes using alt-click on link paths

* nit - Refactor

* nit - Refactor

* Fix reroute deselect UX

Temporary workaround

* Add Reroute link centre-marker handling

* Add optional link arrow markers

Add enum for link markers
-> Pointing the way forward ->
Set default centre marker to arrow

* Add module export: LinkMarkerShape

* Add link arrow direction for all link types

* Add Reroute auto-swivel with custom curves

* Add state switch to disable reroutes

Works at root of all canvas interactions, should leave existing reroutes untouched but invisible until e.g. links are edited / changed.

* Fix cannot deselect when reroutes disabled

* Include reroutes in select-all
This commit is contained in:
filtered
2024-11-13 03:18:48 +11:00
committed by GitHub
parent c6d7a446f2
commit 3d6adf0225
11 changed files with 1104 additions and 352 deletions

View File

@@ -1,12 +1,14 @@
import type { Dictionary, IContextMenuValue, ISlotType, MethodNames, Point } from "./interfaces"
import type { ISerialisedGraph, Serialisable, SerialisableGraph } from "./types/serialisation"
import type { Dictionary, IContextMenuValue, LinkNetwork, ISlotType, MethodNames, Point, LinkSegment } from "./interfaces"
import type { ISerialisedGraph, Serialisable, SerialisableGraph, SerialisableReroute } from "./types/serialisation"
import { Reroute, RerouteId } from "./Reroute"
import { LGraphEventMode } from "./types/globalEnums"
import { LiteGraph } from "./litegraph"
import { LGraphCanvas } from "./LGraphCanvas"
import { LGraphGroup } from "./LGraphGroup"
import { type NodeId, LGraphNode } from "./LGraphNode"
import { type LinkId, LLink, type SerialisedLLinkArray } from "./LLink"
import { type LinkId, LLink } from "./LLink"
import { MapProxyHandler } from "./MapProxyHandler"
import { isSortaInsideOctagon } from "./measure"
interface IGraphInput {
name: string
@@ -30,7 +32,7 @@ type ParamsArray<T extends Record<any, any>, K extends MethodNames<T>> = Paramet
+ onNodeRemoved: when a node inside this graph is removed
+ onNodeConnectionChange: some connection has changed in the graph (connected or disconnected)
*/
export class LGraph implements Serialisable<SerialisableGraph> {
export class LGraph implements LinkNetwork, Serialisable<SerialisableGraph> {
static serialisedSchemaVersion = 1 as const
//default supported types
@@ -88,6 +90,28 @@ export class LGraph implements Serialisable<SerialisableGraph> {
inputs: Dictionary<IGraphInput>
outputs: Dictionary<IGraphInput>
#reroutes = new Map<RerouteId, Reroute>()
/** All reroutes in this graph. */
public get reroutes(): Map<RerouteId, Reroute> {
return this.#reroutes
}
public set reroutes(value: Map<RerouteId, Reroute>) {
if (!value) throw new TypeError("Attempted to set LGraph.reroutes to a falsy value.")
const reroutes = this.#reroutes
if (value.size === 0) {
reroutes.clear()
return
}
for (const rerouteId of reroutes.keys()) {
if (!value.has(rerouteId)) reroutes.delete(rerouteId)
}
for (const [id, reroute] of value) {
reroutes.set(id, reroute)
}
}
/** @deprecated See {@link state}.{@link LGraphState.lastNodeId lastNodeId} */
get last_node_id() {
return this.state.lastNodeId
@@ -185,7 +209,9 @@ export class LGraph implements Serialisable<SerialisableGraph> {
this._nodes_by_id = {}
this._nodes_in_order = [] //nodes sorted in execution order
this._nodes_executable = null //nodes that contain onExecute sorted in execution order
this._links.clear()
this.reroutes.clear()
//other scene stuff
this._groups = []
@@ -913,6 +939,31 @@ export class LGraph implements Serialisable<SerialisableGraph> {
return this._groups.toReversed().find(g => g.isPointInside(x, y))
}
/**
* Returns the top-most group with a titlebar in the provided position.
* @param x The x coordinate in canvas space
* @param y The y coordinate in canvas space
* @return The group or null
*/
getGroupTitlebarOnPos(x: number, y: number): LGraphGroup | undefined {
return this._groups.toReversed().find(g => g.isPointInTitlebar(x, y))
}
/**
* Finds a reroute a the given graph point
* @param x X co-ordinate in graph space
* @param y Y co-ordinate in graph space
* @returns The first reroute under the given co-ordinates, or undefined
*/
getRerouteOnPos(x: number, y: number): Reroute | undefined {
for (const reroute of this.reroutes.values()) {
const pos = reroute.pos
if (isSortaInsideOctagon(x - pos[0], y - pos[1], 20))
return reroute
}
}
/**
* Checks that the node type matches the node type registered, used when replacing a nodetype by a newer version during execution
* this replaces the ones using the old version with the new version
@@ -1205,6 +1256,72 @@ export class LGraph implements Serialisable<SerialisableGraph> {
setDirtyCanvas(fg: boolean, bg?: boolean): void {
this.canvasAction(c => c.setDirty(fg, bg))
}
/**
* Configures a reroute on the graph where ID is already known (probably deserialisation).
* Creates the object if it does not exist.
* @param id Reroute ID
* @param pos Position in graph space
* @param linkIds IDs of links that pass through this reroute
*/
setReroute({ id, parentId, pos, linkIds }: SerialisableReroute): Reroute {
if (id > this.state.lastRerouteId) this.state.lastRerouteId = id
const reroute = this.reroutes.get(id) ?? new Reroute(id, this)
reroute.update(parentId, pos, linkIds)
this.reroutes.set(id, reroute)
return reroute
}
/**
* Creates a new reroute and adds it to the graph.
* @param pos Position in graph space
* @param links The links that will use this reroute (e.g. if from an output with multiple outputs, and all will use it)
* @param afterRerouteId If set, this reroute will be shown after the specified ID. Otherwise, the reroute will be added as the last on the link.
* @returns The newly created reroute - typically ignored.
*/
createReroute(pos: Point, before: LinkSegment): Reroute {
const rerouteId = ++this.state.lastRerouteId
const linkIds = before instanceof Reroute
? before.linkIds
: [before.id]
const reroute = new Reroute(rerouteId, this, pos, before.parentId, linkIds)
this.reroutes.set(rerouteId, reroute)
for (const linkId of linkIds) {
const link = this._links.get(linkId)
if (!link) continue
if (link.parentId === before.parentId) link.parentId = rerouteId
LLink.getReroutes(this, link)
?.filter(x => x.parentId === before.parentId)
.forEach(x => x.parentId = rerouteId)
}
return reroute
}
/**
* Removes a reroute from the graph
* @param id ID of reroute to remove
*/
removeReroute(id: RerouteId): void {
const { reroutes } = this
const reroute = reroutes.get(id)
if (!reroute) return
// Extract reroute from the reroute chain
const { parentId, linkIds } = reroute
for (const reroute of reroutes.values()) {
if (reroute.parentId === id) reroute.parentId = parentId
}
for (const linkId of linkIds) {
const link = this._links.get(linkId)
if (link && link.parentId === id) link.parentId = parentId
}
reroutes.delete(id)
this.setDirtyCanvas(false, true)
}
/**
* Destroys a link
* @param {Number} link_id
@@ -1215,8 +1332,10 @@ export class LGraph implements Serialisable<SerialisableGraph> {
const node = this.getNodeById(link.target_id)
node?.disconnectInput(link.target_slot)
link.disconnect(this)
}
//save and recover app state ***************************************
/**
* Creates a Object containing all the info about this graph, it can be serialized
* @deprecated Use {@link asSerialisable}, which returns the newer schema version.
@@ -1224,9 +1343,18 @@ export class LGraph implements Serialisable<SerialisableGraph> {
* @return {Object} value of the node
*/
serialize(option?: { sortNodes: boolean }): ISerialisedGraph {
const { config, state, groups, nodes, extra } = this.asSerialisable(option)
const links = [...this._links.values()].map(x => x.serialize())
const { config, state, groups, nodes, reroutes, extra } = this.asSerialisable(option)
const linkArray = [...this._links.values()]
const links = linkArray.map(x => x.serialize())
if (reroutes.length) {
extra.reroutes = reroutes
// Link parent IDs cannot go in 0.4 schema arrays
extra.linkExtensions = linkArray
.filter(x => x.parentId !== undefined)
.map(x => ({ id: x.id, parentId: x.parentId }))
}
return {
last_node_id: state.lastNodeId,
last_link_id: state.lastLinkId,
@@ -1259,6 +1387,7 @@ export class LGraph implements Serialisable<SerialisableGraph> {
const groups = this._groups.map(x => x.serialize())
const links = [...this._links.values()].map(x => x.asSerialisable())
const reroutes = [...this.reroutes.values()].map(x => x.asSerialisable())
const data: SerialisableGraph = {
version: LGraph.serialisedSchemaVersion,
@@ -1267,6 +1396,7 @@ export class LGraph implements Serialisable<SerialisableGraph> {
groups,
nodes,
links,
reroutes,
extra
}
@@ -1284,6 +1414,10 @@ export class LGraph implements Serialisable<SerialisableGraph> {
if (!data) return
if (!keep_old) this.clear()
const { extra } = data
let reroutes: SerialisableReroute[] | undefined
// TODO: Determine whether this should this fall back to 0.4.
if (data.version === 0.4) {
// Deprecated - old schema version, links are arrays
if (Array.isArray(data.links)) {
@@ -1292,6 +1426,20 @@ export class LGraph implements Serialisable<SerialisableGraph> {
this._links.set(link.id, link)
}
}
//#region `extra` embeds for v0.4
// LLink parentIds
if (Array.isArray(extra?.linkExtensions)) {
for (const linkEx of extra.linkExtensions) {
const link = this._links.get(linkEx.id)
if (link) link.parentId = linkEx.parentId
}
}
// Reroutes
reroutes = extra?.reroutes
//#endregion `extra` embeds for v0.4
} else {
// New schema - one version so far, no check required.
@@ -1311,6 +1459,19 @@ export class LGraph implements Serialisable<SerialisableGraph> {
this._links.set(link.id, link)
}
}
reroutes = data.reroutes
}
// Reroutes
if (Array.isArray(reroutes)) {
for (const rerouteData of reroutes) {
const reroute = this.setReroute(rerouteData)
// Drop broken links, and ignore reroutes with no valid links
if (!reroute.validateLinks(this._links))
this.reroutes.delete(rerouteData.id)
}
}
const nodesData = data.nodes
@@ -1318,7 +1479,7 @@ export class LGraph implements Serialisable<SerialisableGraph> {
//copy all stored fields
for (const i in data) {
//links must be accepted
if (i == "nodes" || i == "groups" || i == "links" || i === "state")
if (i == "nodes" || i == "groups" || i == "links" || i === "state" || i === "reroutes")
continue
this[i] = data[i]
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import type { LGraph } from "./LGraph"
import type { ISerialisedGroup } from "./types/serialisation"
import { LiteGraph } from "./litegraph"
import { LGraphCanvas } from "./LGraphCanvas"
import { isInsideRectangle, containsCentre, containsRect, createBounds } from "./measure"
import { containsCentre, containsRect, isInsideRectangle, isPointInRectangle, createBounds } from "./measure"
import { LGraphNode } from "./LGraphNode"
import { RenderShape, TitleMode } from "./types/globalEnums"
@@ -194,21 +194,26 @@ export class LGraphGroup implements Positionable, IPinnable {
}
recomputeInsideNodes(): void {
const { nodes, groups } = this.graph
const { nodes, reroutes, groups } = this.graph
const children = this._children
const node_bounding = new Float32Array(4)
this._nodes.length = 0
children.clear()
// move any nodes we partially overlap
// Move nodes we overlap the centre point of
for (const node of nodes) {
node.getBounding(node_bounding)
if (containsCentre(this._bounding, node_bounding)) {
if (containsCentre(this._bounding, node.boundingRect)) {
this._nodes.push(node)
children.add(node)
}
}
// Move reroutes we overlap the centre point of
for (const reroute of reroutes.values()) {
if (isPointInRectangle(reroute.pos, this._bounding))
children.add(reroute)
}
// Move groups we wholly contain
for (const group of groups) {
if (containsRect(this._bounding, group._bounding))
children.add(group)
@@ -283,7 +288,7 @@ export class LGraphGroup implements Positionable, IPinnable {
}
isInResize(x: number, y: number): boolean {
const b = this._bounding
const b = this.boundingRect
const right = b[0] + b[2]
const bottom = b[1] + b[3]

View File

@@ -5,6 +5,7 @@ import type { ISerialisedNode } from "./types/serialisation"
import type { LGraphCanvas } from "./LGraphCanvas"
import type { CanvasMouseEvent } from "./types/events"
import type { DragAndScale } from "./DragAndScale"
import type { Reroute, RerouteId } from "./Reroute"
import { LGraphEventMode, NodeSlotType, TitleMode, RenderShape } from "./types/globalEnums"
import { BadgePosition, LGraphBadge } from "./LGraphBadge"
import { type LGraphNodeConstructor, LiteGraph } from "./litegraph"
@@ -36,6 +37,8 @@ interface ConnectByTypeOptions {
wildcardToTyped?: boolean
/** Allow our typed slot to connect to wildcard slots on remote node. Default: true */
typedToWildcard?: boolean
/** The {@link Reroute.id} that the connection is being dragged from. */
afterRerouteId?: RerouteId
}
/** Internal type used for type safety when implementing generic checks for inputs & outputs */
@@ -1291,7 +1294,7 @@ export class LGraphNode implements Positionable, IPinnable {
if (this.widgets?.length) {
for (let i = 0, l = this.widgets.length; i < l; ++i) {
const widget = this.widgets[i]
if (widget.hidden || (widget.advanced && !this.showAdvanced)) continue;
if (widget.hidden || (widget.advanced && !this.showAdvanced)) continue
widgets_height += widget.computeSize
? widget.computeSize(size[0])[1] + 4
@@ -1795,7 +1798,7 @@ export class LGraphNode implements Positionable, IPinnable {
*/
connectByType(slot: number | string, target_node: LGraphNode, target_slotType: ISlotType, optsIn?: ConnectByTypeOptions): LLink | null {
const slotIndex = this.findConnectByTypeSlot(true, target_node, target_slotType, optsIn)
if (slotIndex !== null) return this.connect(slot, target_node, slotIndex)
if (slotIndex !== null) return this.connect(slot, target_node, slotIndex, optsIn?.afterRerouteId)
console.debug("[connectByType]: no way to connect type: ", target_slotType, " to node: ", target_node)
return null
@@ -1816,7 +1819,7 @@ export class LGraphNode implements Positionable, IPinnable {
if ("generalTypeInCase" in optsIn) optsIn.typedToWildcard = !!optsIn.generalTypeInCase
}
const slotIndex = this.findConnectByTypeSlot(false, source_node, source_slotType, optsIn)
if (slotIndex !== null) return source_node.connect(slotIndex, this, slot)
if (slotIndex !== null) return source_node.connect(slotIndex, this, slot, optsIn?.afterRerouteId)
console.debug("[connectByType]: no way to connect type: ", source_slotType, " to node: ", source_node)
return null
@@ -1829,7 +1832,7 @@ export class LGraphNode implements Positionable, IPinnable {
* @param {number | string} target_slot the input slot of the target node (could be the number of the slot or the string with the name of the slot, or -1 to connect a trigger)
* @return {Object} the link_info is created, otherwise null
*/
connect(slot: number | string, target_node: LGraphNode, target_slot: ISlotType): LLink | null {
connect(slot: number | string, target_node: LGraphNode, target_slot: ISlotType, afterRerouteId?: RerouteId): LLink | null {
// Allow legacy API support for searching target_slot by string, without mutating the input variables
let targetIndex: number
@@ -1919,7 +1922,7 @@ export class LGraphNode implements Positionable, IPinnable {
//if there is something already plugged there, disconnect
if (target_node.inputs[targetIndex]?.link != null) {
graph.beforeChange()
target_node.disconnectInput(targetIndex)
target_node.disconnectInput(targetIndex, true)
changed = true
}
if (output.links?.length) {
@@ -1942,7 +1945,8 @@ export class LGraphNode implements Positionable, IPinnable {
this.id,
slot,
target_node.id,
targetIndex
targetIndex,
afterRerouteId
)
//add to graph links list
@@ -1953,6 +1957,10 @@ export class LGraphNode implements Positionable, IPinnable {
output.links.push(link_info.id)
//connect in input
target_node.inputs[targetIndex].link = link_info.id
// Reroutes
LLink.getReroutes(graph, link_info)
.forEach(x => x?.linkIds.add(nextId))
graph._version++
//link_info has been created now, so its updated
@@ -2108,9 +2116,10 @@ export class LGraphNode implements Positionable, IPinnable {
/**
* Disconnect one input
* @param slot Input slot index, or the name of the slot
* @param keepReroutes If `true`, reroutes will not be garbage collected.
* @return true if disconnected successfully or already disconnected, otherwise false
*/
disconnectInput(slot: number | string): boolean {
disconnectInput(slot: number | string, keepReroutes?: boolean): boolean {
// Allow search by string
if (typeof slot === "string") {
slot = this.findInputSlot(slot)
@@ -2150,7 +2159,7 @@ export class LGraphNode implements Positionable, IPinnable {
}
}
this.graph._links.delete(link_id)
link_info.disconnect(this.graph, keepReroutes)
if (this.graph) this.graph._version++
this.onConnectionsChange?.(
@@ -2453,8 +2462,7 @@ export class LGraphNode implements Positionable, IPinnable {
const outNode = graph.getNodeById(outLink.target_id)
if (!outNode) return
// TODO: Add 4th param (afterRerouteId: inLink.parentId) when reroutes are merged.
const result = inNode.connect(inLink.origin_slot, outNode, outLink.target_slot)
const result = inNode.connect(inLink.origin_slot, outNode, outLink.target_slot, inLink.parentId)
madeAnyConnections ||= !!result
}
}

View File

@@ -1,15 +1,17 @@
import type { CanvasColour, ISlotType } from "./interfaces"
import type { CanvasColour, LinkNetwork, ISlotType, LinkSegment } from "./interfaces"
import type { NodeId } from "./LGraphNode"
import type { Reroute, RerouteId } from "./Reroute"
import type { Serialisable, SerialisableLLink } from "./types/serialisation"
export type LinkId = number
export type SerialisedLLinkArray = [id: LinkId, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number, type: ISlotType]
export type SerialisedLLinkArray = [id: LinkId, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number, type: ISlotType]
//this is the class in charge of storing link information
export class LLink implements Serialisable<SerialisableLLink> {
export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
/** Link ID */
id: LinkId
parentId?: RerouteId
type: ISlotType
/** Output node ID */
origin_id: NodeId
@@ -19,6 +21,7 @@ export class LLink implements Serialisable<SerialisableLLink> {
target_id: NodeId
/** Input slot index */
target_slot: number
data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown
/** Centre point of the link, calculated during render only - can be inaccurate */
@@ -27,6 +30,8 @@ export class LLink implements Serialisable<SerialisableLLink> {
_last_time?: number
/** The last canvas 2D path that was used to render this link */
path?: Path2D
/** @inheritdoc */
_centreAngle?: number
#color?: CanvasColour
/** Custom colour for this link only */
@@ -35,13 +40,14 @@ export class LLink implements Serialisable<SerialisableLLink> {
this.#color = value === "" ? null : value
}
constructor(id: LinkId, type: ISlotType, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number) {
constructor(id: LinkId, type: ISlotType, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number, parentId?: RerouteId) {
this.id = id
this.type = type
this.origin_id = origin_id
this.origin_slot = origin_slot
this.target_id = target_id
this.target_slot = target_slot
this.parentId = parentId
this._data = null
this._pos = new Float32Array(2) //center
@@ -58,7 +64,28 @@ export class LLink implements Serialisable<SerialisableLLink> {
* @returns A new LLink
*/
static create(data: SerialisableLLink): LLink {
return new LLink(data.id, data.type, data.origin_id, data.origin_slot, data.target_id, data.target_slot)
return new LLink(data.id, data.type, data.origin_id, data.origin_slot, data.target_id, data.target_slot, data.parentId)
}
/**
* Gets all reroutes from the output slot to this segment. If this segment is a reroute, it will be the last element.
* @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: LinkNetwork, linkSegment: LinkSegment): Reroute[] {
return network.reroutes.get(linkSegment.parentId)
?.getReroutes() ?? []
}
/**
* Finds the reroute in the chain after the provided reroute ID.
* @param network The network this link belongs to
* @param linkSegment The starting point of the search (input side). Typically the LLink object itself, but can be any link segment.
* @param rerouteId The matching reroute will have this set as its {@link parentId}.
* @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected.
*/
static findNextReroute(network: LinkNetwork, linkSegment: LinkSegment, rerouteId: RerouteId): Reroute | null | undefined {
return network.reroutes.get(linkSegment.parentId)
?.findNextReroute(rerouteId)
}
configure(o: LLink | SerialisedLLinkArray) {
@@ -76,9 +103,25 @@ export class LLink implements Serialisable<SerialisableLLink> {
this.origin_slot = o.origin_slot
this.target_id = o.target_id
this.target_slot = o.target_slot
this.parentId = o.parentId
}
}
/**
* 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.
*/
disconnect(network: LinkNetwork, keepReroutes?: boolean): void {
const reroutes = LLink.getReroutes(network, this)
for (const reroute of reroutes) {
reroute.linkIds.delete(this.id)
if (!keepReroutes && !reroute.linkIds.size) network.reroutes.delete(reroute.id)
}
network.links.delete(this.id)
}
/**
* @deprecated Prefer {@link LLink.asSerialisable} (returns an object, not an array)
* @returns An array representing this LLink
@@ -103,6 +146,7 @@ export class LLink implements Serialisable<SerialisableLLink> {
target_slot: this.target_slot,
type: this.type
}
if (this.parentId) copy.parentId = this.parentId
return copy
}
}

279
src/Reroute.ts Normal file
View File

@@ -0,0 +1,279 @@
import type { CanvasColour, LinkSegment, LinkNetwork, Point, Positionable, ReadOnlyRect } from "./interfaces"
import { LLink, type LinkId } from "./LLink"
import type { SerialisableReroute, Serialisable } from "./types/serialisation"
import { distance } from "./measure"
import type { NodeId } from "./LGraphNode"
export type RerouteId = number
/**
* Represents an additional point on the graph that a link path will travel through. Used for visual organisation only.
*
* Requires no disposal or clean up.
* Stores only primitive values (IDs) to reference other items in its network, and a `WeakRef` to a {@link LinkNetwork} to resolve them.
*/
export class Reroute implements Positionable, LinkSegment, Serialisable<SerialisableReroute> {
static radius: number = 10
#malloc = new Float32Array(8)
/** The network this reroute belongs to. Contains all valid links and reroutes. */
#network: WeakRef<LinkNetwork>
#parentId?: RerouteId
/** @inheritdoc */
public get parentId(): RerouteId {
return this.#parentId
}
/** Ignores attempts to create an infinite loop. @inheritdoc */
public set parentId(value: RerouteId) {
if (value === this.id) return
if (this.getReroutes() === null) return
this.#parentId = value
}
#pos = this.#malloc.subarray(0, 2)
/** @inheritdoc */
get pos(): Point {
return this.#pos
}
set pos(value: Point) {
if (!(value?.length >= 2)) throw new TypeError("Reroute.pos is an x,y point, and expects an indexable with at least two values.")
this.#pos[0] = value[0]
this.#pos[1] = value[1]
}
/** @inheritdoc */
get boundingRect(): ReadOnlyRect {
const { radius } = Reroute
const [x, y] = this.#pos
return [x - radius, y - radius, 2 * radius, 2 * radius]
}
/** @inheritdoc */
selected?: boolean
/** The ID ({@link LLink.id}) of every link using this reroute */
linkIds: Set<LinkId>
/** The averaged angle of every link through this reroute. */
otherAngle: number = 0
/** Cached cos */
cos: number = 0
sin: number = 0
/** Bezier curve control point for the "target" (input) side of the link */
controlPoint: Point = this.#malloc.subarray(4, 6)
/** @inheritdoc */
path?: Path2D
/** @inheritdoc */
_centreAngle?: number
/** @inheritdoc */
_pos: Float32Array = this.#malloc.subarray(6, 8)
/**
* Used to ensure reroute angles are only executed once per frame.
* @todo Calculate on change instead.
*/
#lastRenderTime: number = -Infinity
#buffer: Point = this.#malloc.subarray(2, 4)
/** @inheritdoc */
get origin_id(): NodeId | undefined {
// if (!this.linkIds.size) return this.#network.deref()?.reroutes.get(this.parentId)
return this.#network.deref()
?.links.get(this.linkIds.values().next().value)
?.origin_id
}
/** @inheritdoc */
get origin_slot(): number | undefined {
return this.#network.deref()
?.links.get(this.linkIds.values().next().value)
?.origin_slot
}
/**
* Initialises a new link reroute object.
* @param id Unique identifier for this reroute
* @param network The network of links this reroute belongs to. Internally converted to a WeakRef.
* @param pos Position in graph coordinates
* @param linkIds Link IDs ({@link LLink.id}) of all links that use this reroute
*/
constructor(
public readonly id: RerouteId,
network: LinkNetwork,
pos?: Point,
parentId?: RerouteId,
linkIds?: Iterable<LinkId>
) {
this.#network = new WeakRef(network)
this.update(parentId, pos, linkIds)
this.linkIds ??= new Set()
}
/**
* Applies a new parentId to the reroute, and optinoally a new position and linkId.
* Primarily used for deserialisation.
* @param parentId The ID of the reroute prior to this reroute, or `undefined` if it is the first reroute connected to a nodes output
* @param pos The position of this reroute
* @param linkIds All link IDs that pass through this reroute
*/
update(parentId: RerouteId | undefined, pos?: Point, linkIds?: Iterable<LinkId>): void {
this.parentId = parentId
if (pos) this.pos = pos
if (linkIds) this.linkIds = new Set(linkIds)
}
/**
* Validates the linkIds this reroute has. Removes broken links.
* @param links Collection of valid links
* @returns true if any links remain after validation
*/
validateLinks(links: Map<LinkId, LLink>): boolean {
const { linkIds } = this
for (const linkId of linkIds) {
if (!links.get(linkId)) linkIds.delete(linkId)
}
return linkIds.size > 0
}
/**
* Retrieves an ordered array of all reroutes from the node output.
* @param visited Internal. A set of reroutes that this function has already visited whilst recursing up the chain.
* @returns An ordered array of all reroutes from the node output to this reroute, inclusive.
* `null` if an infinite loop is detected.
* `undefined` if the reroute chain or {@link LinkNetwork} are invalid.
*/
getReroutes(visited = new Set<Reroute>()): Reroute[] | null | undefined {
// No parentId - last in the chain
if (this.#parentId === undefined) return [this]
// Invalid chain - looped
if (visited.has(this)) return null
visited.add(this)
const parent = this.#network.deref()?.reroutes.get(this.#parentId)
// Invalid parent (or network) - drop silently to recover
if (!parent) {
this.#parentId = undefined
return [this]
}
const reroutes = parent.getReroutes(visited)
reroutes?.push(this)
return reroutes
}
/**
* Internal. Called by {@link LLink.findNextReroute}. Not intended for use by itself.
* @param withParentId The rerouteId to look for
* @param visited A set of reroutes that have already been visited
* @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected.
*/
findNextReroute(withParentId: RerouteId, visited = new Set<Reroute>()): Reroute | null | undefined {
if (this.#parentId === withParentId) return this
if (visited.has(this)) return null
visited.add(this)
return this.#network.deref()
?.reroutes.get(this.#parentId)
?.findNextReroute(withParentId, visited)
}
/** @inheritdoc */
move(deltaX: number, deltaY: number) {
this.#pos[0] += deltaX
this.#pos[1] += deltaY
}
calculateAngle(lastRenderTime: number, network: LinkNetwork, 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 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)
?.getConnectionPos(true, link.target_slot, this.#buffer)
if (!pos) continue
// 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
}
if (!angles.length) return
sum /= angles.length
const originToReroute = Math.atan2(this.#pos[1] - linkStart[1], this.#pos[0] - linkStart[0])
let diff = (originToReroute - sum) * 0.5
if (Math.abs(diff) > Math.PI * 0.5) diff += Math.PI
const dist = Math.min(80, distance(linkStart, this.#pos) * 0.25)
// Store results
const originDiff = originToReroute - diff
const cos = Math.cos(originDiff)
const sin = Math.sin(originDiff)
this.otherAngle = originDiff
this.cos = cos
this.sin = sin
this.controlPoint[0] = dist * -cos
this.controlPoint[1] = dist * -sin
return
}
/**
* Renders the reroute on the canvas.
* @param ctx Canvas context to draw on
* @param colour Reroute colour (typically link colour)
*
* @remarks Leaves {@link ctx}.fillStyle, strokeStyle, and lineWidth dirty (perf.).
*/
draw(ctx: CanvasRenderingContext2D, colour: CanvasColour): void {
const { pos } = this
ctx.fillStyle = colour
ctx.beginPath()
ctx.arc(pos[0], pos[1], Reroute.radius, 0, 2 * Math.PI)
ctx.fill()
ctx.lineWidth = 1
ctx.strokeStyle = "rgb(0,0,0,0.5)"
ctx.stroke()
ctx.fillStyle = "#ffffff55"
ctx.strokeStyle = "rgb(0,0,0,0.3)"
ctx.beginPath()
ctx.arc(pos[0], pos[1], 8, 0, 2 * Math.PI)
ctx.fill()
ctx.stroke()
if (this.selected) {
ctx.strokeStyle = "#fff"
ctx.beginPath()
ctx.arc(pos[0], pos[1], 12, 0, 2 * Math.PI)
ctx.stroke()
}
}
/** @inheritdoc */
asSerialisable(): SerialisableReroute {
return {
id: this.id,
parentId: this.parentId,
pos: [this.pos[0], this.pos[1]],
linkIds: [...this.linkIds]
}
}
}

View File

@@ -1,7 +1,8 @@
import type { ContextMenu } from "./ContextMenu"
import type { LGraphNode, NodeId } from "./LGraphNode"
import type { LinkDirection, RenderShape } from "./types/globalEnums"
import type { LinkId } from "./LLink"
import type { LinkId, LLink } from "./LLink"
import type { Reroute, RerouteId } from "./Reroute"
export type Dictionary<T> = { [key: string]: T }
@@ -12,13 +13,19 @@ export type NullableProperties<T> = {
export type CanvasColour = string | CanvasGradient | CanvasPattern
/** An object containing a set of child objects */
export interface Parent<TChild> {
/** All objects owned by the parent object. */
readonly children?: ReadonlySet<TChild>
}
/**
* An object that can be positioned, selected, and moved.
*
* May contain other {@link Positionable} objects.
*/
export interface Positionable {
id: NodeId | number
export interface Positionable extends Parent<Positionable> {
id: NodeId | RerouteId | number
/** Position in graph coordinates. Default: 0,0 */
pos: Point
/** true if this object is part of the selection, otherwise false. */
@@ -27,8 +34,6 @@ export interface Positionable {
/** See {@link IPinnable.pinned} */
readonly pinned?: boolean
readonly children?: ReadonlySet<Positionable>
/**
* Adds a delta to the current position.
* @param deltaX X value to add to current position
@@ -60,6 +65,35 @@ export interface IPinnable {
unpin(): void
}
/**
* Contains a list of links, reroutes, and nodes.
*/
export interface LinkNetwork {
links: Map<LinkId, LLink>
reroutes: Map<RerouteId, Reroute>
getNodeById(id: NodeId): LGraphNode | null
}
/** Contains a cached 2D canvas path and a centre point, with an optional forward angle. */
export interface LinkSegment {
/** Link / reroute ID */
readonly id: LinkId | RerouteId
/** The {@link id} of the reroute that this segment starts from (output side), otherwise `undefined`. */
readonly parentId?: RerouteId
/** The last canvas 2D path that was used to render this segment */
path?: Path2D
/** Centre point of the {@link path}. Calculated during render only - can be inaccurate */
readonly _pos: Float32Array
/** Y-forward along the {@link path} from its centre point, in radians. `undefined` if using circles for link centres. Calculated during render only - can be inaccurate. */
_centreAngle?: number
/** Output node ID */
readonly origin_id: NodeId
/** Output slot index */
readonly origin_slot: number
}
export interface IInputOrOutput {
// If an input, this will be defined
input?: INodeInputSlot
@@ -167,6 +201,7 @@ export interface ConnectingLink extends IInputOrOutput {
slot: number
pos: Point
direction?: LinkDirection
afterRerouteId?: RerouteId
}
interface IContextMenuBase {

View File

@@ -23,7 +23,7 @@ export { INodeSlot, INodeInputSlot, INodeOutputSlot, ConnectingLink, CanvasColou
export { IWidget }
export { LGraphBadge, BadgePosition }
export { SlotShape, LabelPosition, SlotDirection, SlotType }
export { EaseFunction } from "./types/globalEnums"
export { EaseFunction, LinkMarkerShape } from "./types/globalEnums"
export type { SerialisableGraph, SerialisableLLink } from "./types/serialisation"
export { createBounds } from "./measure"

View File

@@ -37,6 +37,16 @@ export enum LinkRenderType {
SPLINE_LINK = 2,
}
/** The marker in the middle of a link */
export enum LinkMarkerShape {
/** Do not display markers */
None = 0,
/** Circles (default) */
Circle = 1,
/** Directional arrows */
Arrow = 2,
}
export enum TitleMode {
NORMAL_TITLE = 0,
NO_TITLE = 1,

View File

@@ -1,11 +1,12 @@
import type { ISlotType, Dictionary, INodeFlags, INodeInputSlot, INodeOutputSlot, Point, Rect, Size } from "../interfaces"
import type { ISlotType, Dictionary, INodeFlags, INodeInputSlot, INodeOutputSlot, Point, Size } from "../interfaces"
import type { LGraph, LGraphState } from "../LGraph"
import type { IGraphGroupFlags, LGraphGroup } from "../LGraphGroup"
import type { LGraphNode, NodeId } from "../LGraphNode"
import type { LiteGraph } from "../litegraph"
import type { LinkId, LLink } from "../LLink"
import type { RerouteId } from "../Reroute"
import type { TWidgetValue } from "../types/widgets"
import { RenderShape } from "./globalEnums"
import type { RenderShape } from "./globalEnums"
/**
* An object that implements custom pre-serialization logic via {@link Serialisable.asSerialisable}.
@@ -27,6 +28,7 @@ export interface SerialisableGraph {
groups?: ISerialisedGroup[]
nodes?: ISerialisedNode[]
links?: SerialisableLLink[]
reroutes?: SerialisableReroute[]
extra?: Record<any, any>
}
@@ -67,7 +69,7 @@ export type ISerialisedGraph<
groups: TGroup[]
config: LGraph["config"]
version: typeof LiteGraph.VERSION
extra?: unknown
extra?: Record<any, any>
}
/** Serialised LGraphGroup */
@@ -87,6 +89,14 @@ export interface IClipboardContents {
nodes?: ISerialisedNode[]
links?: TClipboardLink[]
}
export interface SerialisableReroute {
id: RerouteId
parentId?: RerouteId
pos: Point
linkIds: LinkId[]
}
export interface SerialisableLLink {
/** Link ID */
id: LinkId
@@ -100,4 +110,6 @@ export interface SerialisableLLink {
target_slot: number
/** Data type of the link */
type: ISlotType
/** ID of the last reroute (from input to output) that this link passes through, otherwise `undefined` */
parentId?: RerouteId
}

18
src/utils/collections.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Parent } from "../interfaces"
/**
* Creates a flat set of all items by recursively iterating through all child items.
* @param items The original set of items to iterate through
* @returns All items in the original set, and recursively, their children
*/
export function getAllNestedItems<TParent extends Parent<TParent>>(items: ReadonlySet<TParent>): Set<TParent> {
const allItems = new Set<TParent>()
items?.forEach(x => addRecursively(x, allItems))
return allItems
function addRecursively(item: TParent, flatSet: Set<TParent>): void {
if (flatSet.has(item)) return
flatSet.add(item)
item.children?.forEach(x => addRecursively(x, flatSet))
}
}