#!/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 from inkex.units import convert_unit # 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 use_system_language(): """Renseigne LANGUAGE avec la langue du systeme si rien ne la fixe. Sous Windows, Inkscape regle sur « langue du systeme » ne transmet aucune variable de langue au script : gettext retomberait sur l'anglais alors que la boite de dialogue, traduite par Inkscape lui-meme, est dans la langue de l'utilisateur. """ if any(os.environ.get(name) for name in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG")): return try: import ctypes import locale language = locale.windows_locale.get( ctypes.windll.kernel32.GetUserDefaultUILanguage()) except Exception: # pylint: disable=broad-except return # hors Windows : LANG est toujours defini if language: os.environ["LANGUAGE"] = language # 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. use_system_language() localize() def uu_per_mm(svg): """Unites utilisateur par millimetre, comme Inkscape les affiche. svg.unittouu() devine l'unite du document par arrondi a l'unite connue la plus proche : il se trompe des que l'echelle n'est pas standard (page redimensionnee). On prend le rapport reel page / viewBox ; Inkscape applique une echelle uniforme (preserveAspectRatio « meet »), soit la plus petite des deux. """ try: vb_w, vb_h = svg.viewbox_width, svg.viewbox_height page_w = svg.to_dimensional(svg.viewport_width, "mm") page_h = svg.to_dimensional(svg.viewport_height, "mm") except AttributeError: # inkex anterieur a 1.2 return svg.unittouu("1mm") if not (vb_w and vb_h and page_w and page_h): return svg.unittouu("1mm") return 1 / min(page_w / vb_w, page_h / vb_h) def length_to_uu(svg, value, unit="mm"): """Longueur reelle (valeur + unite) -> unites utilisateur du document.""" return convert_unit("{}{}".format(value, unit), "mm") * uu_per_mm(svg) 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 length_to_uu(self.svg, 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()