diff --git a/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx b/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx index 0b4ed97f8..e3c314ac7 100644 --- a/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx @@ -99,6 +99,8 @@ export const CanvasEdge = memo(function CanvasEdge({ targetY, targetPosition, waypoints: effectiveWaypoints, + // Face-clearance applies to router waypoints only; manual ones render as-is. + autoRouted: waypoints.length === 0, enableSegments: editingEnabled, hideArrowHead, }); diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/geometry.test.ts b/packages/apollo-react/src/canvas/components/Edges/shared/geometry.test.ts index 741ccbcc3..2c1abf9b8 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/geometry.test.ts +++ b/packages/apollo-react/src/canvas/components/Edges/shared/geometry.test.ts @@ -185,6 +185,110 @@ describe('buildPathVertices', () => { const indices = result.map((v) => v.waypointIndex).filter((i) => i >= 0); expect(indices.sort()).toEqual([0, 1]); }); + + it('shifts an auto-routed first waypoint sitting against the source face out to STUB_OFFSET (regression)', () => { + // Auto-routed bend hard against the Right face → pushed out to STUB_OFFSET. + const result = buildPathVertices( + 0, + 0, + Position.Right, + 200, + 100, + Position.Left, + [wp('a', 5, 50)], + true + ); + const drawn = result.find((v) => v.waypointIndex === 0); + const src = ARROW_OFFSETS[Position.Right]; + expect(drawn).toMatchObject({ x: src.x + EDGE_CONSTANTS.STUB_OFFSET, y: 50 }); + expect(isOrthogonal(result)).toBe(true); + }); + + it('renders a MANUAL close waypoint exactly where placed (no face-clearance)', () => { + // Same close bend, user-placed (autoRouted = false) → rendered as-is. + const result = buildPathVertices( + 0, + 0, + Position.Right, + 200, + 100, + Position.Left, + [wp('a', 5, 50)], + false + ); + expect(result.find((v) => v.waypointIndex === 0)).toMatchObject({ x: 5, y: 50 }); + }); + + it('leaves an auto-routed first waypoint that already clears the source face untouched', () => { + const result = buildPathVertices( + 0, + 0, + Position.Right, + 200, + 100, + Position.Left, + [wp('a', 100, 50)], + true + ); + expect(result.find((v) => v.waypointIndex === 0)).toMatchObject({ x: 100, y: 50 }); + }); + + it('shifts an auto-routed last waypoint sitting against the target face out to STUB_OFFSET', () => { + // Target (Left face) is cleared symmetrically — riser pushed away from it. + const result = buildPathVertices( + 0, + 0, + Position.Right, + 200, + 100, + Position.Left, + [wp('a', 100, 50), wp('b', 198, 50)], + true + ); + const drawn = result.find((v) => v.waypointIndex === 1); + const endX = 200 + ARROW_OFFSETS[Position.Left].x; + expect(drawn).toMatchObject({ x: endX - EDGE_CONSTANTS.STUB_OFFSET, y: 50 }); + expect(isOrthogonal(result)).toBe(true); + }); + + it('clears a vertical (Bottom) source face on the y axis', () => { + // Bottom-face exit: the shift runs on y, not x. + const result = buildPathVertices( + 0, + 0, + Position.Bottom, + 100, + 200, + Position.Top, + [wp('a', 50, 5)], + true + ); + const drawn = result.find((v) => v.waypointIndex === 0); + const src = ARROW_OFFSETS[Position.Bottom]; + expect(drawn).toMatchObject({ x: 50, y: src.y + EDGE_CONSTANTS.STUB_OFFSET }); + expect(isOrthogonal(result)).toBe(true); + }); + + it('pulls a bend behind the source face forward to a clean STUB_OFFSET exit', () => { + // Behind the face (gap < 0), e.g. router port behind the handle: the riser is + // pulled forward to exactly STUB_OFFSET in front, never left hugging the node. + const src = ARROW_OFFSETS[Position.Right]; + const result = buildPathVertices( + 0, + 0, + Position.Right, + 200, + 100, + Position.Left, + [wp('a', src.x - 20, 50)], + true + ); + expect(result.find((v) => v.waypointIndex === 0)).toMatchObject({ + x: src.x + EDGE_CONSTANTS.STUB_OFFSET, + y: 50, + }); + expect(isOrthogonal(result)).toBe(true); + }); }); describe('extractSegments', () => { diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/geometry.ts b/packages/apollo-react/src/canvas/components/Edges/shared/geometry.ts index f607f39a0..5094be8ad 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/geometry.ts +++ b/packages/apollo-react/src/canvas/components/Edges/shared/geometry.ts @@ -153,6 +153,33 @@ function connectorElbow(from: Point, to: Point, incoming: SegmentOrientation | n return incoming === 'horizontal' ? { x: from.x, y: to.y } : { x: to.x, y: from.y }; } +/** + * Push the waypoint riser nearest a node face (`anchor` = inset source/target + * endpoint) out to `EDGE_CONSTANTS.STUB_OFFSET` so the edge keeps a perpendicular + * offset. Any riser closer than that is shifted forward, including bends behind + * the face (gap < 0) — common on multi-handle nodes where the router's port sits + * behind the rendered handle. Shifting stored waypoints (ids kept) survives + * consolidation, whereas a derived elbow would not. + */ +function clearNodeFace(waypoints: Waypoint[], anchor: Point, position: Position): Waypoint[] { + const dir = getDirection(position); + const axis = dir.dx !== 0 ? 'x' : 'y'; + const sign = dir.dx !== 0 ? dir.dx : dir.dy; + const nearest = waypoints.reduce( + (acc, w) => (sign > 0 ? Math.min(acc, w[axis]) : Math.max(acc, w[axis])), + sign > 0 ? Infinity : -Infinity + ); + // Lands the riser exactly STUB_OFFSET in front of the face for any gap < that, + // including bends behind the face (gap < 0) — common on multi-handle nodes + // where the router's port sits behind the rendered handle. + const gap = (nearest - anchor[axis]) * sign; + if (gap >= EDGE_CONSTANTS.STUB_OFFSET) return waypoints; + const shift = (EDGE_CONSTANTS.STUB_OFFSET - gap) * sign; + return waypoints.map((w) => + Math.abs(w[axis] - nearest) < TOL ? { ...w, [axis]: w[axis] + shift } : w + ); +} + /** * Build the ordered, provenance-tagged vertices of the path: * `[start, …, end]`. Falls back to auto-routing when no manual waypoints are @@ -176,7 +203,9 @@ export function buildPathVertices( targetX: number, targetY: number, targetPosition: Position, - waypoints: Waypoint[] = [] + waypoints: Waypoint[] = [], + // Router waypoints get face-clearance (clearNodeFace); manual ones render as-is. + autoRouted = false ): PathVertex[] { const sourceOffset = ARROW_OFFSETS[sourcePosition]; const targetOffset = ARROW_OFFSETS[targetPosition]; @@ -201,6 +230,11 @@ export function buildPathVertices( const startVertex = derived(start); const endVertex = derived(end); + // Clear both faces for auto-routed bends; leave manual waypoints as placed. + const routed = autoRouted + ? clearNodeFace(clearNodeFace(waypoints, start, sourcePosition), end, targetPosition) + : waypoints; + // `path` carries the start anchor only as orientation context for the // interior-elbow heuristic; it is sliced off before consolidation. The // anchors must never be fed to `consolidateWaypoints` — they are trivially @@ -209,22 +243,22 @@ export function buildPathVertices( const path: PathVertex[] = [startVertex]; // Source stub: anchor face → first waypoint. - for (const elbow of routeAnchorToPoint(start, sourcePosition, waypoints[0]!)) { + for (const elbow of routeAnchorToPoint(start, sourcePosition, routed[0]!)) { path.push(derived(elbow)); } - path.push({ x: waypoints[0]!.x, y: waypoints[0]!.y, waypointIndex: 0 }); + path.push({ x: routed[0]!.x, y: routed[0]!.y, waypointIndex: 0 }); // Interior links: orthogonalize between consecutive waypoints. - for (let i = 1; i < waypoints.length; i++) { + for (let i = 1; i < routed.length; i++) { const from = path[path.length - 1]!; const incoming = path.length >= 2 ? getSegmentOrientation(path[path.length - 2]!, from) : null; - const elbow = connectorElbow(from, waypoints[i]!, incoming); + const elbow = connectorElbow(from, routed[i]!, incoming); if (elbow) path.push(derived(elbow)); - path.push({ x: waypoints[i]!.x, y: waypoints[i]!.y, waypointIndex: i }); + path.push({ x: routed[i]!.x, y: routed[i]!.y, waypointIndex: i }); } // Target stub: last waypoint → anchor face (reverse of the anchor-outward order). - const targetStub = routeAnchorToPoint(end, targetPosition, waypoints[waypoints.length - 1]!); + const targetStub = routeAnchorToPoint(end, targetPosition, routed[routed.length - 1]!); for (let j = targetStub.length - 1; j >= 0; j--) { path.push(derived(targetStub[j]!)); } diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts b/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts index de13e86bb..67ab066d9 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts +++ b/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts @@ -29,6 +29,11 @@ export type UseEdgeGeometryArgs = { targetPosition: Position; /** Manual waypoints — only used in `waypoint` routing. */ waypoints: Waypoint[]; + /** + * True when `waypoints` are router-produced (not user-placed), so the path + * builder may shift bends off the node faces. Manual waypoints render as-is. + */ + autoRouted?: boolean; /** * When false, `segments` is always empty and segment extraction is skipped. * Segments are only consumed by the editing chrome, so consumers with @@ -81,6 +86,7 @@ export function useEdgeGeometry(args: UseEdgeGeometryArgs): EdgeGeometry { targetY, targetPosition, waypoints, + autoRouted = false, enableSegments = true, hideArrowHead = false, } = args; @@ -99,10 +105,21 @@ export function useEdgeGeometry(args: UseEdgeGeometryArgs): EdgeGeometry { targetX, targetY, targetPosition, - waypoints + waypoints, + autoRouted ) : EMPTY_VERTICES, - [isWaypoint, sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, waypoints] + [ + isWaypoint, + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + waypoints, + autoRouted, + ] ); const segments = useMemo(