Другими словами мы имеем частный случай задачи по раскрою материала.

Немного предыстории: в одном из своих проектов у меня появилась задача по расчету необходимого количества листов металла для производства деталей круглой формы. В моем случае это листы металла стандартного габарита 6000x1500 миллиметров.

Поискав готовые решения этой задачи, я решил написать свой вариант.

Не судите строго это все родилось за несколько часов :)

Постановка задачи

Найти наименьшее количество листов металла, заданного габарита для производства заданного списка деталей круглой формы, заданных своими диаметрами.

Описание алгоритма

Из полученного на входе словаря, содержащего радиусы кругов и их количества мы формируем массив, вида:

# Словарь на входе от пользователя
user_circles = {
    830 : 1,
    730 : 2,
    490 : 4,
    360 : 6,
    280 : 8,
    150 : 12,
    100 : 16,
    50 : 18,
}
# Массив упорядоченных по убыванию радиуса объектов-кругов
self.circles = [
  Circle(830),
  Circle(730),
  Circle(730),
  Circle(490),
  Circle(490),
  Circle(490),
  Circle(490),
  ...
]

Далее мы создаем новый лист (прямоугольник) заданных пользователем размеров:

self.sheets.append(Sheet(self.sheet_w, self.sheet_h))

Организуем цикл по массиву с упорядоченными объектами-кругами и для каждого круга используем метод поиска наилучшей координаты Y:

for i in self.circles:
  i.cx, i.cy = self.find_best_packing_start_point(current_sheet, i)

Вот небольшая иллюстрация этого алгоритма:

Для каждого следующего круга ищется наилучшая координата Y
Для каждого следующего круга ищется наилучшая координата Y

Если очередной круг был успешно помещен на текущий лист (прямоугольник) то из словаря кругов, заданного пользователей вычитается круг данного радиуса:

self.user_circles[i.r] -= 1

Если текущий круг не входит в лист (прямоугольник) по габаритам, то он добавляется в переменную:

self.circles_excluded[i.r] += 1

Когда для текущего листа (прямоугольника) мы попробовали разместить все круги из массива self.circles мы генерируем заново массив self.circles из оставшихся для размещения кругов:

# Убираем круги, которые уже размещены
  def clean_circles(self):
    circles_cleaned = []
    for i in self.user_circles.keys():
      for k in range(self.user_circles[i]):
        circles_cleaned.append(Circle(i))
    self.circles = circles_cleaned

Далее мы добавляем следующий лист и повторяем алгоритм пока не останется кругов для размещения.

Исходный код на Python

import svgwrite
import math

class Sheet:

  def __init__(self, w, h):
    self.w = w
    self.h = h
    self.circles = []


class Circle:

  def __init__(self, r, cx=None, cy=None):
    self.cx = cx
    self.cy = cy
    self.r = r
  
  
  @staticmethod
  def two_circle_intersections(c1, c2):
    x0, y0, r0 = c1.cx, c1.cy, c1.r
    x1, y1, r1 = c2.cx, c2.cy, c2.r
    d = math.sqrt((x1-x0)**2 + (y1-y0)**2)
    if d > r0 + r1 :
        return False
    if d < abs(r0-r1):
        return False
    if d == 0 and r0 == r1:
        return False
    else:
      return True


class CirclePacking:

  ACCURACY = 20 # На сколько частей делим высоту для поиска наилучшей позиции по вертикали для текущего круга
  
  def __init__(self, sheet_w, sheet_h, circles, cut_border):
    self.sheet_w = sheet_w
    self.sheet_h = sheet_h
    self.user_circles = circles
    self.circles = []
    self.sheets = []
    self.circles_excluded = {}
    self.cut_border = cut_border


  def create_circles_sorted(self):
    self.circles = []
    for i in reversed(sorted(self.user_circles.keys())):
      for k in range(self.user_circles[i]):
        self.circles.append(Circle(i))


  # Убираем круги, которые уже размещены
  def clean_circles(self):
    circles_cleaned = []
    for i in self.user_circles.keys():
      for k in range(self.user_circles[i]):
        circles_cleaned.append(Circle(i))
    self.circles = circles_cleaned


  def packing(self):
    self.create_circles_sorted()
    while sum(self.user_circles.values()) > 0:
      #print(self.user_circles, self.circles)
      self.sheets.append(Sheet(self.sheet_w, self.sheet_h))
      current_sheet = self.sheets[-1]
      for i in self.circles:
        i.cx, i.cy = self.find_best_packing_start_point(current_sheet, i)
        if i.cx is None and i.cy is None:
          if i.r not in self.circles_excluded:
            self.circles_excluded[i.r] = 1
          else:
            self.circles_excluded[i.r] += 1
          self.user_circles[i.r] -= 1
        else:
          # Проверка что мы не вышли за длину листа
          if i.cx > current_sheet.w - i.r - self.cut_border:
            pass
          else:
            self.user_circles[i.r] -= 1
            current_sheet.circles.append(i)
      self.clean_circles()


  # Ищем наилучшую позицию по вертикали для размещения круга 
  def find_best_packing_start_point(self, s, c):
    try_results = {}
    N = CirclePacking.ACCURACY
    step = int((s.h -c.r) / N)
    # Формируем массив координат по вертикали для попыток
    try_y = [c.r, s.h-c.r, s.h/2]
    for i in range(int((s.h - c.r) / step)):
      try_y.append(c.r + step * i)
    # Пробуем все координаты из массива попыток
    for i in try_y:
      c.cx = s.w + c.r
      c.cy = i
      self.move_circle(s, c)
      # Проверяем что мы вошли на лист
      if c.cy >= c.r + self.cut_border and c.cy <= s.h - c.r - self.cut_border:
        try_results[c.cx] = c.cy
    if try_results == {}:
      cx = cy = None
    else:
      cx = sorted(try_results.keys())[0]
      cy = try_results[cx]
    return cx, cy


  def move_circle(self, s, c):
    while c.cx > c.r + self.cut_border:
      if self.check_intersections(s, c):
        return
      c.cx -= 1


  def check_intersections(self, s, c):
    for i in s.circles:
      i.r += self.cut_border
      if Circle.two_circle_intersections(i, c):
        i.r -= self.cut_border
        return True
      i.r -= self.cut_border
    return False


  def draw_sheet_with_circles(self, sheet_idx, scale, filename):
    sheet = self.sheets[sheet_idx]
    w = sheet.w * scale
    h = sheet.h * scale
    border_x = 150 * scale
    border_y = 150 * scale
    svg = svgwrite.Drawing(filename = filename, size = (str(w+border_x*2)+'px', str(h+border_y*2)+'px'))
    svg.add(svg.line(stroke='red', start=(str(0+border_x)+'px', str(0+border_y)+'px'), end=(str(w+border_x)+'px', str(0+border_y)+'px')))
    svg.add(svg.line(stroke='red', start=(str(0+border_x)+'px', str(0+border_y)+'px'), end=(str(0+border_x)+'px', str(h+border_y)+'px')))
    svg.add(svg.line(stroke='red', start=(str(0+border_x)+'px', str(h+border_y)+'px'), end=(str(w+border_x)+'px', str(h+border_y)+'px')))
    line1 = svg.add(svg.line(stroke='red', start=(str(w+border_x)+'px', str(0+border_y)+'px'), end=(str(w+border_x)+'px', str(h+border_y)+'px')))
    line1.dasharray([5, 10])
    for i in sheet.circles:
      cx = i.cx * scale
      cy = i.cy * scale
      r = i.r * scale
      svg.add(svg.circle(center=(str(cx+border_x)+'px', str(cy+border_x)+'px'), r=str(r)+'px', stroke='black', fill='white', stroke_width=1))
    svg.save()

Пример работы алгоритма

Ниже приведен пример работы алгоритма:

circles = {
    830 : 1,
    730 : 2,
    490 : 4,
    360 : 6,
    280 : 8,
    150 : 12,
    100 : 16,
    50 : 18,
}

cp = CirclePacking(sheet_w=6000, sheet_h=1500, circles=circles, cut_border=20)
cp.packing()
Пример упаковки кругов на листы (прямоугольники)
Пример упаковки кругов на листы (прямоугольники)

Обратите внимание на то, что круг с радиусом 830 не вошел на листы 6000x1500 и он помещен в атрибут: cp.circles_excluded:

cp.circles_excluded
#  {830: 1}

Оставшиеся вопросы:

  • Как ускорить работу алгоритма не выходя за возможности стандартного Python (не использовать библиотеки ускорения кода Python)?

  • Как еще повысить плотность заполнения листов?

Ссылки

Github: https://github.com/tau15/python_circle_packing_in_rectangle

Google Colab: https://colab.research.google.com/drive/1e-RoNHStyqdyROZNPHga_vvrIKyRy3ZR?usp=sharing

Вопросы и предложения на алгоритму пишите мне: @TAU15

Комментарии (10)


  1. deemytch
    00.00.0000 00:00
    +5

    Отсортировать круги по размеру и брать с двух концов сразу? В примере весь третий лист мог бы войти в два предыдущих.


    1. alnite
      00.00.0000 00:00
      +4

      Вот кстати да, вы хорошо сформулировали "претензию" к алгоритму, что слишком много пустот остаётся на листе. Плюс, если это для раскроя, то совсем не обрабатывается уменьшение холостого хода раскроечной головки (что там, лазер, или плазма или другое).

      Плюс, если на ртсунках реальный результат алгоритма, то видно, что много кругов "посажены" не вкрай, а значит, теряется расстояние по длине.


    1. Artur_Averin
      00.00.0000 00:00
      +1

      Я бы сделал иначе - мы в угол листа выставляем первый, самый большой круг, и в полученный уголок страницы мы вписываем наиболее большой подходящий в него круг по размеру


    1. Biga
      00.00.0000 00:00
      +2

      Мне опыт подсказывает, что отсортировано правильно, но каждый следующий круг надо класть не вверх, а искать место по всему листу как можно ниже (левее?). То есть сначала идут большие круги, и как только дело дойдёт до круга, который поместится в уголок, туда его и помещать. Ниже уже написали, как выбрать наилучшее место на листе из всех возможных.


  1. SnakeSolid
    00.00.0000 00:00
    +3

    При "установке" очередного круга рассчитывать заполнители (как в диаграммах Вороного). Потом из пула "свободных" кругов сначала подбирать круги максимально вписывающиеся в заполнители. Если ничего подобрать не получилось - размещаем самый большой доступный круг в конец листа. При "установке" в заполнитель если новый круг при пересекает один или несколько заполнителей - удаляем все пересечения и пересчитываем заполнители для установленного круга. Самое сложное будет - найти места для заполнителей.

    Попытался нарисовать как это должно работать

    Синие - это места-заполнители, красная стрелка - добавленный круг.


    1. TAU15 Автор
      00.00.0000 00:00

      Да, отличная мысль! Попробую реализовать ;)


  1. Dimon41
    00.00.0000 00:00
    +1

    3й лист явно лишний. Я бы попробовал сротировать через один. Сначала самый большой потом самый маленький. Как колоду карт.


  1. vzhilin
    00.00.0000 00:00
    +1

    А я бы попробовал использовать SMT-решатель Z3.


    1. TAU15 Автор
      00.00.0000 00:00

      Интересно, изучу!


  1. zartdinov
    00.00.0000 00:00
    +2

    Нет смысла искать логичное решение, это стандартная NP-полная задача о рюкзаке.