inkscape.circlePacking/circle_packing.py
2026-10-01 11:57:58 +02:00

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()