From 2b9d83efb81d9b9800f868e8804601ce7983c0d8 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 14 Aug 2025 21:33:34 -0400 Subject: [PATCH] Totally not scuffed renderer and adapter --- src/lib/litegraph/src/LGraphCanvas.ts | 424 +++--------- .../adapters/LitegraphLinkAdapter.ts | 531 +++++++++++++++ src/rendering/canvas/PathRenderer.ts | 630 ++++++++++++++++++ 3 files changed, 1244 insertions(+), 341 deletions(-) create mode 100644 src/rendering/adapters/LitegraphLinkAdapter.ts create mode 100644 src/rendering/canvas/PathRenderer.ts diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index cbb644cbf..0136d0646 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -1,4 +1,9 @@ import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector' +import { + type LinkRenderContext, + LitegraphLinkAdapter +} from '@/rendering/adapters/LitegraphLinkAdapter' +import { layoutStore } from '@/stores/layoutStore' import { CanvasPointer } from './CanvasPointer' import type { ContextMenu } from './ContextMenu' @@ -47,7 +52,6 @@ import { containsRect, createBounds, distance, - findPointOnCurve, isInRect, isInRectangle, isPointInRect, @@ -232,9 +236,6 @@ export class LGraphCanvas static #tmp_area = new Float32Array(4) static #margin_area = new Float32Array(4) static #link_bounding = new Float32Array(4) - static #lTempA: Point = new Float32Array(2) - static #lTempB: Point = new Float32Array(2) - static #lTempC: Point = new Float32Array(2) static DEFAULT_BACKGROUND_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=' @@ -636,6 +637,9 @@ export class LGraphCanvas /** Set on keydown, keyup. @todo */ #shiftDown: boolean = false + /** Link rendering adapter for litegraph-to-canvas integration */ + linkRenderer: LitegraphLinkAdapter | null = null + /** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */ dragZoomEnabled: boolean = false /** The start position of the drag zoom. */ @@ -697,6 +701,11 @@ export class LGraphCanvas this.ds = new DragAndScale(canvas) this.pointer = new CanvasPointer(canvas) + // Initialize link renderer if graph is available + if (graph) { + this.linkRenderer = new LitegraphLinkAdapter(layoutStore, graph) + } + this.linkConnector.events.addEventListener('link-created', () => this.#dirty() ) @@ -1790,6 +1799,9 @@ export class LGraphCanvas this.clear() newGraph.attachCanvas(this) + // Re-initialize link renderer with new graph + this.linkRenderer = new LitegraphLinkAdapter(layoutStore, newGraph) + this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph }) this.#dirty() } @@ -4538,18 +4550,26 @@ export class LGraphCanvas : LiteGraph.CONNECTING_LINK_COLOR // the connection being dragged by the mouse - this.renderLink( - ctx, - pos, - highlightPos, - null, - false, - null, - colour, - fromDirection, - dragDirection - ) + if (this.linkRenderer) { + const context = this.buildLinkRenderContext() + this.linkRenderer.renderLinkDirect( + ctx, + pos, + highlightPos, + null, + false, + null, + colour, + fromDirection, + dragDirection, + context, + { + disabled: false + } + ) + } + ctx.fillStyle = colour ctx.beginPath() if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) { ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10) @@ -5660,6 +5680,34 @@ export class LGraphCanvas } } + /** + * Build LinkRenderContext from canvas properties + * Helper method for using LitegraphLinkAdapter + */ + private buildLinkRenderContext(): LinkRenderContext { + return { + // Canvas settings + renderMode: this.links_render_mode, + connectionWidth: this.connections_width, + renderBorder: this.render_connections_border, + lowQuality: this.low_quality, + highQualityRender: this.highquality_render, + scale: this.ds.scale, + linkMarkerShape: this.linkMarkerShape, + renderConnectionArrows: this.render_connection_arrows, + + // State + highlightedLinks: new Set(Object.keys(this.highlighted_links)), + + // Colors + defaultLinkColor: this.default_link_color, + linkTypeColors: LGraphCanvas.link_type_colors, + + // Pattern for disabled links + disabledPattern: this._pattern + } + } + /** * draws a link between two points * @param ctx Canvas 2D rendering context @@ -5701,333 +5749,27 @@ export class LGraphCanvas disabled?: boolean } = {} ): void { - const linkColour = - link != null && this.highlighted_links[link.id] - ? '#FFF' - : color || - link?.color || - (link?.type != null && LGraphCanvas.link_type_colors[link.type]) || - this.default_link_color - const startDir = start_dir || LinkDirection.RIGHT - const endDir = end_dir || LinkDirection.LEFT - - const dist = - this.links_render_mode == LinkRenderType.SPLINE_LINK && - (!endControl || !startControl) - ? distance(a, b) - : 0 - - // TODO: Subline code below was inserted in the wrong place - should be before this statement - if (this.render_connections_border && !this.low_quality) { - ctx.lineWidth = this.connections_width + 4 - } - ctx.lineJoin = 'round' - num_sublines ||= 1 - if (num_sublines > 1) ctx.lineWidth = 0.5 - - // begin line shape - const path = new Path2D() - - /** The link or reroute we're currently rendering */ - const linkSegment = reroute ?? link - 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. */ - const pos: Point = linkSegment?._pos ?? [0, 0] - - for (let i = 0; i < num_sublines; i++) { - const offsety = (i - (num_sublines - 1) * 0.5) * 5 - innerA[0] = a[0] - innerA[1] = a[1] - innerB[0] = b[0] - innerB[1] = b[1] - - if (this.links_render_mode == LinkRenderType.SPLINE_LINK) { - if (endControl) { - innerB[0] = b[0] + endControl[0] - innerB[1] = b[1] + endControl[1] - } else { - this.#addSplineOffset(innerB, endDir, dist) + if (this.linkRenderer) { + const context = this.buildLinkRenderContext() + this.linkRenderer.renderLinkDirect( + ctx, + a, + b, + link, + skip_border, + flow, + color, + start_dir, + end_dir, + context, + { + reroute, + startControl, + endControl, + num_sublines, + disabled } - if (startControl) { - innerA[0] = a[0] + startControl[0] - innerA[1] = a[1] + startControl[1] - } else { - this.#addSplineOffset(innerA, startDir, dist) - } - path.moveTo(a[0], a[1] + offsety) - path.bezierCurveTo( - innerA[0], - innerA[1] + offsety, - innerB[0], - innerB[1] + offsety, - b[0], - b[1] + offsety - ) - - // Calculate centre point - findPointOnCurve(pos, a, b, innerA, innerB, 0.5) - - if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { - const justPastCentre = LGraphCanvas.#lTempC - findPointOnCurve(justPastCentre, a, b, innerA, innerB, 0.51) - - linkSegment._centreAngle = Math.atan2( - justPastCentre[1] - pos[1], - justPastCentre[0] - pos[0] - ) - } - } else { - const l = this.links_render_mode == LinkRenderType.LINEAR_LINK ? 15 : 10 - switch (startDir) { - case LinkDirection.LEFT: - innerA[0] += -l - break - case LinkDirection.RIGHT: - innerA[0] += l - break - case LinkDirection.UP: - innerA[1] += -l - break - case LinkDirection.DOWN: - innerA[1] += l - break - } - switch (endDir) { - case LinkDirection.LEFT: - innerB[0] += -l - break - case LinkDirection.RIGHT: - innerB[0] += l - break - case LinkDirection.UP: - innerB[1] += -l - break - case LinkDirection.DOWN: - innerB[1] += l - break - } - if (this.links_render_mode == LinkRenderType.LINEAR_LINK) { - path.moveTo(a[0], a[1] + offsety) - path.lineTo(innerA[0], innerA[1] + offsety) - path.lineTo(innerB[0], innerB[1] + offsety) - path.lineTo(b[0], b[1] + offsety) - - // Calculate centre point - pos[0] = (innerA[0] + innerB[0]) * 0.5 - pos[1] = (innerA[1] + innerB[1]) * 0.5 - - if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { - linkSegment._centreAngle = Math.atan2( - innerB[1] - innerA[1], - innerB[0] - innerA[0] - ) - } - } else if (this.links_render_mode == LinkRenderType.STRAIGHT_LINK) { - const midX = (innerA[0] + innerB[0]) * 0.5 - - path.moveTo(a[0], a[1]) - path.lineTo(innerA[0], innerA[1]) - path.lineTo(midX, innerA[1]) - path.lineTo(midX, innerB[1]) - path.lineTo(innerB[0], innerB[1]) - path.lineTo(b[0], b[1]) - - // Calculate centre point - pos[0] = midX - pos[1] = (innerA[1] + innerB[1]) * 0.5 - - if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { - const diff = innerB[1] - innerA[1] - if (Math.abs(diff) < 4) linkSegment._centreAngle = 0 - else if (diff > 0) linkSegment._centreAngle = Math.PI * 0.5 - else linkSegment._centreAngle = -(Math.PI * 0.5) - } - } else { - return - } - } - } - - // rendering the outline of the connection can be a little bit slow - if (this.render_connections_border && !this.low_quality && !skip_border) { - ctx.strokeStyle = 'rgba(0,0,0,0.5)' - ctx.stroke(path) - } - - ctx.lineWidth = this.connections_width - ctx.fillStyle = ctx.strokeStyle = linkColour - ctx.stroke(path) - - // render arrow in the middle - if (this.ds.scale >= 0.6 && this.highquality_render && linkSegment) { - // render arrow - if (this.render_connection_arrows) { - // compute two points in the connection - const posA = this.computeConnectionPoint(a, b, 0.25, startDir, endDir) - const posB = this.computeConnectionPoint(a, b, 0.26, startDir, endDir) - const posC = this.computeConnectionPoint(a, b, 0.75, startDir, endDir) - const posD = this.computeConnectionPoint(a, b, 0.76, startDir, endDir) - - // compute the angle between them so the arrow points in the right direction - let angleA = 0 - let angleB = 0 - if (this.render_curved_connections) { - angleA = -Math.atan2(posB[0] - posA[0], posB[1] - posA[1]) - angleB = -Math.atan2(posD[0] - posC[0], posD[1] - posC[1]) - } else { - angleB = angleA = b[1] > a[1] ? 0 : Math.PI - } - - // render arrow - const transform = ctx.getTransform() - ctx.translate(posA[0], posA[1]) - ctx.rotate(angleA) - ctx.beginPath() - ctx.moveTo(-5, -3) - ctx.lineTo(0, +7) - ctx.lineTo(+5, -3) - ctx.fill() - ctx.setTransform(transform) - - ctx.translate(posC[0], posC[1]) - ctx.rotate(angleB) - ctx.beginPath() - ctx.moveTo(-5, -3) - ctx.lineTo(0, +7) - ctx.lineTo(+5, -3) - ctx.fill() - ctx.setTransform(transform) - } - - // Draw link centre marker - ctx.beginPath() - if (this.linkMarkerShape === LinkMarkerShape.Arrow) { - const transform = ctx.getTransform() - ctx.translate(pos[0], pos[1]) - if (linkSegment._centreAngle) ctx.rotate(linkSegment._centreAngle) - // The math is off, but it currently looks better in chromium - ctx.moveTo(-3.2, -5) - ctx.lineTo(+7, 0) - ctx.lineTo(-3.2, +5) - ctx.setTransform(transform) - } else if ( - this.linkMarkerShape == null || - this.linkMarkerShape === LinkMarkerShape.Circle - ) { - ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2) - } - if (disabled) { - const { fillStyle, globalAlpha } = ctx - ctx.fillStyle = this._pattern ?? '#797979' - ctx.globalAlpha = 0.75 - ctx.fill() - ctx.globalAlpha = globalAlpha - ctx.fillStyle = fillStyle - } - ctx.fill() - - if (LLink._drawDebug) { - const { fillStyle, font, globalAlpha, lineWidth, strokeStyle } = ctx - ctx.globalAlpha = 1 - ctx.lineWidth = 4 - ctx.fillStyle = 'white' - ctx.strokeStyle = 'black' - ctx.font = '16px Arial' - - const text = String(linkSegment.id) - const { width, actualBoundingBoxAscent } = ctx.measureText(text) - const x = pos[0] - width * 0.5 - const y = pos[1] + actualBoundingBoxAscent * 0.5 - ctx.strokeText(text, x, y) - ctx.fillText(text, x, y) - - ctx.font = font - ctx.globalAlpha = globalAlpha - ctx.lineWidth = lineWidth - ctx.fillStyle = fillStyle - ctx.strokeStyle = strokeStyle - } - } - - // render flowing points - if (flow) { - ctx.fillStyle = linkColour - for (let i = 0; i < 5; ++i) { - const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1 - const flowPos = this.computeConnectionPoint(a, b, f, startDir, endDir) - ctx.beginPath() - ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI) - ctx.fill() - } - } - } - - /** - * Finds a point along a spline represented by a to b, with spline endpoint directions dictacted by start_dir and end_dir. - * @param a Start point - * @param b End point - * @param t Time: distance between points (e.g 0.25 is 25% along the line) - * @param start_dir Spline start direction - * @param end_dir Spline end direction - * @returns The point at {@link t} distance along the spline a-b. - */ - computeConnectionPoint( - a: ReadOnlyPoint, - b: ReadOnlyPoint, - t: number, - start_dir: LinkDirection, - end_dir: LinkDirection - ): Point { - start_dir ||= LinkDirection.RIGHT - end_dir ||= LinkDirection.LEFT - - const dist = distance(a, b) - const pa: Point = [a[0], a[1]] - const pb: Point = [b[0], b[1]] - - this.#addSplineOffset(pa, start_dir, dist) - this.#addSplineOffset(pb, end_dir, dist) - - const c1 = (1 - t) * (1 - t) * (1 - t) - const c2 = 3 * ((1 - t) * (1 - t)) * t - const c3 = 3 * (1 - t) * (t * t) - const c4 = t * t * t - - const x = c1 * a[0] + c2 * pa[0] + c3 * pb[0] + c4 * b[0] - const y = c1 * a[1] + c2 * pa[1] + c3 * pb[1] + c4 * b[1] - return [x, y] - } - - /** - * Modifies an existing point, adding a single-axis offset. - * @param point The point to add the offset to - * @param direction The direction to add the offset in - * @param dist Distance to offset - * @param factor Distance is mulitplied by this value. Default: 0.25 - */ - #addSplineOffset( - point: Point, - direction: LinkDirection, - dist: number, - factor = 0.25 - ): void { - switch (direction) { - case LinkDirection.LEFT: - point[0] += dist * -factor - break - case LinkDirection.RIGHT: - point[0] += dist * factor - break - case LinkDirection.UP: - point[1] += dist * -factor - break - case LinkDirection.DOWN: - point[1] += dist * factor - break + ) } } diff --git a/src/rendering/adapters/LitegraphLinkAdapter.ts b/src/rendering/adapters/LitegraphLinkAdapter.ts new file mode 100644 index 000000000..b957d3cc5 --- /dev/null +++ b/src/rendering/adapters/LitegraphLinkAdapter.ts @@ -0,0 +1,531 @@ +/** + * Litegraph Link Adapter + * + * Bridges the gap between litegraph's data model and the pure canvas renderer. + * Converts litegraph-specific types (LLink, LGraphNode, slots) into generic + * rendering data that can be consumed by the PathRenderer. + * Maintains backward compatibility with existing litegraph integration. + */ +import type { LGraph } from '@/lib/litegraph/src/LGraph' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import { LLink } from '@/lib/litegraph/src/LLink' +import type { Reroute } from '@/lib/litegraph/src/Reroute' +import type { + CanvasColour, + INodeInputSlot, + INodeOutputSlot, + ISlotType, + ReadOnlyPoint +} from '@/lib/litegraph/src/interfaces' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import { + LinkDirection, + LinkMarkerShape, + LinkRenderType +} from '@/lib/litegraph/src/types/globalEnums' +import { + type ArrowShape, + CanvasPathRenderer, + type Direction, + type DragLinkData, + type LinkRenderData, + type RenderContext as PathRenderContext, + type RenderMode +} from '@/rendering/canvas/PathRenderer' +import type { LayoutStore, Point } from '@/types/layoutTypes' + +export interface LinkRenderContext { + // Canvas settings + renderMode: LinkRenderType + connectionWidth: number + renderBorder: boolean + lowQuality: boolean + highQualityRender: boolean + scale: number + linkMarkerShape: LinkMarkerShape + renderConnectionArrows: boolean + + // State + highlightedLinks: Set + + // Colors + defaultLinkColor: CanvasColour + linkTypeColors: Record + + // Pattern for disabled links (optional) + disabledPattern?: CanvasPattern | null +} + +export interface LinkRenderOptions { + color?: CanvasColour + flow?: boolean + skipBorder?: boolean + disabled?: boolean +} + +export class LitegraphLinkAdapter { + private layoutStore: LayoutStore + private graph: LGraph + private pathRenderer: CanvasPathRenderer + + constructor(layoutStore: LayoutStore, graph: LGraph) { + this.layoutStore = layoutStore + this.graph = graph + this.pathRenderer = new CanvasPathRenderer() + } + + /** + * Render a single link with all necessary data properly fetched + * Populates link.path for hit detection + */ + renderLink( + ctx: CanvasRenderingContext2D, + link: LLink, + context: LinkRenderContext, + options: LinkRenderOptions = {} + ): void { + // Get nodes from graph + const sourceNode = this.graph.getNodeById(link.origin_id) + const targetNode = this.graph.getNodeById(link.target_id) + + if (!sourceNode || !targetNode) { + console.warn(`Cannot render link ${link.id}: missing nodes`) + return + } + + // Get slots from nodes + const sourceSlot = sourceNode.outputs?.[link.origin_slot] + const targetSlot = targetNode.inputs?.[link.target_slot] + + if (!sourceSlot || !targetSlot) { + console.warn(`Cannot render link ${link.id}: missing slots`) + return + } + + // Get positions from nodes + const startPos = sourceNode.getOutputPos(link.origin_slot) + const endPos = targetNode.getInputPos(link.target_slot) + + // Get directions from slots + const startDir = sourceSlot.dir || LinkDirection.RIGHT + const endDir = targetSlot.dir || LinkDirection.LEFT + + // Convert to pure render data + const linkData = this.convertToLinkRenderData( + link, + { x: startPos[0], y: startPos[1] }, + { x: endPos[0], y: endPos[1] }, + startDir, + endDir, + options + ) + + // Convert context + const pathContext = this.convertToPathRenderContext(context) + + // Render using pure renderer + const path = this.pathRenderer.drawLink(ctx, linkData, pathContext) + + // Store path for hit detection + link.path = path + } + + /** + * Convert litegraph link data to pure render format + */ + private convertToLinkRenderData( + link: LLink, + startPoint: Point, + endPoint: Point, + startDir: LinkDirection, + endDir: LinkDirection, + options: LinkRenderOptions + ): LinkRenderData { + return { + id: String(link.id), + startPoint, + endPoint, + startDirection: this.convertDirection(startDir), + endDirection: this.convertDirection(endDir), + color: options.color + ? String(options.color) + : link.color + ? String(link.color) + : undefined, + type: link.type !== undefined ? String(link.type) : undefined, + flow: options.flow || false, + disabled: options.disabled || false + } + } + + /** + * Convert LinkDirection enum to Direction string + */ + private convertDirection(dir: LinkDirection): Direction { + switch (dir) { + case LinkDirection.LEFT: + return 'left' + case LinkDirection.RIGHT: + return 'right' + case LinkDirection.UP: + return 'up' + case LinkDirection.DOWN: + return 'down' + default: + return 'right' + } + } + + /** + * Convert LinkRenderContext to PathRenderContext + */ + private convertToPathRenderContext( + context: LinkRenderContext + ): PathRenderContext { + return { + style: { + mode: this.convertRenderMode(context.renderMode), + connectionWidth: context.connectionWidth, + borderWidth: context.renderBorder ? 4 : undefined, + arrowShape: this.convertArrowShape(context.linkMarkerShape), + showArrows: context.renderConnectionArrows, + lowQuality: context.lowQuality, + // Center marker settings (matches original litegraph behavior) + showCenterMarker: true, + centerMarkerShape: + context.linkMarkerShape === LinkMarkerShape.Arrow + ? 'arrow' + : 'circle', + highQuality: context.highQualityRender + }, + colors: { + default: String(context.defaultLinkColor), + byType: this.convertColorMap(context.linkTypeColors), + highlighted: '#FFF' + }, + patterns: { + disabled: context.disabledPattern + }, + animation: { + time: LiteGraph.getTime() * 0.001 + }, + scale: context.scale, + highlightedIds: new Set(Array.from(context.highlightedLinks).map(String)) + } + } + + /** + * Convert LinkRenderType to RenderMode + */ + private convertRenderMode(mode: LinkRenderType): RenderMode { + switch (mode) { + case LinkRenderType.LINEAR_LINK: + return 'linear' + case LinkRenderType.STRAIGHT_LINK: + return 'straight' + case LinkRenderType.SPLINE_LINK: + default: + return 'spline' + } + } + + /** + * Convert LinkMarkerShape to ArrowShape + */ + private convertArrowShape(shape: LinkMarkerShape): ArrowShape { + switch (shape) { + case LinkMarkerShape.Circle: + return 'circle' + case LinkMarkerShape.Arrow: + default: + return 'triangle' + } + } + + /** + * Convert color map to ensure all values are strings + */ + private convertColorMap( + colors: Record + ): Record { + const result: Record = {} + for (const [key, value] of Object.entries(colors)) { + result[key] = String(value) + } + return result + } + + /** + * Apply spline offset to a point, mimicking original #addSplineOffset behavior + * Critically: does nothing for CENTER/NONE directions (no case for them) + */ + private applySplineOffset( + point: Point, + direction: LinkDirection, + distance: number + ): void { + switch (direction) { + case LinkDirection.LEFT: + point.x -= distance + break + case LinkDirection.RIGHT: + point.x += distance + break + case LinkDirection.UP: + point.y -= distance + break + case LinkDirection.DOWN: + point.y += distance + break + // CENTER and NONE: no offset applied (original behavior) + } + } + + /** + * Direct rendering method compatible with LGraphCanvas + * Converts data and delegates to pure renderer + */ + renderLinkDirect( + ctx: CanvasRenderingContext2D, + a: ReadOnlyPoint, + b: ReadOnlyPoint, + link: LLink | null, + skip_border: boolean, + flow: number | boolean | null, + color: CanvasColour | null, + start_dir: LinkDirection, + end_dir: LinkDirection, + context: LinkRenderContext, + extras: { + reroute?: Reroute + startControl?: ReadOnlyPoint + endControl?: ReadOnlyPoint + num_sublines?: number + disabled?: boolean + } = {} + ): void { + // Apply same defaults as original renderLink + const startDir = start_dir || LinkDirection.RIGHT + const endDir = end_dir || LinkDirection.LEFT + + // Convert flow to boolean + const flowBool = flow === true || (typeof flow === 'number' && flow > 0) + + // Create LinkRenderData from direct parameters + const linkData: LinkRenderData = { + id: link ? String(link.id) : 'temp', + startPoint: { x: a[0], y: a[1] }, + endPoint: { x: b[0], y: b[1] }, + startDirection: this.convertDirection(startDir), + endDirection: this.convertDirection(endDir), + color: color !== null && color !== undefined ? String(color) : undefined, + type: link?.type !== undefined ? String(link.type) : undefined, + flow: flowBool, + disabled: extras.disabled || false + } + + // Control points handling (spline mode): + // - Pre-refactor, the old renderLink honored a single provided control and + // derived the missing side via #addSplineOffset (CENTER => no offset). + // - Restore that behavior here so reroute segments render identically. + if (context.renderMode === LinkRenderType.SPLINE_LINK) { + const hasStartCtrl = !!extras.startControl + const hasEndCtrl = !!extras.endControl + + // Compute distance once for offsets + const dist = Math.sqrt( + (b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1]) + ) + const factor = 0.25 + + const cps: Point[] = [] + + if (hasStartCtrl && hasEndCtrl) { + // Both provided explicitly + cps.push( + { + x: a[0] + (extras.startControl![0] || 0), + y: a[1] + (extras.startControl![1] || 0) + }, + { + x: b[0] + (extras.endControl![0] || 0), + y: b[1] + (extras.endControl![1] || 0) + } + ) + linkData.controlPoints = cps + } else if (hasStartCtrl && !hasEndCtrl) { + // Start provided, derive end via direction offset (CENTER => no offset) + const start = { + x: a[0] + (extras.startControl![0] || 0), + y: a[1] + (extras.startControl![1] || 0) + } + const end = { x: b[0], y: b[1] } + this.applySplineOffset(end, endDir, dist * factor) + cps.push(start, end) + linkData.controlPoints = cps + } else if (!hasStartCtrl && hasEndCtrl) { + // End provided, derive start via direction offset (CENTER => no offset) + const start = { x: a[0], y: a[1] } + this.applySplineOffset(start, startDir, dist * factor) + const end = { + x: b[0] + (extras.endControl![0] || 0), + y: b[1] + (extras.endControl![1] || 0) + } + cps.push(start, end) + linkData.controlPoints = cps + } else { + // Neither provided: derive both from directions (CENTER => no offset) + const start = { x: a[0], y: a[1] } + const end = { x: b[0], y: b[1] } + this.applySplineOffset(start, startDir, dist * factor) + this.applySplineOffset(end, endDir, dist * factor) + cps.push(start, end) + linkData.controlPoints = cps + } + } + + // Convert context + const pathContext = this.convertToPathRenderContext(context) + + // Override skip_border if needed + if (skip_border) { + pathContext.style.borderWidth = undefined + } + + // Render using pure renderer + const path = this.pathRenderer.drawLink(ctx, linkData, pathContext) + + // Store path for hit detection + const linkSegment = extras.reroute ?? link + if (linkSegment) { + linkSegment.path = path + + // Copy calculated center position back to litegraph object + // This is needed for hit detection and menu interaction + if (linkData.centerPos) { + linkSegment._pos = linkSegment._pos || new Float32Array(2) + linkSegment._pos[0] = linkData.centerPos.x + linkSegment._pos[1] = linkData.centerPos.y + + // Store center angle if calculated (for arrow markers) + if (linkData.centerAngle !== undefined) { + linkSegment._centreAngle = linkData.centerAngle + } + } + } + } + + /** + * Render a link being dragged from a slot to mouse position + * Used during link creation/reconnection + */ + renderDraggingLink( + ctx: CanvasRenderingContext2D, + fromNode: LGraphNode | null, + fromSlot: INodeOutputSlot | INodeInputSlot, + fromSlotIndex: number, + toPosition: ReadOnlyPoint, + context: LinkRenderContext, + options: { + fromInput?: boolean + color?: CanvasColour + disabled?: boolean + } = {} + ): void { + if (!fromNode) return + + // Get slot position + const slotPos = options.fromInput + ? fromNode.getInputPos(fromSlotIndex) + : fromNode.getOutputPos(fromSlotIndex) + if (!slotPos) return + + // Get slot direction + const slotDir = + fromSlot.dir || + (options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT) + + // Create drag data + const dragData: DragLinkData = { + fixedPoint: { x: slotPos[0], y: slotPos[1] }, + fixedDirection: this.convertDirection(slotDir), + dragPoint: { x: toPosition[0], y: toPosition[1] }, + color: options.color ? String(options.color) : undefined, + type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined, + disabled: options.disabled || false, + fromInput: options.fromInput || false + } + + // Convert context + const pathContext = this.convertToPathRenderContext(context) + + // Render using pure renderer + this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext) + } + + /** + * Render a dragging link with direct position data + * More flexible version for complex drag scenarios + */ + renderDraggingLinkDirect( + ctx: CanvasRenderingContext2D, + fromPos: ReadOnlyPoint, + fromDir: LinkDirection, + toPos: ReadOnlyPoint, + context: LinkRenderContext, + options: { + fromInput?: boolean + toDir?: LinkDirection + color?: CanvasColour + type?: ISlotType + disabled?: boolean + } = {} + ): void { + // Use renderLinkDirect which already handles CENTER/NONE correctly + this.renderLinkDirect( + ctx, + fromPos, + toPos, + null, // no link + false, // skip_border + null, // flow + options.color || null, + fromDir, + options.toDir || LinkDirection.CENTER, // Default to CENTER for drag end + context, + { + disabled: options.disabled || false + } + ) + } + + /** + * Get slot position helper - may be useful for Vue components + */ + getSlotAbsolutePosition( + nodeId: string, + slotType: 'input' | 'output', + slotIndex: number + ): Point | null { + // Get node from graph + const node = this.graph.getNodeById(nodeId) + if (!node) return null + + // Get node position from layout tree + const nodeLayout = this.layoutStore.getNodeLayoutRef(nodeId).value + if (!nodeLayout) return null + + // Get relative slot position from node + const relativePos = + slotType === 'input' + ? node.getInputPos(slotIndex) + : node.getOutputPos(slotIndex) + if (!relativePos) return null + + // Combine to get absolute position + return { + x: nodeLayout.position.x + relativePos[0], + y: nodeLayout.position.y + relativePos[1] + } + } +} diff --git a/src/rendering/canvas/PathRenderer.ts b/src/rendering/canvas/PathRenderer.ts new file mode 100644 index 000000000..47fd16453 --- /dev/null +++ b/src/rendering/canvas/PathRenderer.ts @@ -0,0 +1,630 @@ +/** + * Path Renderer + * + * Pure canvas2D rendering utility with no framework dependencies. + * Renders bezier curves, straight lines, and linear connections between points. + * Supports arrows, flow animations, and returns Path2D objects for hit detection. + * Can be reused in any canvas-based project without modification. + */ + +export interface Point { + x: number + y: number +} + +export type Direction = 'left' | 'right' | 'up' | 'down' +export type RenderMode = 'spline' | 'straight' | 'linear' +export type ArrowShape = 'triangle' | 'circle' | 'square' + +export interface LinkRenderData { + id: string + startPoint: Point + endPoint: Point + startDirection: Direction + endDirection: Direction + color?: string + type?: string + controlPoints?: Point[] + flow?: boolean + disabled?: boolean + // Optional multi-segment support + segments?: Array<{ + start: Point + end: Point + controlPoints?: Point[] + }> + // Center point storage (for hit detection and menu) + centerPos?: Point + centerAngle?: number +} + +export interface RenderStyle { + mode: RenderMode + connectionWidth: number + borderWidth?: number + arrowShape?: ArrowShape + showArrows?: boolean + lowQuality?: boolean + // Center marker properties + showCenterMarker?: boolean + centerMarkerShape?: 'circle' | 'arrow' + highQuality?: boolean +} + +export interface RenderColors { + default: string + byType: Record + highlighted: string +} + +export interface RenderContext { + style: RenderStyle + colors: RenderColors + patterns?: { + disabled?: CanvasPattern | null + } + animation?: { + time: number // Seconds for flow animation + } + scale?: number // Canvas scale for quality adjustments + highlightedIds?: Set +} + +export interface DragLinkData { + /** Fixed end - the slot being dragged from */ + fixedPoint: Point + fixedDirection: Direction + /** Moving end - follows mouse */ + dragPoint: Point + dragDirection?: Direction + /** Visual properties */ + color?: string + type?: string + disabled?: boolean + /** Whether dragging from input (reverse direction) */ + fromInput?: boolean +} + +export class CanvasPathRenderer { + /** + * Draw a link between two points + * Returns a Path2D object for hit detection + */ + drawLink( + ctx: CanvasRenderingContext2D, + link: LinkRenderData, + context: RenderContext + ): Path2D { + const path = new Path2D() + + // Determine final color + const isHighlighted = context.highlightedIds?.has(link.id) ?? false + const color = this.determineLinkColor(link, context, isHighlighted) + + // Save context state + ctx.save() + + // Apply disabled pattern if needed + if (link.disabled && context.patterns?.disabled) { + ctx.strokeStyle = context.patterns.disabled + } else { + ctx.strokeStyle = color + } + + // Set line properties + ctx.lineWidth = context.style.connectionWidth + ctx.lineJoin = 'round' + + // Draw border if needed + if (context.style.borderWidth && !context.style.lowQuality) { + this.drawLinkPath( + ctx, + path, + link, + context, + context.style.connectionWidth + context.style.borderWidth, + 'rgba(0,0,0,0.5)' + ) + } + + // Draw main link + this.drawLinkPath( + ctx, + path, + link, + context, + context.style.connectionWidth, + color + ) + + // Calculate and store center position + this.calculateCenterPoint(link, context) + + // Draw arrows if needed + if (context.style.showArrows) { + this.drawArrows(ctx, link, context, color) + } + + // Draw center marker if needed (for link menu interaction) + if ( + context.style.showCenterMarker && + context.scale && + context.scale >= 0.6 && + context.style.highQuality + ) { + this.drawCenterMarker(ctx, link, context, color) + } + + // Draw flow animation if needed + if (link.flow && context.animation) { + this.drawFlowAnimation(ctx, path, link, context) + } + + ctx.restore() + + return path + } + + private determineLinkColor( + link: LinkRenderData, + context: RenderContext, + isHighlighted: boolean + ): string { + if (isHighlighted) { + return context.colors.highlighted + } + if (link.color) { + return link.color + } + if (link.type && context.colors.byType[link.type]) { + return context.colors.byType[link.type] + } + return context.colors.default + } + + private drawLinkPath( + ctx: CanvasRenderingContext2D, + path: Path2D, + link: LinkRenderData, + context: RenderContext, + lineWidth: number, + color: string + ): void { + ctx.strokeStyle = color + ctx.lineWidth = lineWidth + + const start = link.startPoint + const end = link.endPoint + + // Build the path based on render mode + if (context.style.mode === 'linear') { + this.buildLinearPath(path, start, end) + } else if (context.style.mode === 'straight') { + this.buildStraightPath( + path, + start, + end, + link.startDirection, + link.endDirection + ) + } else { + // Spline mode (default) + this.buildSplinePath( + path, + start, + end, + link.startDirection, + link.endDirection, + link.controlPoints + ) + } + + ctx.stroke(path) + } + + private buildLinearPath(path: Path2D, start: Point, end: Point): void { + path.moveTo(start.x, start.y) + path.lineTo(end.x, end.y) + } + + private buildStraightPath( + path: Path2D, + start: Point, + end: Point, + _startDir: Direction, + _endDir: Direction + ): void { + path.moveTo(start.x, start.y) + + const dx = end.x - start.x + const dy = end.y - start.y + + if (Math.abs(dx) > Math.abs(dy)) { + const midX = start.x + dx * 0.5 + path.lineTo(midX, start.y) + path.lineTo(midX, end.y) + } else { + const midY = start.y + dy * 0.5 + path.lineTo(start.x, midY) + path.lineTo(end.x, midY) + } + + path.lineTo(end.x, end.y) + } + + private buildSplinePath( + path: Path2D, + start: Point, + end: Point, + startDir: Direction, + endDir: Direction, + controlPoints?: Point[] + ): void { + path.moveTo(start.x, start.y) + + // Calculate control points if not provided + const controls = + controlPoints || this.calculateControlPoints(start, end, startDir, endDir) + + if (controls.length >= 2) { + // Cubic bezier + path.bezierCurveTo( + controls[0].x, + controls[0].y, + controls[1].x, + controls[1].y, + end.x, + end.y + ) + } else if (controls.length === 1) { + // Quadratic bezier + path.quadraticCurveTo(controls[0].x, controls[0].y, end.x, end.y) + } else { + // Fallback to linear + path.lineTo(end.x, end.y) + } + } + + private calculateControlPoints( + start: Point, + end: Point, + startDir: Direction, + endDir: Direction + ): Point[] { + const dist = Math.sqrt( + Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2) + ) + const controlDist = Math.max(30, dist * 0.25) + + // Calculate control point offsets based on direction + const startControl = this.getDirectionOffset(startDir, controlDist) + const endControl = this.getDirectionOffset(endDir, controlDist) + + return [ + { x: start.x + startControl.x, y: start.y + startControl.y }, + { x: end.x + endControl.x, y: end.y + endControl.y } + ] + } + + private getDirectionOffset(direction: Direction, distance: number): Point { + switch (direction) { + case 'left': + return { x: -distance, y: 0 } + case 'right': + return { x: distance, y: 0 } + case 'up': + return { x: 0, y: -distance } + case 'down': + return { x: 0, y: distance } + } + } + + private drawArrows( + ctx: CanvasRenderingContext2D, + link: LinkRenderData, + context: RenderContext, + color: string + ): void { + if (!context.style.showArrows) return + + const arrowSize = 5 * (context.scale || 1) + const shape = context.style.arrowShape || 'triangle' + + // Calculate arrow position (middle of link for now) + const mid = { + x: (link.startPoint.x + link.endPoint.x) / 2, + y: (link.startPoint.y + link.endPoint.y) / 2 + } + + // Calculate angle + const angle = Math.atan2( + link.endPoint.y - link.startPoint.y, + link.endPoint.x - link.startPoint.x + ) + + ctx.save() + ctx.translate(mid.x, mid.y) + ctx.rotate(angle) + ctx.fillStyle = color + + if (shape === 'circle') { + ctx.beginPath() + ctx.arc(0, 0, arrowSize, 0, Math.PI * 2) + ctx.fill() + } else if (shape === 'square') { + ctx.fillRect(-arrowSize / 2, -arrowSize / 2, arrowSize, arrowSize) + } else { + // Triangle (default) + ctx.beginPath() + ctx.moveTo(arrowSize, 0) + ctx.lineTo(-arrowSize, -arrowSize) + ctx.lineTo(-arrowSize, arrowSize) + ctx.closePath() + ctx.fill() + } + + ctx.restore() + } + + private drawFlowAnimation( + ctx: CanvasRenderingContext2D, + path: Path2D, + _link: LinkRenderData, + context: RenderContext + ): void { + if (!context.animation) return + + const time = context.animation.time + const spacing = 24 + const speed = 48 + + ctx.save() + ctx.strokeStyle = context.colors.highlighted + ctx.lineWidth = Math.max(1, context.style.connectionWidth * 0.5) + + // Create dashed line effect for flow + const dashOffset = (time * speed) % spacing + ctx.setLineDash([4, spacing - 4]) + ctx.lineDashOffset = -dashOffset + + ctx.stroke(path) + ctx.restore() + } + + /** + * Utility to find a point on a bezier curve (for hit detection) + */ + findPointOnBezier( + t: number, + p0: Point, + p1: Point, + p2: Point, + p3: Point + ): Point { + const mt = 1 - t + const mt2 = mt * mt + const mt3 = mt2 * mt + const t2 = t * t + const t3 = t2 * t + + return { + x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x, + y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y + } + } + + /** + * Draw a link being dragged from a slot to the mouse position + * Returns a Path2D object for potential hit detection + */ + drawDraggingLink( + ctx: CanvasRenderingContext2D, + dragData: DragLinkData, + context: RenderContext + ): Path2D { + // Create LinkRenderData from drag data + // When dragging from input, swap the points/directions + const linkData: LinkRenderData = dragData.fromInput + ? { + id: 'dragging', + startPoint: dragData.dragPoint, + endPoint: dragData.fixedPoint, + startDirection: + dragData.dragDirection || + this.getOppositeDirection(dragData.fixedDirection), + endDirection: dragData.fixedDirection, + color: dragData.color, + type: dragData.type, + disabled: dragData.disabled + } + : { + id: 'dragging', + startPoint: dragData.fixedPoint, + endPoint: dragData.dragPoint, + startDirection: dragData.fixedDirection, + endDirection: + dragData.dragDirection || + this.getOppositeDirection(dragData.fixedDirection), + color: dragData.color, + type: dragData.type, + disabled: dragData.disabled + } + + // Use standard link drawing + return this.drawLink(ctx, linkData, context) + } + + /** + * Get the opposite direction (for drag preview) + */ + private getOppositeDirection(direction: Direction): Direction { + switch (direction) { + case 'left': + return 'right' + case 'right': + return 'left' + case 'up': + return 'down' + case 'down': + return 'up' + } + } + + /** + * Get the center point of a link (useful for labels, debugging) + */ + getLinkCenter(link: LinkRenderData): Point { + // For now, simple midpoint + // Could be enhanced to find actual curve midpoint + return { + x: (link.startPoint.x + link.endPoint.x) / 2, + y: (link.startPoint.y + link.endPoint.y) / 2 + } + } + + /** + * Calculate and store the center point and angle of a link + * Mimics the original litegraph center point calculation + */ + private calculateCenterPoint( + link: LinkRenderData, + context: RenderContext + ): void { + const { startPoint, endPoint, controlPoints } = link + + if ( + context.style.mode === 'spline' && + controlPoints && + controlPoints.length >= 2 + ) { + // For spline mode, find point at t=0.5 on the bezier curve + const centerPos = this.findPointOnBezier( + 0.5, + startPoint, + controlPoints[0], + controlPoints[1], + endPoint + ) + link.centerPos = centerPos + + // Calculate angle for arrow marker (point slightly past center) + if (context.style.centerMarkerShape === 'arrow') { + const justPastCenter = this.findPointOnBezier( + 0.51, + startPoint, + controlPoints[0], + controlPoints[1], + endPoint + ) + link.centerAngle = Math.atan2( + justPastCenter.y - centerPos.y, + justPastCenter.x - centerPos.x + ) + } + } else if (context.style.mode === 'linear') { + // For linear mode, calculate midpoint between control points + const startControl = this.getDirectionOffset(link.startDirection, 15) + const endControl = this.getDirectionOffset(link.endDirection, 15) + const innerA = { + x: startPoint.x + startControl.x, + y: startPoint.y + startControl.y + } + const innerB = { + x: endPoint.x + endControl.x, + y: endPoint.y + endControl.y + } + + link.centerPos = { + x: (innerA.x + innerB.x) * 0.5, + y: (innerA.y + innerB.y) * 0.5 + } + + if (context.style.centerMarkerShape === 'arrow') { + link.centerAngle = Math.atan2(innerB.y - innerA.y, innerB.x - innerA.x) + } + } else if (context.style.mode === 'straight') { + // For straight mode, calculate midpoint + const dx = endPoint.x - startPoint.x + const dy = endPoint.y - startPoint.y + + if (Math.abs(dx) > Math.abs(dy)) { + const midX = startPoint.x + dx * 0.5 + link.centerPos = { + x: midX, + y: (startPoint.y + endPoint.y) * 0.5 + } + } else { + const midY = startPoint.y + dy * 0.5 + link.centerPos = { + x: (startPoint.x + endPoint.x) * 0.5, + y: midY + } + } + + if (context.style.centerMarkerShape === 'arrow') { + const diff = endPoint.y - startPoint.y + if (Math.abs(diff) < 4) { + link.centerAngle = 0 + } else if (diff > 0) { + link.centerAngle = Math.PI * 0.5 + } else { + link.centerAngle = -(Math.PI * 0.5) + } + } + } else { + // Fallback to simple midpoint + link.centerPos = this.getLinkCenter(link) + if (context.style.centerMarkerShape === 'arrow') { + link.centerAngle = Math.atan2( + endPoint.y - startPoint.y, + endPoint.x - startPoint.x + ) + } + } + } + + /** + * Draw the center marker on a link (for menu interaction) + * Matches the original litegraph center marker rendering + */ + private drawCenterMarker( + ctx: CanvasRenderingContext2D, + link: LinkRenderData, + context: RenderContext, + color: string + ): void { + if (!link.centerPos) return + + ctx.beginPath() + + if ( + context.style.centerMarkerShape === 'arrow' && + link.centerAngle !== undefined + ) { + const transform = ctx.getTransform() + ctx.translate(link.centerPos.x, link.centerPos.y) + ctx.rotate(link.centerAngle) + // The math is off, but it currently looks better in chromium (from original) + ctx.moveTo(-3.2, -5) + ctx.lineTo(7, 0) + ctx.lineTo(-3.2, 5) + ctx.setTransform(transform) + } else { + // Default to circle + ctx.arc(link.centerPos.x, link.centerPos.y, 5, 0, Math.PI * 2) + } + + // Apply disabled pattern or color + if (link.disabled && context.patterns?.disabled) { + const { fillStyle, globalAlpha } = ctx + ctx.fillStyle = context.patterns.disabled + ctx.globalAlpha = 0.75 + ctx.fill() + ctx.globalAlpha = globalAlpha + ctx.fillStyle = fillStyle + } else { + ctx.fillStyle = color + ctx.fill() + } + } +}