#!/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 from inkex.localization import localize # 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 # Installe _() dans les builtins : catalogue « circlepacking » de la langue # de l'interface (Inkscape transmet domaine et dossier par l'environnement), # textes anglais d'origine sinon. localize() 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(_( "Custom shape mode: select the shape(s) to fill, then " "add the motif to the selection last (Shift+click).")) 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(_("Select at least one closed shape " "(path, rectangle, circle, ellipse, polygon).")) return motif_geometry = None if motif is not None: motif_geometry = self.build_motif(motif) if motif_geometry is None: inkex.errormsg(_("The selected motif has no usable outline. " "Convert it to a path, or pick another " "object as the motif.")) 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(_("The radii must be strictly positive.")) return if min_radius > max_radius: inkex.errormsg(_("The minimum radius must be smaller than the " "maximum radius.")) 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(_("No circle could be placed. Reduce the minimum " "radius, the gap or the margin, or increase the " "number of attempts.")) @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()