Skip to content

Instantly share code, notes, and snippets.

@iosonosempreio
Last active June 12, 2020 13:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save iosonosempreio/df76b42e1d74b7414c6d5eb129b8c60c to your computer and use it in GitHub Desktop.
Save iosonosempreio/df76b42e1d74b7414c6d5eb129b8c60c to your computer and use it in GitHub Desktop.
SVG metaball - segments calculation
<html>
<head>
<title>Metaball</title>
<style>
.circles text {
font-size: 9px;
font-family: sans-serif;
}
</style>
</head>
<body>
<svg></svg>
<script src="https://d3js.org/d3.v5.min.js"></script>
<!-- <script src="http://www.kevlindev.com/gui/2D.js"></script> -->
<script src="https://cdn.jsdelivr.net/gh/thelonious/kld-intersections/dist/index-umd.js"></script>
<script>
const width=960,
height=500,
data = Array.from( Array(9), (x,i)=>({id:i,r:Math.round(d3.randomUniform(5,40)())}) ),
svg = d3.select('svg')
.attr('width',width)
.attr('height',height)
.style('background','#f6f6f6'),
g = svg.append('g')
.attr('transform','translate('+width/2+','+height/2+')'),
circles = g.append('g').classed('circles',true),
circle = circles.selectAll('circle')
.data(data).enter().append('circle')
.attr('r',d=>d.r)
.attr('fill','#d3d3d3'),
text = circles.selectAll('text')
.data(data).enter().append('text')
.text(d=>d.id),
metaballs = g.append('g').classed('metaballs',true),
convex_hulls = g.append('g').classed('convex-hulls',true),
intersections = g.append('g').classed('intersections',true)
simulation = d3.forceSimulation(data)
.force("x", d3.forceX(0))
.force("y", d3.forceY(0))
.force("collide", d3.forceCollide(d=>d.r-1))
.on('tick',()=>{
circle.attr('cx',d=>d.x).attr('cy',d=>d.y);
text.attr('x',d=>d.x).attr('y',d=>d.y);
})
.on('end', ()=>{
circle.attr('cx',d=>d.x).attr('cy',d=>d.y);
text.attr('x',d=>d.x).attr('y',d=>d.y);
// circle.remove();
drawMetaball(data);
})
.alphaMin(0.1)
.alpha(1)
.restart()
function drawMetaball(data, subset_data=undefined) {
// console.log('all data',data);
// add "c" property to all data elements
data.forEach(d=>d.c=[d.x,d.y]);
// only in first iteration
if (!subset_data) {
// calculate convex hull
const convex_hull = d3.polygonHull(data.map(d=>([d.x,d.y])))
// visual feedbacks
convex_hulls.selectAll('polygon').remove()
convex_hulls.append('polygon')
.attr('points',convex_hull)
.attr('fill','none')
.attr('stroke','red');
// console.log('convex hull points',convex_hull);
// generate first metaball with circles whose center are used in the hull
subset_data = convex_hull.reverse().map(d=>{
const elm=data.find(dd=>dd.x===d[0]&&dd.y===d[1]);
return elm;
} )
}
// console.log('subset data',subset_data);
// init the path
let metaball_path='';
for (let i=0; i<subset_data.length; i++) {
const circles = subset_data.slice(0,3);
subset_data.push(subset_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) metaball_path+=`M ${pointsAndHandles.p[1][0]},${pointsAndHandles.p[1][1]} `;
// draw bezier curve and arc
metaball_path+=`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]}`
metaball_path+=`A ${next.r1}, ${next.r1}, 1, 0, 1, ${next.p[1][0]}, ${next.p[1][1]}`
}
// close the path
metaball_path+='Z'
// visual feedback
metaballs.selectAll('path').remove()
const metaball = metaballs.append('path')
.attr('d',metaball_path)
.attr('fill','none')
.attr('stroke-width',2)
.attr('stroke','blue');
// circles not used to check for intersections
const check_intersections = data.filter(d=>subset_data.indexOf(d)<0);
// console.log('circles to check for intersections',check_intersections);
let redo_metaball=false;
for (let i=0; i<check_intersections.length; i++){
const this_circle = check_intersections[i];
// console.log('circle id', this_circle.id,' - ',this_circle);
const path = KldIntersections.ShapeInfo.path(metaball_path);
const circle = KldIntersections.ShapeInfo.circle([this_circle.x, this_circle.y], this_circle.r);
const intersections_data = KldIntersections.Intersection.intersect(path, circle);
// console.log('intersection data',intersections_data);
if (intersections_data.status==="Intersection") {
redo_metaball=true;
intersections.append('g').selectAll('circle').data(intersections_data.points).enter().append('circle')
.attr('r',2)
.attr('cx',d=>d.x)
.attr('cy',d=>d.y);
// add the circle to the array of elements that are used to generate the metaball, in the corrent position
// find the two adjacent circles
const adjacent_circles = intersections_data.points.map(d=>{
const subset_with_distances = subset_data.map( (dd,i)=>{
const a = d.x - dd.x;
const b = d.y - dd.y;
dd.distance = Math.sqrt( a*a + b*b );
dd.index = i;
return dd;
}).sort((a,b)=>a.distance-b.distance);
return subset_with_distances[0];
})
// console.log(adjacent_circles);
// console.log(subset_data.map(d=>d.id).join(', '));
if ( (adjacent_circles[0].index===0&&adjacent_circles[1].index===subset_data.length-1) || (adjacent_circles[1].index===0&&adjacent_circles[0].index===subset_data.length-1) ) {
subset_data.push(this_circle);
} else {
const append_after = adjacent_circles.sort((a,b)=>a.index-b.index)[0].index;
subset_data.splice(append_after+1, 0, this_circle);
}
// console.log(subset_data.map(d=>d.id).join(', '));
}
}
if (redo_metaball) {
// calcualte the metaball segments with the new circles array
drawMetaball(data, subset_data);
} else {
return metaball_path
}
}
/**
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment