Below is a square with a few equal circles dropped at random — they overlap and spill messily. Press Relax to nudge each circle away from its neighbors and the walls, one small step at a time, while slowly growing the radius. Watch how a clean grid almost never appears; the circles settle into a slightly irregular arrangement instead.
<p class="hint">A few equal circles are dropped at random inside the square — they overlap messily. Press <b>Relax</b> to nudge each circle away from its neighbors and the walls while slowly growing the radius. A clean grid almost never appears; the packing settles into a slightly irregular arrangement instead.</p>
<canvas id="cv" width="320" height="320"></canvas>
<div class="status" id="status">Drop circles, then relax.</div>
<div class="btns">
<button id="relax" type="button">Relax (×40 steps)</button>
<button id="step" type="button" class="ghost">One step</button>
<button id="reset" type="button" class="ghost">New random drop</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
#cv { background: #f3f6f9; border: 1px solid #cdd9e3; border-radius: 10px; display: block;
width: 320px; max-width: 100%; height: auto; touch-action: none; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Circle packing by RELAXATION (local search). N equal circles live in a square.
// Each step: (1) push overlapping pairs apart, (2) push circles inside the walls,
// (3) if the layout is feasible (no overlaps, all inside), grow the radius a touch.
// Checking a layout is O(N^2) distance tests; finding the OPTIMAL packing is NP-hard,
// and the result here is a LOCAL optimum that is usually irregular, not a tidy grid.
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const W = 320, N = 7; // square side, number of circles
let circles = [], r = 16, dr = 1.5; // radius and current growth step
function reset(){
r = 16; dr = 1.5; circles = [];
for (let i = 0; i < N; i++)
circles.push({ x: r + Math.random()*(W-2*r), y: r + Math.random()*(W-2*r) });
setStatus(fmt('Dropped %N% circles. Press Relax.', { N: N }), '');
draw();
}
// True iff no two circles overlap and every circle is fully inside the square.
function feasible(rad){
for (const c of circles){
if (c.x < rad-0.01 || c.x > W-rad+0.01 || c.y < rad-0.01 || c.y > W-rad+0.01) return false;
}
for (let i = 0; i < N; i++) for (let j = i+1; j < N; j++){
const dx = circles[i].x-circles[j].x, dy = circles[i].y-circles[j].y;
if (dx*dx + dy*dy < (2*rad)*(2*rad) - 0.01) return false;
}
return true;
}
// Smallest gap (negative = overlap) over all pairs and walls, at radius rad.
function tightness(rad){
let m = Infinity;
for (const c of circles) m = Math.min(m, c.x-rad, W-rad-c.x, c.y-rad, W-rad-c.y);
for (let i = 0; i < N; i++) for (let j = i+1; j < N; j++){
const dx = circles[i].x-circles[j].x, dy = circles[i].y-circles[j].y;
m = Math.min(m, Math.hypot(dx, dy) - 2*rad);
}
return m;
}
function separate(){
// Push overlapping pairs apart, then push circles back inside the walls. Iterate
// until the layout stops moving (converged) or a pass cap is hit, so the resulting
// tightness reading is trustworthy and the grow/shrink decision below is sound.
for (let pass = 0; pass < 120; pass++){
let moved = 0;
for (let i = 0; i < N; i++) for (let j = i+1; j < N; j++){
const a = circles[i], b = circles[j];
let dx = b.x-a.x, dy = b.y-a.y, d = Math.hypot(dx, dy) || 0.001;
const overlap = 2*r - d;
if (overlap > 0){
const ux = dx/d, uy = dy/d, push = overlap/2;
a.x -= ux*push; a.y -= uy*push; b.x += ux*push; b.y += uy*push;
moved = Math.max(moved, overlap);
}
}
for (const c of circles){
c.x = Math.max(r, Math.min(W-r, c.x));
c.y = Math.max(r, Math.min(W-r, c.y));
}
if (moved < 0.02) break; // converged
}
}
function step(){
// RELAX, then ADAPTIVELY GROW. Spread the circles so none overlap, then try to
// inflate the shared radius by the current step `dr`. If the bigger circles still
// fit after the layout re-settles, keep the growth; if not, undo it and halve the
// step. The radius homes in on the tightest feasible packing (a binary search on
// size) and the layout you see is always a valid, overlap-free packing.
separate();
if (dr > 0.05 && r + dr <= W/2){
const saved = circles.map(c => ({ x: c.x, y: c.y }));
r += dr; separate();
if (tightness(r) < -0.05){ r -= dr; circles = saved; dr *= 0.5; } // too greedy: back off
}
draw();
}
function relax(){
for (let k = 0; k < 40; k++) step();
const ok = feasible(r);
setStatus(fmt('%v%Settled at radius %r% — %v2%', { v: ok ? '✓ ' : '', r: r.toFixed(1), v2: ok ? 'a valid (locally optimal) packing.' : 'still resolving overlaps...' }),
ok ? 'ok' : 'bad');
console.log('radius', r.toFixed(2), 'feasible', ok);
}
function draw(){
ctx.clearRect(0,0,W,W);
ctx.strokeStyle = '#cdd9e3'; ctx.strokeRect(0.5,0.5,W-1,W-1);
for (const c of circles){
ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, 7);
ctx.fillStyle = 'rgba(29,53,87,0.18)'; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = '#1d3557'; ctx.stroke();
ctx.beginPath(); ctx.arc(c.x, c.y, 2, 0, 7); ctx.fillStyle = '#1d3557'; ctx.fill();
}
}
function setStatus(t, cls){ const s = document.getElementById('status'); s.textContent = t; s.className = 'status '+cls; }
document.getElementById('relax').addEventListener('click', relax);
document.getElementById('step').addEventListener('click', step);
document.getElementById('reset').addEventListener('click', reset);
reset();
<p class="hint">A few equal circles are dropped at random inside the square — they overlap messily. Press <b>Relax</b> to nudge each circle away from its neighbors and the walls while slowly growing the radius. A clean grid almost never appears; the packing settles into a slightly irregular arrangement instead.</p>
<canvas id="cv" width="320" height="320"></canvas>
<div class="status" id="status">Drop circles, then relax.</div>
<div class="btns">
<button id="relax" type="button">Relax (×40 steps)</button>
<button id="step" type="button" class="ghost">One step</button>
<button id="reset" type="button" class="ghost">New random drop</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
#cv { background: #f3f6f9; border: 1px solid #cdd9e3; border-radius: 10px; display: block;
width: 320px; max-width: 100%; height: auto; touch-action: none; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Circle packing by RELAXATION (local search). N equal circles live in a square.
// Each step: (1) push overlapping pairs apart, (2) push circles inside the walls,
// (3) if the layout is feasible (no overlaps, all inside), grow the radius a touch.
// Checking a layout is O(N^2) distance tests; finding the OPTIMAL packing is NP-hard,
// and the result here is a LOCAL optimum that is usually irregular, not a tidy grid.
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const W = 320, N = 7; // square side, number of circles
let circles = [], r = 16, dr = 1.5; // radius and current growth step
function reset(){
r = 16; dr = 1.5; circles = [];
for (let i = 0; i < N; i++)
circles.push({ x: r + Math.random()*(W-2*r), y: r + Math.random()*(W-2*r) });
setStatus(fmt('Dropped %N% circles. Press Relax.', { N: N }), '');
draw();
}
// True iff no two circles overlap and every circle is fully inside the square.
function feasible(rad){
for (const c of circles){
if (c.x < rad-0.01 || c.x > W-rad+0.01 || c.y < rad-0.01 || c.y > W-rad+0.01) return false;
}
for (let i = 0; i < N; i++) for (let j = i+1; j < N; j++){
const dx = circles[i].x-circles[j].x, dy = circles[i].y-circles[j].y;
if (dx*dx + dy*dy < (2*rad)*(2*rad) - 0.01) return false;
}
return true;
}
// Smallest gap (negative = overlap) over all pairs and walls, at radius rad.
function tightness(rad){
let m = Infinity;
for (const c of circles) m = Math.min(m, c.x-rad, W-rad-c.x, c.y-rad, W-rad-c.y);
for (let i = 0; i < N; i++) for (let j = i+1; j < N; j++){
const dx = circles[i].x-circles[j].x, dy = circles[i].y-circles[j].y;
m = Math.min(m, Math.hypot(dx, dy) - 2*rad);
}
return m;
}
function separate(){
// Push overlapping pairs apart, then push circles back inside the walls. Iterate
// until the layout stops moving (converged) or a pass cap is hit, so the resulting
// tightness reading is trustworthy and the grow/shrink decision below is sound.
for (let pass = 0; pass < 120; pass++){
let moved = 0;
for (let i = 0; i < N; i++) for (let j = i+1; j < N; j++){
const a = circles[i], b = circles[j];
let dx = b.x-a.x, dy = b.y-a.y, d = Math.hypot(dx, dy) || 0.001;
const overlap = 2*r - d;
if (overlap > 0){
const ux = dx/d, uy = dy/d, push = overlap/2;
a.x -= ux*push; a.y -= uy*push; b.x += ux*push; b.y += uy*push;
moved = Math.max(moved, overlap);
}
}
for (const c of circles){
c.x = Math.max(r, Math.min(W-r, c.x));
c.y = Math.max(r, Math.min(W-r, c.y));
}
if (moved < 0.02) break; // converged
}
}
function step(){
// RELAX, then ADAPTIVELY GROW. Spread the circles so none overlap, then try to
// inflate the shared radius by the current step `dr`. If the bigger circles still
// fit after the layout re-settles, keep the growth; if not, undo it and halve the
// step. The radius homes in on the tightest feasible packing (a binary search on
// size) and the layout you see is always a valid, overlap-free packing.
separate();
if (dr > 0.05 && r + dr <= W/2){
const saved = circles.map(c => ({ x: c.x, y: c.y }));
r += dr; separate();
if (tightness(r) < -0.05){ r -= dr; circles = saved; dr *= 0.5; } // too greedy: back off
}
draw();
}
function relax(){
for (let k = 0; k < 40; k++) step();
const ok = feasible(r);
setStatus(fmt('%v%Settled at radius %r% — %v2%', { v: ok ? '✓ ' : '', r: r.toFixed(1), v2: ok ? 'a valid (locally optimal) packing.' : 'still resolving overlaps...' }),
ok ? 'ok' : 'bad');
console.log('radius', r.toFixed(2), 'feasible', ok);
}
function draw(){
ctx.clearRect(0,0,W,W);
ctx.strokeStyle = '#cdd9e3'; ctx.strokeRect(0.5,0.5,W-1,W-1);
for (const c of circles){
ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, 7);
ctx.fillStyle = 'rgba(29,53,87,0.18)'; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = '#1d3557'; ctx.stroke();
ctx.beginPath(); ctx.arc(c.x, c.y, 2, 0, 7); ctx.fillStyle = '#1d3557'; ctx.fill();
}
}
function setStatus(t, cls){ const s = document.getElementById('status'); s.textContent = t; s.className = 'status '+cls; }
document.getElementById('relax').addEventListener('click', relax);
document.getElementById('step').addEventListener('click', step);
document.getElementById('reset').addEventListener('click', reset);
reset();
Comments
Loading comments...