#!/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). Le motif depose peut etre un cercle (defaut) ou une forme quelconque : dans ce cas le motif est le dernier objet selectionne, mis a l'echelle pour tenir dans le disque calcule et oriente au hasard. 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 bounding_circle, 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 motifs non chevauchants.""" def add_arguments(self, pars): pars.add_argument("--tab", default="options") pars.add_argument("--shape_mode", default="circle") 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 # `selection` conserve l'ordre de selection d'Inkscape (et non l'ordre # du document, accessible par `rendering_order()`) : le motif est donc # simplement le dernier element de la liste. selected = list(self.svg.selection.values()) motif = None if opt.shape_mode == "custom": if len(selected) < 2: inkex.errormsg( "Mode « forme personnalisée » : sélectionnez la ou les " "formes à remplir, puis ajoutez le motif à la sélection " "en dernier (Maj+clic).") return motif = selected[-1] selected = selected[:-1] shapes = [] for elem in selected: 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 motif_geometry = None if motif is not None: motif_geometry = self.build_motif(motif) if motif_geometry is None: inkex.errormsg("Le motif sélectionné ne fournit aucun contour " "exploitable.") 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, motif_geometry) 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 build_motif(self, elem): """Geometrie du motif : (path absolu, cx, cy, rayon circonscrit, style). Renvoie None si le motif n'a pas de contour exploitable. """ try: path = elem.path.transform(elem.composed_transform()) except (AttributeError, TypeError): return None rings = flatten_path(path, self.options.flatness) circle = bounding_circle(rings) if rings else None if circle is None or circle[2] <= 0: return None cx, cy, radius = circle return path, cx, cy, radius, elem.specified_style() def pack_shape(self, elem, min_radius, max_radius, gap, margin, rng, motif_geometry=None): """Genere le groupe de motifs 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: if motif_geometry is None: node = inkex.Circle(cx=str(x), cy=str(y), r=str(r)) node.style = style else: node = self.place_motif(motif_geometry, x, y, r, rng) group.add(node) if not opt.keep_original: elem.delete() return len(circles) @staticmethod def place_motif(motif_geometry, x, y, radius, rng): """Copie du motif inscrite dans le disque (x, y, radius), au hasard. La transformation est appliquee aux donnees du chemin plutot que posee en attribut : le SVG produit reste exploitable tel quel par les logiciels de decoupe. """ path, cx, cy, motif_radius, style = motif_geometry transform = inkex.Transform() transform.add_translate(x, y) transform.add_rotate(rng.uniform(0.0, 360.0)) transform.add_scale(radius / motif_radius) transform.add_translate(-cx, -cy) node = inkex.PathElement() node.path = path.transform(transform) node.style = style return node if __name__ == "__main__": CirclePacking().run()