Paper.js Meta-ball Effect For Irregular shapes

107 Views Asked by At

My Problem

I'm attempting to create a metaball effect using Paper.js. While there's a metaball effect example on the Paper.js site (Metaball demo), it focuses on round shapes. My shapes, however, are irregular. I'm not sure how to achieve this effect with these shapes or if it's even feasible. I am not good enough in math that I could write a logical function that calculates the shape between objects.

While I'm aware I could use SVG filters for the desired result, this would rasterize the effect, and exporting as SVG would no longer be an option. The main reason for trying to create the meta-ball effect without effects is that the exported SVG should then work in every vector program Figma / Adobe Illustrator...

Here's a CodePen demo I've created. In this demo, you can move the shapes, and when the threshold is met, they connect with a straight line.

The primary function of interest is drawConnections, which establishes connections between two segment points. The Paper.js documentation for segments is available here. Some of these methods and properties might be useful for achieving the metaball effect.

The structure of the demo is like this:

  • init() Sets up the Paper.js and imports the SVG onto the canvas.
  • onSvgLoad(item) This function performs initial setup tasks when an SVG is loaded such as scaling, adding to canvas, segment extraction, and setting up event listeners
  • drawLine(points, size): Creates and returns a simple path (line) using provided points.
  • drawConnections(segments, threshold): Checks for nearby segments and draws connections between them based on a threshold distance.
  • extractSegments(item, includeCounterClockwise): Extracts segments (points) from the provided SVG item. It can also be configured to exclude segments of paths that are counter-clockwise.
  • getAllObjects(item): Retrieves all SVG objects (like paths, compound paths) from the provided item.
  • addSegmentsToPath(item, add, includeCounterClockwise): Increases the number of segments in a given path or compound path based on the specified number (add).
  • onDragStart(item, event): Determines which child path of the SVG was clicked on.
  • onDrag(item, event): Moves the selected path based on drag events and redraws the connections.
  • onDragEnd(item, event): Finalizes the drag operation, deselects the path, and updates the view.

Here is the main function drawConnections this function is called in the onDrag function:

....

function drawConnections(segments, threshold = 40) {
  for (const segment of segments) {
    for (const otherSegment of segments) {
      if (segment === otherSegment) continue;

      for (const seg of segment) {
        for (const otherSeg of otherSegment) {
          if (seg.point.getDistance(otherSeg.point) < threshold) {
            
            const path = drawLine([seg.point, otherSeg.point], 4);
            path.sendToBack();
            path.removeOn({ drag: true, up: true });
            
          }
        }
      }
    }
  }
}

....

Some extra demos I have created:

  1. In this demo each segment can only have one connection Demo.
  2. The meta-ball effect is created with an SVG filter Demo.

I am thankful for all the help I get.

1

There are 1 best solutions below

2
Kaiido On

Disclaimer: I'm no expert in paper.js at all, so there might be an even simpler solution.


While I'm aware I could use SVG filters for the desired result, this would rasterize the effect, and exporting as SVG would no longer be an option.

What makes you think you can't use SVG filters with SVG? That's literally what they've been made for.
Since your filter is added through CSS over the canvas it's not surprising that paper.js can't export it, but you can very well just add it yourself to the exported <svg>:

// http://paperjs.org/reference/segment/

function init() {
  // Setup Paper
  paper.setup(document.getElementById("canvas"));

  // Load SVG
  const svg = document.getElementById("svg");
  paper.project.importSVG(svg, { expandShapes: true, onLoad: onSvgLoad });
  svg.style.display = "none";
}

function onSvgLoad(item) {
  paper.project.clear();

  // Show the anchors
  item.selected = true;

  // Scale the imported SVG to fit the view
  item.fitBounds(paper.view.bounds);
   
  // Scale item by 50%
  item.scale(0.5);

  // Add the imported SVG to the canvas
  paper.project.activeLayer.addChild(item);
  
  // Collect all objects
  const objects = getAllObjects(item);

  // Add more segments to all objects
  for (const object of objects) {
    addSegmentsToPath(object, 15, false);
  }
  
  // Extract segments (points) from the imported SVG item
  const segments = extractSegments(item, false);
  item.allCollectedSegments = segments;
  
  // add event listeners
  item.onMouseDown = (event) => onDragStart(item, event);
  item.onMouseDrag = (event) => onDrag(item, event);
  item.onMouseUp = (event) => onDragEnd(item, event);

  // Refresh the view
  paper.view.draw();
  // Update view
  paper.view.update();
}

function drawLine(points, size = 3){
  const path = new paper.Path({
    segments: points,
    strokeColor: "red",
    strokeWidth: size,
    closed: false,
    strokeCap: "round",
    strokeJoin: "round",
    opacity: 1,
    type: "connection", // Custom property
  });
  
  return path;
}

// Check for close segments and draw the connection
function drawConnections(segments, threshold = 40) {
    // First, clear existing connections
    clearExistingConnections(segments);

    for (const segmentGroup of segments) {
        for (const seg of segmentGroup) {
            if (seg.isConnected) continue;

            const closestSegment = findClosestSegment(seg, segments, segmentGroup);

            if (closestSegment && seg.point.getDistance(closestSegment.point) < threshold) {
                createConnection(seg, closestSegment);
            }
        }
    }
}

function clearExistingConnections(segments) {
    for (const segmentGroup of segments) {
        for (const seg of segmentGroup) {
            if (seg.connection) {
                if (seg.connection.segment2) {
                    seg.connection.segment2.isConnected = false;
                    seg.connection.segment2.connection = null;
                }
                seg.connection.remove();
                seg.connection = null;
                seg.isConnected = false;
            }
        }
    }
}

function createConnection(seg1, seg2, steps = 40) {
    if (seg1.connection) {
        seg1.connection.segment2.isConnected = false;
        seg1.connection.remove();
    }
    if (seg2.connection) {
        seg2.connection.segment1.isConnected = false;
        seg2.connection.remove();
    }

    const path = drawLine([seg1.point, seg2.point], 9);
    path.sendToBack({ insert: true });
    path.removeOn({ drag: true, up: false, down: true, move: false });

    const midPoint = seg1.point.add(seg2.point).divide(2);
    const totalDistance = seg1.point.getDistance(seg2.point);
    const connectionGroup = new paper.Group();

    seg1.connection = connectionGroup;
    seg2.connection = connectionGroup;
    seg1.isConnected = true;
    seg2.isConnected = true;

    connectionGroup.segment1 = seg1;
    connectionGroup.segment2 = seg2;
}

function findClosestSegment(targetSeg, segments, currentSegmentGroup) {
    let closestSegment = null;
    let closestDistance = Infinity;

    for (const segmentGroup of segments) {
        if (segmentGroup === currentSegmentGroup) continue;

        for (const seg of segmentGroup) {
            if (seg.isConnected) continue;

            const distance = targetSeg.point.getDistance(seg.point);
            if (distance < closestDistance) {
                closestDistance = distance;
                closestSegment = seg;
            }
        }
    }

    return closestSegment;
}

// Get all segments (points) from svg
function extractSegments(item, includeCounterClockwise = true) {
  let segmentsList = [];

  // If the item itself is a direct path, return its segments in an array
  if (item instanceof paper.Path && item.segments) return [item.segments.slice()];
  // If the item is a compound path, gather its path children's segments
  else if (item instanceof paper.CompoundPath && item.children) {
    let compoundSegments = [];
    for (let child of item.children) {
      if (child.clockwise === true && includeCounterClockwise === false) continue;
      if (child instanceof paper.Path && child.segments) compoundSegments.push(child.segments.slice());
    }
    if (compoundSegments.length > 0) segmentsList.push(compoundSegments.flat());
  }

  // If the item is a group and has children, recursively extract segments from each child
  else if (item instanceof paper.Group && item.children) {
    for (let child of item.children) {
      if (child.clockwise === true && includeCounterClockwise === false) continue;

      let childSegments = extractSegments(child, includeCounterClockwise);
      segmentsList = segmentsList.concat(childSegments);
    }
  }

  return segmentsList;
}

// Get all svg objects
function getAllObjects(item) {
  let objects = [];

  // If the item itself is of the desired type, collect it
  if (item instanceof paper.Path) objects.push(item);
  // If the item is a compound path, gather it
  else if (item instanceof paper.CompoundPath) objects.push(item);
  // If the item is a group
  else if (item instanceof paper.Group && item.children) {
    for (let child of item.children) {
      const childObjects = getAllObjects(child);
      objects = objects.concat(childObjects);
    }
  }

  return objects;
}

// Add more segments (points) to svg
function addSegmentsToPath(item, add = 10, includeCounterClockwise = false) {
  if (item instanceof paper.Path) {
    if (item.clockwise === true && includeCounterClockwise === false) return;

    let pathLength = item.length;
    let step = pathLength / (add + item.segments.length - 1);
    let iterations = Math.floor(pathLength / step);

    for (let i = 1; i <= iterations; i++) {
      let offset = i * step;
      item.divideAt(offset);
    }
  } else if (item instanceof paper.CompoundPath && item.children) {
    // Recursively process children of the compound path
    for (const child of item.children) {
      addSegmentsToPath(child, add, includeCounterClockwise);
    }
  }
}

// Event listeners
function onDragStart(item, event) {
  for (const child of item.children) {
    if (child.hitTest(event.point)) {
      item.selectedPath = child;
      item.selectedPath.selected = true;
      break;
    }
  }
}

function onDrag(item, event) {
  if (item.selectedPath) {
    item.selectedPath.position = item.selectedPath.position.add(event.delta);
  }
  
  // Draw the connections
  drawConnections(item.allCollectedSegments, 60);

  // Refresh the view
  paper.view.draw();
}

function onDragEnd(item, event) {
  if (item.selectedPath) {
    item.selectedPath.selected = true;
    item.selectedPath = null;
  }
  // Refresh the view
  paper.view.draw();
  // Update view
  paper.view.update();
}


// Init project
window.onload = init;
document.querySelector("button").onclick = (evt) => {
  // get the project as <svg>
  const exp = paper.project.exportSVG();
  // add a clone of the <filter> in that <svg>
  exp.prepend(document.querySelector("#goo").cloneNode(true));
  // set up the filter on the main <g>
  exp.querySelector("g").setAttribute("filter", "url(#goo)");
  // do whatever with the exported <svg>, here we'll display it in an img.
  const markup = new XMLSerializer().serializeToString(exp);
  const blob = new Blob([markup], { type: "image/svg+xml" });
  const img = document.querySelector("img").src = URL.createObjectURL(blob);
};
canvas{ 
  border: solid 1px #1D1E22; 
  width: 800px; 
  height: 400px;
  -webkit-filter: url("#goo");
  filter: url("#goo");
}
#svg{ visibility: hidden; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/paper.js/0.12.17/paper-full.min.js"></script>
<button id="btn">export</button>
<svg id="svg" xmlns="http://www.w3.org/2000/svg">
<path d="M175.138 1.36292H156.138C152.538 1.36292 149.738 4.26291 149.738 7.76291V57.7629C149.738 61.2629 152.638 64.1629 156.138 64.1629H175.338C197.838 64.1629 208.938 51.3629 208.938 32.8629C208.938 14.3629 197.638 1.36292 175.138 1.36292ZM174.138 48.6629H166.738V17.5629H174.038C189.138 17.5629 191.538 24.5629 191.538 32.8629C191.538 41.1629 189.138 48.6629 174.138 48.6629Z" />
<path d="M104.138 0.462952C84.9378 0.462952 69.5378 15.063 69.5378 32.963C69.5378 50.863 84.9378 65.363 104.138 65.363C123.338 65.363 138.738 50.863 138.738 32.963C138.738 15.063 123.438 0.462952 104.138 0.462952ZM104.138 50.063C94.7378 50.063 87.0378 42.563 87.0378 32.963C87.0378 23.363 94.7378 15.763 104.138 15.763C113.538 15.763 121.338 23.763 121.338 32.963C121.338 42.163 113.538 50.063 104.138 50.063Z" />
<path d="M51.7258 1.36292H50.8258C44.4258 1.36292 43.7258 9.46291 43.2258 14.3629C42.8258 18.8629 42.4258 25.4629 37.6258 25.4629C28.5258 25.4629 20.8258 2.1629 8.42583 2.1629H8.22583C3.12583 2.1629 0.72583 4.66291 0.72583 11.4629V57.7629C0.72583 61.9629 3.32582 64.7629 7.62582 64.7629H8.42583C19.5258 64.7629 11.8258 39.2629 21.7258 39.2629C31.6258 39.2629 37.9258 64.0629 51.3258 64.0629H51.5258C56.0258 64.0629 58.5258 61.4629 58.5258 56.4629V8.76291C58.7258 4.46291 56.4258 1.36292 51.7258 1.36292Z" />
</svg>

<svg xmlns="http://www.w3.org/2000/svg" version="1.1">
  <defs>
    <filter id="goo">
      <feGaussianBlur in="SourceGraphic" stdDeviation="4" result="blur" />
      <feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 16 -8" result="goo" />
      <feComposite in="SourceGraphic" in2="goo" operator="atop"/>
    </filter>
  </defs>
</svg>

<canvas id="canvas" resize></canvas><br>
Exported SVG:<br>
<img>