mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-03-13 12:08:46 +00:00
Add self-loop (self-reference) support for parallel edges
- Add getSelfLoopParams() with shape-aware geometry for all node shapes (rectangle, roundedRectangle, circle, ellipse, pill) - Anchor loops at the top-right corner: innermost loop is tightest (label closest to node), outer parallel loops fan progressively higher - Fix ellipse source point which was incorrectly placed at the bottom - Fix horizontal pill source Y which was at center instead of top edge - Apply loopOffset to ellipse/circle to differentiate parallel self-loops - Handle GraphEditor parallel edge sorting for self-referencing edges - Fix loopLevel overflow for 6+ parallel self-loops via Math.max(0, ...) - Add tests for self-loop detection, geometry, and parallel self-loops Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4e1f19c82b
commit
ed18b11dc9
3 changed files with 321 additions and 8 deletions
|
|
@ -304,14 +304,27 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
||||||
// Sort edges by direction: edges going in normalized direction first, then reverse
|
// Sort edges by direction: edges going in normalized direction first, then reverse
|
||||||
const [normalizedSource, normalizedTarget] = groupKey.split('<->');
|
const [normalizedSource, normalizedTarget] = groupKey.split('<->');
|
||||||
|
|
||||||
const edgesInNormalizedDirection = group.edges.filter(
|
// For self-references (source === target), all edges go in one group
|
||||||
e => e.source === normalizedSource && e.target === normalizedTarget
|
// For regular edges, separate by direction
|
||||||
);
|
let sortedEdges: Relation[];
|
||||||
const edgesInReverseDirection = group.edges.filter(
|
if (normalizedSource === normalizedTarget) {
|
||||||
e => e.source === normalizedTarget && e.target === normalizedSource
|
// Self-reference: just sort by ID for deterministic ordering
|
||||||
);
|
sortedEdges = [...group.edges].sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
} else {
|
||||||
|
// Regular edges: separate by direction
|
||||||
|
const edgesInNormalizedDirection = group.edges.filter(
|
||||||
|
e => e.source === normalizedSource && e.target === normalizedTarget
|
||||||
|
);
|
||||||
|
const edgesInReverseDirection = group.edges.filter(
|
||||||
|
e => e.source === normalizedTarget && e.target === normalizedSource
|
||||||
|
);
|
||||||
|
|
||||||
const sortedEdges = [...edgesInNormalizedDirection, ...edgesInReverseDirection];
|
// Sort each group by edge ID for deterministic ordering
|
||||||
|
edgesInNormalizedDirection.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
edgesInReverseDirection.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
|
||||||
|
sortedEdges = [...edgesInNormalizedDirection, ...edgesInReverseDirection];
|
||||||
|
}
|
||||||
|
|
||||||
sortedEdges.forEach((edge, index) => {
|
sortedEdges.forEach((edge, index) => {
|
||||||
const multiplier = calculateEdgeOffsetMultiplier(index, totalEdges);
|
const multiplier = calculateEdgeOffsetMultiplier(index, totalEdges);
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import {
|
||||||
calculatePerpendicularOffset,
|
calculatePerpendicularOffset,
|
||||||
groupParallelEdges,
|
groupParallelEdges,
|
||||||
generateEdgeId,
|
generateEdgeId,
|
||||||
|
getFloatingEdgeParams,
|
||||||
} from './edgeUtils';
|
} from './edgeUtils';
|
||||||
import type { Relation } from '../types';
|
import type { Relation } from '../types';
|
||||||
|
import type { Node } from '@xyflow/react';
|
||||||
|
|
||||||
describe('edgeUtils', () => {
|
describe('edgeUtils', () => {
|
||||||
describe('generateEdgeId', () => {
|
describe('generateEdgeId', () => {
|
||||||
|
|
@ -250,5 +252,135 @@ describe('edgeUtils', () => {
|
||||||
|
|
||||||
expect(key1).toBe(key2); // Should produce same normalized key
|
expect(key1).toBe(key2); // Should produce same normalized key
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should group self-referencing edges together', () => {
|
||||||
|
const edges = [
|
||||||
|
createMockEdge('e1', 'n1', 'n1'),
|
||||||
|
createMockEdge('e2', 'n1', 'n1'),
|
||||||
|
createMockEdge('e3', 'n1', 'n1'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupParallelEdges(edges);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1); // All self-references should be in one group
|
||||||
|
const group = Array.from(result.values())[0];
|
||||||
|
expect(group.edges).toHaveLength(3);
|
||||||
|
const key = Array.from(result.keys())[0];
|
||||||
|
expect(key).toBe('n1<->n1'); // Self-reference key
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFloatingEdgeParams - self-references', () => {
|
||||||
|
const createMockNode = (id: string, x: number, y: number, width: number = 150, height: number = 80): Node => ({
|
||||||
|
id,
|
||||||
|
position: { x, y },
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
measured: { width, height },
|
||||||
|
selected: false,
|
||||||
|
dragging: false,
|
||||||
|
type: 'custom',
|
||||||
|
data: {},
|
||||||
|
internals: {
|
||||||
|
positionAbsolute: { x, y },
|
||||||
|
handleBounds: { source: [], target: [] },
|
||||||
|
},
|
||||||
|
} as Node);
|
||||||
|
|
||||||
|
it('should detect self-reference and create loop', () => {
|
||||||
|
const node = createMockNode('node-1', 100, 100);
|
||||||
|
|
||||||
|
const result = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0);
|
||||||
|
|
||||||
|
// Source should be at top of node, target at right side
|
||||||
|
expect(result.sy).toBe(100); // Source at top edge
|
||||||
|
expect(result.tx).toBe(250); // Target at right edge (100 + 150)
|
||||||
|
// Control points should extend outward
|
||||||
|
expect(result.sourceControlY).toBeLessThan(result.sy); // Control point above
|
||||||
|
expect(result.targetControlX).toBeGreaterThan(result.tx); // Control point to the right
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create loop with correct height based on node dimensions', () => {
|
||||||
|
const node = createMockNode('node-1', 100, 100, 200, 100);
|
||||||
|
|
||||||
|
const result = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0);
|
||||||
|
|
||||||
|
const loopHeight = result.sy - result.sourceControlY;
|
||||||
|
// Should be at least 1.5x node height = 150px (may be more due to shape calculations)
|
||||||
|
expect(loopHeight).toBeGreaterThanOrEqual(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply horizontal offset for parallel self-loops', () => {
|
||||||
|
const node = createMockNode('node-1', 100, 100, 150, 80);
|
||||||
|
|
||||||
|
const result1 = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0);
|
||||||
|
const result2 = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 1);
|
||||||
|
const result3 = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', -1);
|
||||||
|
|
||||||
|
// Source should be at top, target at right side
|
||||||
|
const rightEdge = 250; // 100 + 150
|
||||||
|
|
||||||
|
expect(result1.sy).toBe(100); // Source at top
|
||||||
|
expect(result1.tx).toBe(rightEdge); // Target at right
|
||||||
|
|
||||||
|
// Outer loops (±1) should arc higher than the center loop (0)
|
||||||
|
const height1 = Math.abs(result1.sourceControlY - result1.sy);
|
||||||
|
const height2 = Math.abs(result2.sourceControlY - result2.sy);
|
||||||
|
const height3 = Math.abs(result3.sourceControlY - result3.sy);
|
||||||
|
|
||||||
|
// offsetMultiplier=1 → loopLevel=3, offsetMultiplier=0 → loopLevel=2, offsetMultiplier=-1 → loopLevel=1
|
||||||
|
expect(height2).toBeGreaterThan(height1); // Outer loop (level 3) taller than center (level 2)
|
||||||
|
expect(height1).toBeGreaterThan(height3); // Center (level 2) taller than inner (level 1)
|
||||||
|
|
||||||
|
// Spreads should be reasonable
|
||||||
|
expect(height1).toBeLessThan(500);
|
||||||
|
expect(height2).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use shape-aware angles for self-loop', () => {
|
||||||
|
const node = createMockNode('node-1', 100, 100);
|
||||||
|
|
||||||
|
const result = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0);
|
||||||
|
|
||||||
|
// Source angle should point upward (from top of node)
|
||||||
|
// Target angle should point rightward (from right side of node)
|
||||||
|
expect(result.sourceAngle).toBeCloseTo(-Math.PI / 2, 0); // Upward
|
||||||
|
expect(result.targetAngle).toBeCloseTo(0, 0); // Rightward
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create loop with source at top and target at right', () => {
|
||||||
|
const node = createMockNode('node-1', 100, 100, 150, 80);
|
||||||
|
|
||||||
|
const result = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0);
|
||||||
|
|
||||||
|
// Source should be near top of node
|
||||||
|
expect(result.sy).toBe(100);
|
||||||
|
// Target should be at right edge of node
|
||||||
|
expect(result.tx).toBe(250); // 100 + 150
|
||||||
|
// offsetMultiplier=0 → loopLevel=2, loopOffset=55 → sx = rightEdge - 55*0.8 = 250 - 44 = 206
|
||||||
|
expect(result.sx).toBeCloseTo(206, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nodes with minimum loop height', () => {
|
||||||
|
const node = createMockNode('node-1', 100, 100, 150, 20);
|
||||||
|
|
||||||
|
const result = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0);
|
||||||
|
|
||||||
|
const loopHeight = result.sy - result.sourceControlY;
|
||||||
|
// Node height is 20, 1.5x = 30, but minimum is 60
|
||||||
|
expect(loopHeight).toBeGreaterThanOrEqual(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not affect regular edges between different nodes', () => {
|
||||||
|
const sourceNode = createMockNode('node-1', 100, 100);
|
||||||
|
const targetNode = createMockNode('node-2', 300, 100);
|
||||||
|
|
||||||
|
const result = getFloatingEdgeParams(sourceNode, targetNode, 'rectangle', 'rectangle', 0);
|
||||||
|
|
||||||
|
// Should use normal edge calculation
|
||||||
|
expect(result.sx).not.toBe(result.tx);
|
||||||
|
expect(result.sy).toBe(result.ty); // Same Y for horizontal edge
|
||||||
|
// Control points create a slight curve (not necessarily above or below)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Relation, RelationData, NodeShape } from '../types';
|
import type { Relation, RelationData, NodeShape } from '../types';
|
||||||
import type { Node } from '@xyflow/react';
|
import type { Node } from '@xyflow/react';
|
||||||
import { ROUNDED_RECTANGLE_RADIUS } from '../constants';
|
import { ROUNDED_RECTANGLE_RADIUS, DEFAULT_ACTOR_WIDTH, DEFAULT_ACTOR_HEIGHT } from '../constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a unique ID for edges using crypto.randomUUID()
|
* Generates a unique ID for edges using crypto.randomUUID()
|
||||||
|
|
@ -494,6 +494,169 @@ function getNodeIntersection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the parameters for a self-loop edge (self-reference)
|
||||||
|
* Creates a curved loop that goes above the node
|
||||||
|
* Uses shape-aware intersection points for proper rendering
|
||||||
|
* @param node - The node that references itself
|
||||||
|
* @param offsetMultiplier - Offset multiplier for parallel self-loops
|
||||||
|
* @param sourceShape - Shape of the node
|
||||||
|
*/
|
||||||
|
function getSelfLoopParams(
|
||||||
|
node: Node,
|
||||||
|
offsetMultiplier: number,
|
||||||
|
sourceShape: NodeShape
|
||||||
|
): {
|
||||||
|
sx: number;
|
||||||
|
sy: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
sourceControlX: number;
|
||||||
|
sourceControlY: number;
|
||||||
|
targetControlX: number;
|
||||||
|
targetControlY: number;
|
||||||
|
sourceAngle: number;
|
||||||
|
targetAngle: number;
|
||||||
|
} {
|
||||||
|
// Use positionAbsolute for correct positioning of nodes inside groups
|
||||||
|
// @ts-expect-error - internals.positionAbsolute exists at runtime but not in public types
|
||||||
|
const nodePosition = node.internals?.positionAbsolute ?? node.position;
|
||||||
|
|
||||||
|
const nodeWidth = node.measured?.width ?? node.width ?? DEFAULT_ACTOR_WIDTH;
|
||||||
|
const nodeHeight = node.measured?.height ?? node.height ?? DEFAULT_ACTOR_HEIGHT;
|
||||||
|
|
||||||
|
const centerX = nodePosition.x + nodeWidth / 2;
|
||||||
|
const centerY = nodePosition.y + nodeHeight / 2;
|
||||||
|
const topEdge = nodePosition.y;
|
||||||
|
const rightEdge = nodePosition.x + nodeWidth;
|
||||||
|
|
||||||
|
// Calculate loop level for progressive widening/taller loops.
|
||||||
|
// offsetMultiplier values from calculateEdgeOffsetMultiplier:
|
||||||
|
// 1 edge: 0
|
||||||
|
// 2 edges: -0.5, 0.5
|
||||||
|
// 3 edges: -1, 0, 1
|
||||||
|
// 4 edges: -1.5, -0.5, 0.5, 1.5
|
||||||
|
// 5 edges: -2, -1, 0, 1, 2
|
||||||
|
// Shift by +2 so the center/innermost loop starts at level 2 with a comfortable default
|
||||||
|
// offset and arc height. Clamp to 0 for 6+ parallel loops rather than going negative.
|
||||||
|
const roundedMultiplier = Math.round(offsetMultiplier * 2) / 2;
|
||||||
|
const loopLevel = Math.max(0, roundedMultiplier + 2);
|
||||||
|
|
||||||
|
// loopOffset controls how far the exit/entry points spread from the top-right corner.
|
||||||
|
// Lower loopLevel → smaller loopOffset → points close together near the corner → short arc.
|
||||||
|
// Higher loopLevel → larger loopOffset → points spread along edges → tall arc.
|
||||||
|
const baseOffset = 15;
|
||||||
|
const offsetIncrement = 20;
|
||||||
|
const loopOffset = baseOffset + loopLevel * offsetIncrement;
|
||||||
|
|
||||||
|
// Source exits from the top edge, measured leftward from the top-right corner.
|
||||||
|
// Target enters from the right edge, measured downward from the top-right corner.
|
||||||
|
// This anchors the innermost loop tightly at the corner (label closest to node)
|
||||||
|
// and fans outer loops out along the edges.
|
||||||
|
let sx, sy, tx, ty, sourceAngle, targetAngle;
|
||||||
|
|
||||||
|
if (sourceShape === 'circle') {
|
||||||
|
const radius = Math.min(nodeWidth, nodeHeight) / 2;
|
||||||
|
// Anchor both points near the top-right of the circle and fan outward with loopOffset.
|
||||||
|
// angleSpread controls how far source/target diverge from the top-right (angle = -PI/4).
|
||||||
|
// Capped at PI/4 so source never exceeds -PI/2 (top) and target never exceeds 0 (right).
|
||||||
|
const angleSpread = Math.min(Math.PI / 4, (loopOffset / (radius * 2)) * (Math.PI / 2));
|
||||||
|
const sourceAngleRad = -Math.PI / 4 - angleSpread; // toward top
|
||||||
|
const targetAngleRad = -Math.PI / 4 + angleSpread; // toward right
|
||||||
|
sx = centerX + Math.cos(sourceAngleRad) * (radius + 2);
|
||||||
|
sy = centerY + Math.sin(sourceAngleRad) * (radius + 2);
|
||||||
|
tx = centerX + Math.cos(targetAngleRad) * (radius + 2);
|
||||||
|
ty = centerY + Math.sin(targetAngleRad) * (radius + 2);
|
||||||
|
sourceAngle = sourceAngleRad;
|
||||||
|
targetAngle = targetAngleRad;
|
||||||
|
} else if (sourceShape === 'pill') {
|
||||||
|
const isHorizontal = nodeWidth >= nodeHeight;
|
||||||
|
const capRadius = isHorizontal ? nodeHeight / 2 : nodeWidth / 2;
|
||||||
|
|
||||||
|
if (isHorizontal) {
|
||||||
|
// Horizontal pill: source on top straight edge (left of corner), target on right cap (below corner)
|
||||||
|
sx = rightEdge - loopOffset * 0.8;
|
||||||
|
sy = topEdge;
|
||||||
|
sourceAngle = -Math.PI / 2;
|
||||||
|
|
||||||
|
const rightCapCenterX = nodePosition.x + nodeWidth - capRadius;
|
||||||
|
tx = rightCapCenterX + capRadius + 2;
|
||||||
|
ty = topEdge + loopOffset * 0.5;
|
||||||
|
targetAngle = 0;
|
||||||
|
} else {
|
||||||
|
// Vertical pill: source on top cap (near right), target on right straight edge (near top)
|
||||||
|
const topCapCenterY = nodePosition.y + capRadius;
|
||||||
|
const sourceAngleRad = -Math.PI / 2 + (loopOffset / nodeWidth) * Math.PI / 4;
|
||||||
|
sx = centerX + Math.cos(sourceAngleRad) * (capRadius + 2);
|
||||||
|
sy = topCapCenterY + Math.sin(sourceAngleRad) * (capRadius + 2);
|
||||||
|
sourceAngle = sourceAngleRad;
|
||||||
|
|
||||||
|
tx = rightEdge;
|
||||||
|
ty = topEdge + loopOffset * 0.5;
|
||||||
|
targetAngle = 0;
|
||||||
|
}
|
||||||
|
} else if (sourceShape === 'ellipse') {
|
||||||
|
const radiusX = nodeWidth / 2;
|
||||||
|
const radiusY = nodeHeight / 2;
|
||||||
|
|
||||||
|
// Source: near top of ellipse, rotated slightly CW (toward right) by loopOffset.
|
||||||
|
// In screen coords (y increases down) the top is at theta = -PI/2.
|
||||||
|
const sourceTheta = -Math.PI / 2 + (loopOffset / nodeWidth) * 0.8;
|
||||||
|
const sourcePx = centerX + radiusX * Math.cos(sourceTheta);
|
||||||
|
const sourcePy = centerY + radiusY * Math.sin(sourceTheta);
|
||||||
|
const sourceNormalX = Math.cos(sourceTheta) / radiusX;
|
||||||
|
const sourceNormalY = Math.sin(sourceTheta) / radiusY;
|
||||||
|
const sourceNormalLen = Math.sqrt(sourceNormalX * sourceNormalX + sourceNormalY * sourceNormalY);
|
||||||
|
sourceAngle = Math.atan2(sourceNormalY / sourceNormalLen, sourceNormalX / sourceNormalLen);
|
||||||
|
sx = sourcePx + Math.cos(sourceAngle) * 2;
|
||||||
|
sy = sourcePy + Math.sin(sourceAngle) * 2;
|
||||||
|
|
||||||
|
// Target: near right side of ellipse, rotated slightly CW (downward) by loopOffset.
|
||||||
|
const targetTheta = (loopOffset / nodeHeight) * 0.5;
|
||||||
|
const targetPx = centerX + radiusX * Math.cos(targetTheta);
|
||||||
|
const targetPy = centerY + radiusY * Math.sin(targetTheta);
|
||||||
|
const targetNormalX = Math.cos(targetTheta) / radiusX;
|
||||||
|
const targetNormalY = Math.sin(targetTheta) / radiusY;
|
||||||
|
const targetNormalLen = Math.sqrt(targetNormalX * targetNormalX + targetNormalY * targetNormalY);
|
||||||
|
targetAngle = Math.atan2(targetNormalY / targetNormalLen, targetNormalX / targetNormalLen);
|
||||||
|
tx = targetPx + Math.cos(targetAngle) * 2;
|
||||||
|
ty = targetPy + Math.sin(targetAngle) * 2;
|
||||||
|
} else {
|
||||||
|
// Rectangle and roundedRectangle: source on top edge (left of corner), target on right edge (below corner)
|
||||||
|
sx = rightEdge - loopOffset * 0.8;
|
||||||
|
sy = topEdge;
|
||||||
|
sourceAngle = -Math.PI / 2;
|
||||||
|
|
||||||
|
tx = rightEdge;
|
||||||
|
ty = topEdge + loopOffset * 0.5;
|
||||||
|
targetAngle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop height grows with loopLevel so outer parallel loops arc progressively higher.
|
||||||
|
const baseLoopHeight = Math.max(nodeHeight * 1.5, 60);
|
||||||
|
const additionalHeight = loopLevel * 80;
|
||||||
|
const loopHeight = baseLoopHeight + additionalHeight;
|
||||||
|
|
||||||
|
// Control points extend upward along the normal direction
|
||||||
|
const sourceControlX = sx + Math.cos(sourceAngle) * loopHeight;
|
||||||
|
const sourceControlY = sy + Math.sin(sourceAngle) * loopHeight;
|
||||||
|
const targetControlX = tx + Math.cos(targetAngle) * loopHeight;
|
||||||
|
const targetControlY = ty + Math.sin(targetAngle) * loopHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
tx,
|
||||||
|
ty,
|
||||||
|
sourceControlX,
|
||||||
|
sourceControlY,
|
||||||
|
targetControlX,
|
||||||
|
targetControlY,
|
||||||
|
sourceAngle,
|
||||||
|
targetAngle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the parameters for a floating edge between two nodes
|
* Calculate the parameters for a floating edge between two nodes
|
||||||
* Returns source/target coordinates with angles for smooth bezier curves
|
* Returns source/target coordinates with angles for smooth bezier curves
|
||||||
|
|
@ -512,6 +675,11 @@ export function getFloatingEdgeParams(
|
||||||
offsetMultiplier: number = 0,
|
offsetMultiplier: number = 0,
|
||||||
parallelGroupKey?: string
|
parallelGroupKey?: string
|
||||||
) {
|
) {
|
||||||
|
// Handle self-referencing edges (self-loops)
|
||||||
|
if (sourceNode.id === targetNode.id) {
|
||||||
|
return getSelfLoopParams(sourceNode, offsetMultiplier, sourceShape);
|
||||||
|
}
|
||||||
|
|
||||||
const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape);
|
const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape);
|
||||||
const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape);
|
const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue