|
<html> |
|
<header> |
|
<title>Metaball in SVG</title> |
|
<style> |
|
html, body { |
|
margin: 0; |
|
font-family: sans-serif; |
|
} |
|
</style> |
|
</header> |
|
<body> |
|
<svg width="960" height="500"> |
|
<g transform="translate(480,250)"> |
|
<!-- elements will be inserted here --> |
|
</g> |
|
</svg> |
|
<script src="https://d3js.org/d3.v5.min.js"></script> |
|
<script> |
|
// some sample data |
|
let data=[ |
|
{x:40, y:0, r:10}, |
|
{x:20, y:40, r:15}, |
|
{x:-20, y:40, r:20}, |
|
{x:-40, y:0, r:25}, |
|
{x:-20, y:-40, r:6}, |
|
{x:20, y:-40, r:12} |
|
] |
|
data.forEach(d=>d.c=[d.x,d.y]); |
|
|
|
let d=''; |
|
|
|
for (let i=0; i<data.length; i++) { |
|
const circles = data.slice(0,3); |
|
data.push(data.shift()); |
|
|
|
// calculate points and handles for metaball path |
|
const pointsAndHandles=curvesBetweenCircles(circles[0].r, circles[1].r, circles[0].c, circles[1].c); |
|
// calculate for the next one, to draw arch (this could be optimized) |
|
const next=curvesBetweenCircles(circles[1].r, circles[2].r, circles[1].c, circles[2].c); |
|
|
|
// set the starting point of the path ONLY in case it is the first circle |
|
if (i===0) d+=`M ${pointsAndHandles.p[1][0]},${pointsAndHandles.p[1][1]} `; |
|
|
|
// draw bezier curve and arc |
|
d+=`C ${pointsAndHandles.h[1][0]},${pointsAndHandles.h[1][1]} ${pointsAndHandles.h[3][0]},${pointsAndHandles.h[3][1]} ${pointsAndHandles.p[3][0]},${pointsAndHandles.p[3][1]}` |
|
d+=`A ${next.r1}, ${next.r1}, 1, 0, 1, ${next.p[1][0]}, ${next.p[1][1]}` |
|
} |
|
|
|
// close the path |
|
d+='Z' |
|
|
|
// draw the path |
|
d3.select('svg > g') |
|
.append('path') |
|
.attr('d',d) |
|
.attr('fill','#CDDC39') |
|
.attr('stroke','#7CB342'); |
|
|
|
// draw the circles and numbers for visual feedback |
|
data.forEach((c,i)=>{ |
|
d3.select('svg > g') |
|
.append('circle') |
|
.attr('r',c.r) |
|
.attr('cx',c.x) |
|
.attr('cy',c.y) |
|
.attr('fill','rgba(255,255,255,0.4)'); |
|
|
|
d3.select('svg > g') |
|
.append('text') |
|
.attr('x',c.x) |
|
.attr('y',c.y+3) |
|
.attr('text-anchor','middle') |
|
.attr('font-size','9') |
|
.text(i) |
|
}) |
|
|
|
/** |
|
* Based on Metaball script by SATO Hiroyuki |
|
* http://shspage.com/aijs/en/#metaball |
|
*/ |
|
function curvesBetweenCircles(radius1, radius2, center1, center2, handleSize = 2.4, v = 0.5) { |
|
const HALF_PI = Math.PI / 2; |
|
const d = dist(center1, center2); |
|
let u1 = 0, u2 = 0; |
|
|
|
if (radius1 === 0 || radius2 === 0 || d <= Math.abs(radius1 - radius2)) { |
|
return null; |
|
} |
|
|
|
if (d < radius1 + radius2) { |
|
u1 = Math.acos( |
|
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d), |
|
); |
|
u2 = Math.acos( |
|
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d), |
|
); |
|
} |
|
|
|
// All the angles |
|
const angleBetweenCenters = angle(center2, center1); |
|
const maxSpread = Math.acos((radius1 - radius2) / d); |
|
|
|
const angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v; |
|
const angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * v; |
|
const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - maxSpread) * v; |
|
const angle4 = angleBetweenCenters - Math.PI + u2 + (Math.PI - u2 - maxSpread) * v; |
|
|
|
// Points |
|
const p1 = getVector(center1, angle1, radius1); |
|
const p2 = getVector(center1, angle2, radius1); |
|
const p3 = getVector(center2, angle3, radius2); |
|
const p4 = getVector(center2, angle4, radius2); |
|
|
|
// Define handle length by the |
|
// distance between both ends of the curve |
|
const totalRadius = radius1 + radius2; |
|
const d2Base = Math.min(v * handleSize, dist(p1, p3) / totalRadius); |
|
|
|
// Take into account when circles are overlapping |
|
const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2)); |
|
|
|
const r1 = radius1 * d2; |
|
const r2 = radius2 * d2; |
|
|
|
const h1 = getVector(p1, angle1 - HALF_PI, r1); |
|
const h2 = getVector(p2, angle2 + HALF_PI, r1); |
|
const h3 = getVector(p3, angle3 + HALF_PI, r2); |
|
const h4 = getVector(p4, angle4 - HALF_PI, r2); |
|
|
|
return { |
|
p : [ p1, p2, p3, p4 ], |
|
h : [ h1, h2, h3, h4 ], |
|
escaped : d > radius1, |
|
r : radius2, |
|
r1 : radius1 |
|
} |
|
} |
|
|
|
/** |
|
* Utils |
|
*/ |
|
function moveTo([x, y] = [0, 0], element) { |
|
element.setAttribute('cx', x); |
|
element.setAttribute('cy', y); |
|
} |
|
|
|
function line([x1, y1] = [0, 0], [x2, y2] = [0, 0], element) { |
|
element.setAttribute('x1', x1); |
|
element.setAttribute('y1', y1); |
|
element.setAttribute('x2', x2); |
|
element.setAttribute('y2', y2); |
|
} |
|
|
|
function dist([x1, y1], [x2, y2]) { |
|
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5; |
|
} |
|
|
|
function angle([x1, y1], [x2, y2]) { |
|
return Math.atan2(y1 - y2, x1 - x2); |
|
} |
|
|
|
function getVector([cx, cy], a, r) { |
|
return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; |
|
} |
|
</script> |
|
</body> |
|
</html> |