#!/usr/bin/env python3 # coding=utf-8 """ Noyau geometrique du circle packing. Ce module ne depend pas d'inkex : il peut etre importe et teste sans Inkscape. Un contour est represente par une liste d'anneaux (`rings`), chaque anneau etant une liste de points (x, y) decrivant un polygone ferme implicitement (le dernier point est relie au premier). """ import math import random def rings_bbox(rings): """Boite englobante (xmin, ymin, xmax, ymax), ou None si vide.""" xs = [p[0] for ring in rings for p in ring] ys = [p[1] for ring in rings for p in ring] if not xs: return None return min(xs), min(ys), max(xs), max(ys) def point_in_rings(x, y, rings): """Test d'appartenance par crossing number, regle pair-impair. La parite du nombre de traversees sur l'ensemble des anneaux donne directement la gestion des trous : un point situe dans un trou traverse un nombre pair de bords depuis l'infini. """ inside = False for ring in rings: n = len(ring) for i in range(n): x1, y1 = ring[i] x2, y2 = ring[(i + 1) % n] if (y1 > y) != (y2 > y): # abscisse de l'intersection du segment avec la droite Y = y xint = x1 + (y - y1) * (x2 - x1) / (y2 - y1) if x < xint: inside = not inside return inside def distance_point_segment(px, py, x1, y1, x2, y2): """Distance du point (px, py) au segment [(x1, y1), (x2, y2)].""" dx = x2 - x1 dy = y2 - y1 seg_len2 = dx * dx + dy * dy if seg_len2 == 0.0: return math.hypot(px - x1, py - y1) t = ((px - x1) * dx + (py - y1) * dy) / seg_len2 t = max(0.0, min(1.0, t)) return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) def distance_to_rings(x, y, rings): """Distance minimale du point au contour (bord exterieur ou trous).""" best = float('inf') for ring in rings: n = len(ring) for i in range(n): x1, y1 = ring[i] x2, y2 = ring[(i + 1) % n] d = distance_point_segment(x, y, x1, y1, x2, y2) if d < best: best = d return best class SpatialGrid: """Grille de hachage spatial pour retrouver rapidement les cercles voisins. Sans elle, chaque tentative comparerait le point candidat a tous les cercles deja poses, ce qui rend les dizaines de milliers de tentatives inutilisables. """ def __init__(self, cell_size): self.cell_size = max(cell_size, 1e-9) self.cells = {} self.circles = [] def _key(self, x, y): return (int(math.floor(x / self.cell_size)), int(math.floor(y / self.cell_size))) def add(self, x, y, r): index = len(self.circles) self.circles.append((x, y, r)) self.cells.setdefault(self._key(x, y), []).append(index) def neighbors(self, x, y, reach): """Cercles dont le centre tombe dans les cellules couvrant `reach`.""" span = int(math.ceil(reach / self.cell_size)) ci, cj = self._key(x, y) for i in range(ci - span, ci + span + 1): for j in range(cj - span, cj + span + 1): for index in self.cells.get((i, j), ()): yield self.circles[index] def __len__(self): return len(self.circles) def pack_circles(rings, min_radius, max_radius, gap=0.0, margin=0.0, attempts=20000, max_circles=0, rng=None): """Greedy random circle packing. Renvoie la liste des (x, y, r) places. A chaque tentative, un point est tire au hasard dans la boite englobante ; s'il est interieur, on y place le plus grand cercle qui respecte `margin` vis-a-vis du contour et `gap` vis-a-vis des cercles deja poses. """ rng = rng or random bbox = rings_bbox(rings) if bbox is None: return [] xmin, ymin, xmax, ymax = bbox if xmax - xmin <= 0 or ymax - ymin <= 0: return [] grid = SpatialGrid(max_radius + gap) # Un voisin ne peut contraindre le rayon que si son centre est a moins de # son rayon (<= max_radius) + max_radius + gap. reach = 2.0 * max_radius + gap for _ in range(attempts): if max_circles and len(grid) >= max_circles: break x = rng.uniform(xmin, xmax) y = rng.uniform(ymin, ymax) if not point_in_rings(x, y, rings): continue radius = min(max_radius, distance_to_rings(x, y, rings) - margin) if radius < min_radius: continue for cx, cy, cr in grid.neighbors(x, y, reach): radius = min(radius, math.hypot(x - cx, y - cy) - cr - gap) if radius < min_radius: break if radius >= min_radius: grid.add(x, y, radius) return list(grid.circles)