
How it works:
- Input: Takes your selected shape as the container.
- Point Generation: Randomly picks a point inside that shape.
- Collision Check: Checks if that point is too close to any existing circle.
- Growth: If the point is valid, it calculates the maximum possible radius it can grow to before hitting another circle or the container edge.
- Optimization: It uses a spatial grid system (a “QuadTree-lite” approach) to speed up collision checks, preventing the script from freezing on high circle counts.

JavaScript
/*
Circle Packer Pro for Adobe Illustrator 2026
Algorithm: Stochastic Growth with Spatial Grid Optimization
Usage: Select one path item and run the script.
*/
#target illustrator
(function() {
// --- SETTINGS ---
var SETTINGS = {
minRadius: 2, // Minimum circle size
maxRadius: 50, // Maximum circle size
padding: 1, // Gap between circles
totalCircles: 1000, // Number of circles to attempt
attemptsPerCircle: 200, // How hard to try to fit a circle before giving up
circleColor: "Random", // "Random", "Black", or "SelectedSwatches"
createGroup: true // Group the resulting circles?
};
// --- MAIN LOGIC ---
function main() {
if (app.documents.length === 0) {
alert("Please open a document and select a shape.");
return;
}
var doc = app.activeDocument;
var sel = doc.selection;
if (sel.length !== 1 || sel[0].typename === "GroupItem") {
alert("Please select exactly one Path Item (not a group).");
return;
}
var container = sel[0];
// Show Dialog
if (!showDialog(SETTINGS)) return;
// Prepare the container info
var bounds = container.geometricBounds; // [left, top, right, bottom]
var left = bounds[0];
var top = bounds[1];
var right = bounds[2];
var bottom = bounds[3];
var width = right - left;
var height = top - bottom; // Top is y-max, Bottom is y-min in AI
// Extract path coordinates for point-in-polygon check
var polygonPoints = getPathPoints(container);
// Optimization: Spatial Grid
// We divide the area into cells to avoid checking every single circle against every other circle
var cellSize = SETTINGS.maxRadius * 2;
var cols = Math.ceil(width / cellSize);
var rows = Math.ceil(height / cellSize);
var grid = [];
for (var i = 0; i < cols * rows; i++) grid.push([]);
var packedCircles = [];
// Progress Bar (Basic visual feedback using the UI)
var win = new Window("palette", "Packing Circles...");
var progressBar = win.add("progressbar", undefined, 0, SETTINGS.totalCircles);
progressBar.preferredSize.width = 300;
win.show();
// ---------------- PACKING LOOP ----------------
for (var i = 0; i < SETTINGS.totalCircles; i++) {
// Update progress bar every 10 circles to keep UI responsive
if (i % 10 === 0) {
progressBar.value = i;
win.update();
}
var bestCircle = null;
// Try N times to find a valid spot for a new circle
for (var j = 0; j < SETTINGS.attemptsPerCircle; j++) {
// 1. Pick a random point inside the bounding box
var cx = left + Math.random() * width;
var cy = top - Math.random() * height; // Remember Y goes down in some contexts, but bounds are standard
// 2. Fast Fail: Is point inside the polygon shape?
// This is computationally expensive, so we do it first or skip for rectangles
if (!isPointInPolygon([cx, cy], polygonPoints)) continue;
// 3. Find closest distance to any neighbor (Collision Check)
var minDistanceToNeighbor = Number.MAX_VALUE;
var closestIsInvalid = false;
// Determine grid cell
var col = Math.floor((cx - left) / cellSize);
var row = Math.floor((top - cy) / cellSize);
// Check current cell and immediate neighbors (3x3 grid)
var cellsToCheck = getNeighborCells(col, row, cols, rows);
for (var k = 0; k < cellsToCheck.length; k++) {
var cellIndex = cellsToCheck[k];
var cellCircles = grid[cellIndex];
for (var m = 0; m < cellCircles.length; m++) {
var other = cellCircles[m];
var dist = getDistance(cx, cy, other.x, other.y);
// Distance from center to center minus the other circle's radius
var availableSpace = dist - other.r - SETTINGS.padding;
if (availableSpace < minDistanceToNeighbor) {
minDistanceToNeighbor = availableSpace;
}
// If we are already overlapping
if (minDistanceToNeighbor < SETTINGS.minRadius) {
closestIsInvalid = true;
break;
}
}
if (closestIsInvalid) break;
}
if (closestIsInvalid) continue;
// 4. Check distance to container edge (Optional - simplified here to bounding box for speed)
// For exact path edge collision, it's very slow in ExtendScript.
// We rely on the initial point being inside and growth being limited by neighbors.
// A basic box clamp:
var distToEdge = Math.min(
cx - left,
right - cx,
top - cy,
cy - bottom
);
// The max radius is the minimum of: Max Limit, Distance to Neighbor, Distance to Edge
var possibleRadius = Math.min(SETTINGS.maxRadius, minDistanceToNeighbor, distToEdge);
if (possibleRadius >= SETTINGS.minRadius) {
bestCircle = { x: cx, y: cy, r: possibleRadius };
break; // Found a valid spot!
}
}
// 5. If we found a valid circle, add it to data and grid
if (bestCircle) {
packedCircles.push(bestCircle);
var cCol = Math.floor((bestCircle.x - left) / cellSize);
var cRow = Math.floor((top - bestCircle.y) / cellSize);
var cIndex = cRow * cols + cCol;
if(grid[cIndex]) grid[cIndex].push(bestCircle);
}
}
win.close();
// ---------------- DRAWING ----------------
// Create a group for the new circles
var targetLayer = doc.activeLayer;
var group = targetLayer.groupItems.add();
group.name = "Packed Circles";
// Get colors if needed
var swatches = doc.swatches;
var selectedSwatches = doc.swatches.getSelected();
for (var i = 0; i < packedCircles.length; i++) {
var c = packedCircles[i];
var ellipse = group.pathItems.ellipse(
c.y + c.r, // Top
c.x - c.r, // Left
c.r * 2, // Width
c.r * 2 // Height
);
ellipse.stroked = false;
ellipse.filled = true;
// Apply Color
if (SETTINGS.circleColor === "Random") {
var col = new RGBColor();
col.red = Math.random() * 255;
col.green = Math.random() * 255;
col.blue = Math.random() * 255;
ellipse.fillColor = col;
} else if (SETTINGS.circleColor === "SelectedSwatches" && selectedSwatches.length > 0) {
var randSwatch = selectedSwatches[Math.floor(Math.random() * selectedSwatches.length)];
ellipse.fillColor = randSwatch.color;
} else {
var black = new GrayColor();
black.gray = 100;
ellipse.fillColor = black;
}
}
if (!SETTINGS.createGroup) {
// Ungroup if requested
while(group.pageItems.length > 0) {
group.pageItems[0].move(targetLayer, ElementPlacement.PLACEATEND);
}
group.remove();
}
}
// --- HELPER FUNCTIONS ---
function getDistance(x1, y1, x2, y2) {
var dx = x1 - x2;
var dy = y1 - y2;
return Math.sqrt(dx * dx + dy * dy);
}
function getNeighborCells(col, row, cols, rows) {
var cells = [];
for (var x = -1; x <= 1; x++) {
for (var y = -1; y <= 1; y++) {
var nc = col + x;
var nr = row + y;
if (nc >= 0 && nc < cols && nr >= 0 && nr < rows) {
cells.push(nr * cols + nc);
}
}
}
return cells;
}
// Ray-casting algorithm to check if point is in arbitrary polygon
function isPointInPolygon(point, vs) {
var x = point[0], y = point[1];
var inside = false;
for (var i = 0, j = vs.length - 1; i < vs.length; j = i++) {
var xi = vs[i][0], yi = vs[i][1];
var xj = vs[j][0], yj = vs[j][1];
var intersect = ((yi > y) != (yj > y))
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
function getPathPoints(pathItem) {
var points = [];
// Handle simple paths. Compound paths are complex and treated as bounding box for simplicity here
// or we take the first subpath.
if (pathItem.pathPoints) {
for (var i = 0; i < pathItem.pathPoints.length; i++) {
var p = pathItem.pathPoints[i].anchor;
points.push(p);
}
}
return points;
}
function showDialog(settings) {
var dlg = new Window("dialog", "Circle Packing Settings");
dlg.orientation = "column";
dlg.alignChildren = "fill";
var p1 = dlg.add("panel", undefined, "Dimensions");
p1.orientation = "row";
p1.add("statictext", undefined, "Min Radius:");
var eMin = p1.add("edittext", undefined, settings.minRadius); eMin.characters = 5;
p1.add("statictext", undefined, "Max Radius:");
var eMax = p1.add("edittext", undefined, settings.maxRadius); eMax.characters = 5;
var p2 = dlg.add("panel", undefined, "Density");
p2.orientation = "row";
p2.add("statictext", undefined, "Count:");
var eCount = p2.add("edittext", undefined, settings.totalCircles); eCount.characters = 6;
p2.add("statictext", undefined, "Padding:");
var ePad = p2.add("edittext", undefined, settings.padding); ePad.characters = 4;
var p3 = dlg.add("panel", undefined, "Appearance");
p3.alignChildren = "left";
var rRand = p3.add("radiobutton", undefined, "Random Colors");
var rSwatch = p3.add("radiobutton", undefined, "Use Selected Swatches");
var rBlack = p3.add("radiobutton", undefined, "Black");
rRand.value = true;
var btnGroup = dlg.add("group");
btnGroup.alignment = "center";
var okBtn = btnGroup.add("button", undefined, "Pack");
var cancelBtn = btnGroup.add("button", undefined, "Cancel");
okBtn.onClick = function() {
settings.minRadius = parseFloat(eMin.text) || 2;
settings.maxRadius = parseFloat(eMax.text) || 50;
settings.totalCircles = parseInt(eCount.text) || 500;
settings.padding = parseFloat(ePad.text) || 0;
if (rRand.value) settings.circleColor = "Random";
else if (rSwatch.value) settings.circleColor = "SelectedSwatches";
else settings.circleColor = "Black";
dlg.close(1);
};
return dlg.show() === 1;
}
main();
})();Installation & Usage
- Save the file: Copy the code above into a text editor (like Notepad or TextEdit) and save it as CirclePacker.jsx.
- Open Illustrator: Create a new document or open an existing one.
- Draw a Shape: Draw a shape (e.g., a circle, a star, or a blob drawn with the pen tool).
- Select the Shape: Use the Selection Tool (V) to select your shape.
- Run the Script:
- Go to File > Scripts > Other Script…
- Locate your CirclePacker.jsx file and click Open.
- Adjust Settings: A menu will pop up allowing you to change the Min/Max radius and the number of circles (Density).
Tips for Best Results
- Complex Shapes: The script uses a “Ray Casting” method to check if a point is inside your shape. This works best on single, closed paths. If you have a “Compound Path” (like a donut), release the compound path or use Object > Flatten Transparency first for better detection.
- Performance: For “Total Circles” over 2,000, the script might take a few seconds to process. The progress bar will let you know it’s working.[1]
- Colors: If you select “Use Selected Swatches,” make sure you have some color swatches selected in the Swatches Panel before running the script; otherwise, it will default to black.