Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Comment thread
KodudulaAshishUiPath marked this conversation as resolved.
// 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
Expand All @@ -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];
Expand All @@ -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
Expand All @@ -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]!));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export type UseEdgeGeometryArgs = {
targetPosition: Position;
/** Manual waypoints — only used in `waypoint` routing. */
waypoints: Waypoint[];
Comment on lines 30 to 31
/**
* 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
Expand Down Expand Up @@ -81,6 +86,7 @@ export function useEdgeGeometry(args: UseEdgeGeometryArgs): EdgeGeometry {
targetY,
targetPosition,
waypoints,
autoRouted = false,
enableSegments = true,
hideArrowHead = false,
} = args;
Expand All @@ -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(
Expand Down
Loading