inkscape.circlePacking/circle_packing.py
2026-09-04 13:52:44 +02:00

159 lines
5.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).
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 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 cercles non chevauchants."""
def add_arguments(self, pars):
pars.add_argument("--tab", default="options")
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
shapes = []
for elem in self.svg.selection.values():
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
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)
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 pack_shape(self, elem, min_radius, max_radius, gap, margin, rng):
"""Genere le groupe de cercles 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:
circle = inkex.Circle(cx=str(x), cy=str(y), r=str(r))
circle.style = style
group.add(circle)
if not opt.keep_original:
elem.delete()
return len(circles)
if __name__ == "__main__":
CirclePacking().run()