neolateral

programming, drawing, photograpy etc.

Circle-packing

This is a common trope in graphics programming as almost everyone learning graphics attempts this at some time. It's simple and fun and cool.

So when I saw this one on "The Coding Train" I had to do it. There's two parts to this on the channel - Part I & Part II

What is Circle-Packing?

A Circle-packing program will attempt to pack as many circles of varying sizes across a canvas without any two circles overlapping.

Here's an example:

Simple Circle Packing

Circle-packing on a photo

In this simulation we will take an image and random pack circles across a canvas but picking up the color of the circle from the pixel on the image which corresponds to the center of the circle.

Here's a public domain tiger image:

Ref Tiger Image

And the result of circle packing using the image as reference.

Circle Packing Demo

Code

circle.lua

This program implements the Circle class used in the simulation.

--- circle.lua: Circle class for the circle packing simulation
--
-- date: 12/3/2024
-- author: Abhishek Mishra

local class = require 'lib.middleclass'

Circle = class('Circle')

local DEFAULT_COLOR = { r = 0, g = 0, b = 0, a = 1.0 }

--- Circle:initialize: Constructor for the Circle class
-- @param x: x-coordinate of the center of the circle
-- @param y: y-coordinate of the center of the circle
-- @param r: radius of the circle
-- @param cr: (optional) red component of the color of the circle
-- @param cg: (optional) green component of the color of the circle
-- @param cb: (optional) blue component of the color of the circle
-- @param ca: (optional) alpha component of the color of the circle
function Circle:initialize(x, y, r, cr, cg, cb, ca)
    self.x = x
    self.y = y
    self.r = r
    if cr and cg and cb and ca then
        self.color = { r = cr, g = cg, b = cb, a = ca }
    else
        self.color = DEFAULT_COLOR
    end

    -- internal state variable _growing, which is true if the circle is growing
    self._growing = true
end

--- Circle:grow: Increase the radius of the circle
-- @param dr: (default 1) amount by which to increase the radius
function Circle:grow(dr)
    dr = dr or 1
    if self._growing then
        self.r = self.r + dr
    end
end

--- Circle:edges: Check if the circle is touching the edges of the canvas
-- @return: true if the circle is touching the edges, false otherwise
function Circle:edges()
    local cw, ch = love.graphics.getDimensions()
    return self.x - self.r < 0 or self.x + self.r > cw
        or self.y - self.r < 0 or self.y + self.r > ch
end

--- Circle:update: Update the state of the circle
-- @param dt: time since the last update
function Circle:update(dt)
    -- if the circle is touching the edges, stop growing
    if self:edges() then
        self._growing = false
    end
end

--- Circle:draw: Draw the circle
function Circle:draw()
    love.graphics.setColor(self.color.r, self.color.g,
        self.color.b, self.color.a)
    love.graphics.circle("fill", self.x, self.y, self.r)
end

return Circle

main.lua

This is the main program for the simulation.

--- main.lua: Image Circle Packing Simulation in LÖVE
-- based on the circle-packing project in explorations
-- This follows video#2 in the coding train circle packing series
--
-- date: 14/3/2024
-- author: Abhishek Mishra

-- require the Circle class
local Circle = require 'circle'

-- canvas dimensions
local cw, ch

-- list of circles
local circles = {}

-- image to be used for the circles
local imageData

local running = false

--- check if a circle is valid, i.e., it doesn't overlap with any existing circles
-- nor is it inside any existing circle
-- @param x: x-coordinate of the center of the circle
-- @param y: y-coordinate of the center of the circle
-- @param r: radius of the circle
-- @return: true if the circle is valid, false otherwise
local function isValidCircle(x, y, r)
    for i = 1, #circles do
        local dx = circles[i].x - x
        local dy = circles[i].y - y
        local distance = math.sqrt(dx * dx + dy * dy)
        if distance < circles[i].r + r then
            return false
        end
    end
    return true
end

--- create a new circle while ensuring that it doesn't overlap with any existing circles
-- nor is it inside any existing circle
local function createCircle()
    local x, y, r
    local attempts = 0
    local maxAttempts = 100

    -- try to create a new circle
    repeat
        x = love.math.random(1, cw)
        y = love.math.random(1, ch)
        r = love.math.random(1, 5)
        attempts = attempts + 1
    until isValidCircle(x, y, r) or attempts > maxAttempts

    -- if we have reached the maximum number of attempts, return nil
    if attempts > maxAttempts then
        return nil
    end

    -- create a new circle and return it
    return Circle:new(x, y, r, imageData:getPixel(x - 1, y - 1))
end

--- love.load: Called once at the start of the simulation
function love.load()
    -- get the canvas size
    cw, ch = love.graphics.getDimensions()

    -- load the tiger image
    imageData = love.image.newImageData('tiger.jpg')
end

--- love.update: Called every frame, updates the simulation
function love.update(dt)
    if not running then
        return
    end

    -- -- add a new circle every 5 frames
    -- if love.timer.getFPS() % 5 == 0 then

    -- try to add 10 new circles every frame
    for i = 1, 10 do
        local newCircle = createCircle()
        if newCircle then
            table.insert(circles, newCircle)
        end
    end

    -- grow the circles
    for i = 1, #circles do
        circles[i]:update(dt)
        -- if the circle is growing check if it is now touching another circle
        -- if it is touching then stop growing
        if circles[i]._growing then
            for j = 1, #circles do
                if i ~= j then
                    local dx = circles[i].x - circles[j].x
                    local dy = circles[i].y - circles[j].y
                    local distance = math.sqrt(dx * dx + dy * dy)
                    if distance < circles[i].r + circles[j].r then
                        circles[i]._growing = false
                        break
                    end
                end
            end
        end
        circles[i]:grow(0.1)
    end
end

--- love.draw: Called every frame, draws the simulation
function love.draw()
    -- if not running, display the message to start the simulation
    if not running then
        love.graphics.print("Press Space to start the simulation", 50,
            love.graphics.getHeight() / 2)
        return
    end

    -- set the background color
    love.graphics.setBackgroundColor(0, 0, 0)

    -- draw the circle
    for i = 1, #circles do
        circles[i]:draw()
    end
end

-- escape to exit
-- press space to toggle the simulation
function love.keypressed(key)
    if key == "escape" then
        love.event.quit()
    end
    if key == "space" then
        running = not running
    end
end

Appendix

  • Tiger Image Source(tiger.jpg): https://www.pexels.com/photo/close-up-of-tiger-247615/