#!/usr/bin/env python3 # coding=utf-8 """ Extension Inkscape : remplit une forme fermee par circle packing. Pour chaque forme selectionnee, des points sont tires au hasard dans la boite englobante de la forme ; si le point est interieur, on y place le plus grand cercle possible qui ne deborde pas du contour et ne chevauche aucun cercle deja pose (greedy random placement). La geometrie vit dans `packing_core`, sans dependance a inkex. """ import math import os import random import sys import inkex from inkex import bezier # L'extension vit dans son propre sous-dossier des extensions Inkscape, qui # n'est pas forcement dans sys.path : on l'y ajoute pour trouver packing_core. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from packing_core import pack_circles # noqa: E402 def flatten_path(path, flatness): """Convertit un inkex.Path en liste d'anneaux (listes de points).""" csp = path.to_superpath() bezier.cspsubdiv(csp, flatness) rings = [] for subpath in csp: ring = [(float(knot[1][0]), float(knot[1][1])) for knot in subpath] # Un contour ferme repete son premier point : on le retire, la # fermeture etant implicite dans packing_core. if (len(ring) > 1 and math.isclose(ring[0][0], ring[-1][0], abs_tol=1e-9) and math.isclose(ring[0][1], ring[-1][1], abs_tol=1e-9)): ring.pop() if len(ring) >= 3: rings.append(ring) return rings class CirclePacking(inkex.EffectExtension): """Remplit chaque forme selectionnee avec des cercles non chevauchants.""" def add_arguments(self, pars): pars.add_argument("--tab", default="options") pars.add_argument("--min_radius", type=float, default=1.0) pars.add_argument("--max_radius", type=float, default=20.0) pars.add_argument("--gap", type=float, default=0.5) pars.add_argument("--margin", type=float, default=0.5) pars.add_argument("--unit", default="mm") pars.add_argument("--attempts", type=int, default=20000) pars.add_argument("--max_circles", type=int, default=5000) pars.add_argument("--seed", type=int, default=0) pars.add_argument("--flatness", type=float, default=0.2) pars.add_argument("--keep_original", type=inkex.Boolean, default=True) def effect(self): opt = self.options shapes = [] for elem in self.svg.selection.values(): if isinstance(elem, (inkex.Group, inkex.Layer)): # Un groupe selectionne est explore pour en extraire les formes. shapes.extend(child for child in elem.descendants() if self.is_fillable(child)) elif self.is_fillable(elem): shapes.append(elem) if not shapes: inkex.errormsg("Sélectionnez au moins une forme fermée " "(chemin, rectangle, cercle, ellipse, polygone).") return def to_uu(value): return self.svg.unittouu("{}{}".format(value, opt.unit)) min_radius = to_uu(opt.min_radius) max_radius = to_uu(opt.max_radius) gap = to_uu(opt.gap) margin = to_uu(opt.margin) if min_radius <= 0 or max_radius <= 0: inkex.errormsg("Les rayons doivent être strictement positifs.") return if min_radius > max_radius: inkex.errormsg("Le rayon minimal doit être inférieur au rayon maximal.") return rng = random.Random(opt.seed) if opt.seed else random.Random() total = 0 for elem in shapes: total += self.pack_shape(elem, min_radius, max_radius, gap, margin, rng) if total == 0: inkex.errormsg("Aucun cercle n'a pu être placé. Réduisez le rayon " "minimal, l'écart ou la marge, ou augmentez le " "nombre de tentatives.") @staticmethod def is_fillable(elem): """Une forme dont on peut extraire un contour ferme exploitable.""" return (isinstance(elem, inkex.ShapeElement) and not isinstance(elem, (inkex.Group, inkex.Layer, inkex.Line, inkex.Polyline))) def pack_shape(self, elem, min_radius, max_radius, gap, margin, rng): """Genere le groupe de cercles d'une forme. Renvoie leur nombre.""" opt = self.options try: path = elem.path.transform(elem.composed_transform()) except (AttributeError, TypeError): return 0 rings = flatten_path(path, opt.flatness) if not rings: return 0 circles = pack_circles(rings, min_radius, max_radius, gap, margin, opt.attempts, opt.max_circles, rng) if not circles: return 0 style = elem.specified_style() parent = elem.getparent() group = inkex.Group() group.label = "Circle packing" if parent is not None: # Les cercles sont calcules en coordonnees absolues du document : # on neutralise la transformation heritee du parent d'accueil. parent_transform = parent.composed_transform() if parent_transform != inkex.Transform(): group.transform = -parent_transform parent.insert(parent.index(elem) + 1, group) else: self.svg.add(group) for x, y, r in circles: circle = inkex.Circle(cx=str(x), cy=str(y), r=str(r)) circle.style = style group.add(circle) if not opt.keep_original: elem.delete() return len(circles) if __name__ == "__main__": CirclePacking().run()