238 lines
8.7 KiB
Python
238 lines
8.7 KiB
Python
#!/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()
|