inkscape.voronoi/voronoi_fill.py
2026-09-30 22:23:25 +02:00

166 lines
6.9 KiB
Python

#!/usr/bin/env python3
# coding=utf-8
"""
Extension Inkscape : remplit une forme avec un motif de Voronoi separe par un
filet de largeur parametrable.
Des germes (aleatoires, Poisson ou grille hexagonale bruitee) sont semes sur
la forme ; chaque cellule de Voronoi est retrecie de la moitie du filet de
chaque cote, puis decoupee par la forme. Le resultat est soit le filet (un
seul chemin pair-impair : la forme percee des cellules), soit les cellules.
La logique vit dans `voronoi_core`, sans dependance a inkex.
"""
import os
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 voronoi_core.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from voronoi_core import ( # noqa: E402
FillError, fill_shape, net_rings, parse_color, rings_to_d)
# Installe _() dans les builtins : catalogue « voronoi_fill » de la langue
# de l'interface (Inkscape transmet domaine et dossier par l'environnement),
# textes anglais d'origine sinon.
localize()
# Formes acceptees ; les groupes sont explores, le texte doit etre converti.
SHAPES = (inkex.PathElement, inkex.Rectangle, inkex.Circle, inkex.Ellipse,
inkex.Polygon, inkex.Polyline)
class VoronoiFill(inkex.EffectExtension):
"""Remplit chaque forme selectionnee d'un pavage de Voronoi a filet."""
def add_arguments(self, pars):
# Un argument par <param> du .inx, memes noms et memes valeurs par defaut.
pars.add_argument("--tab", default="pattern")
pars.add_argument("--distribution", default="poisson")
pars.add_argument("--cell_size", type=float, default=10.0)
pars.add_argument("--irregularity", type=float, default=30.0)
pars.add_argument("--seed", type=int, default=1)
pars.add_argument("--unit", default="mm")
pars.add_argument("--net_width", type=float, default=1.0)
pars.add_argument("--border", type=inkex.Boolean, default=True)
pars.add_argument("--corner_radius", type=float, default=0.0)
pars.add_argument("--roundness", type=float, default=0.0)
pars.add_argument("--result", default="net")
pars.add_argument("--net_color", default="255")
pars.add_argument("--cell_color", default="3014898687")
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, SHAPES):
shapes.append(elem)
elif isinstance(elem, inkex.Group):
shapes.extend(d for d in elem.descendants() if isinstance(d, SHAPES))
if not shapes:
inkex.errormsg(_("Select at least one closed shape (path, rectangle, "
"ellipse, polygon). Convert text to a path first."))
return
def length(value):
return self.svg.unittouu("{}{}".format(value, opt.unit))
cell_size = length(opt.cell_size)
net_width = length(opt.net_width)
corner_radius = length(opt.corner_radius)
if cell_size <= 0:
inkex.errormsg(_("The cell size must be strictly positive."))
return
if net_width >= cell_size:
inkex.errormsg(_("The net width must be smaller than the cell size: "
"reduce the net width or enlarge the cells."))
return
net_color, net_opacity = parse_color(opt.net_color, ("#000000", 1.0))
cell_color, cell_opacity = parse_color(opt.cell_color)
self.net_style = inkex.Style({
"fill": net_color, "fill-opacity": str(net_opacity),
"fill-rule": "evenodd", "stroke": "none"})
self.cell_style = inkex.Style({
"fill": cell_color, "fill-opacity": str(cell_opacity),
"fill-rule": "evenodd", "stroke": "none"})
# Tolerance d'aplatissement des courbes : fine devant le filet et les
# cellules, pour que le cadre garde une largeur reguliere.
tolerance = max(min(net_width / 4.0, cell_size / 50.0), cell_size / 2000.0)
for elem in shapes:
rings = self.flatten(elem, tolerance)
try:
cells = fill_shape(
rings, cell_size, net_width, distribution=opt.distribution,
irregularity=opt.irregularity, seed=opt.seed, border=opt.border,
corner_radius=corner_radius,
roundness=max(0.0, min(opt.roundness, 100.0)) / 100.0,
tolerance=tolerance)
except FillError as error:
if error.code == "too_many_cells":
inkex.errormsg(_("Too many cells ({}): increase the cell size "
"or fill a smaller shape.").format(error.count))
else:
inkex.errormsg(_("Invalid settings: sizes must be positive."))
return
if not cells:
inkex.errormsg(_("No cell fits in the shape: reduce the cell size, "
"the net width or the corner radius."))
continue
self.write(elem, rings, cells)
@staticmethod
def flatten(elem, tolerance):
"""Contour de l'element en coordonnees absolues, aplati en anneaux."""
path = elem.path.transform(elem.composed_transform())
csp = path.to_superpath()
bezier.cspsubdiv(csp, tolerance)
return [[(point[1][0], point[1][1]) for point in sub] for sub in csp]
def write(self, elem, rings, cells):
"""Groupe resultat insere juste au-dessus de l'element source."""
opt = self.options
parent = elem.getparent()
group = inkex.Group()
group.label = _("Voronoi fill")
if parent is not None:
# Resultat calcule 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)
if opt.result in ("cells", "both"):
for cell in cells:
node = inkex.PathElement()
node.set("d", rings_to_d(cell))
node.style = self.cell_style
node.label = _("Cell")
group.add(node)
if opt.result in ("net", "both"):
node = inkex.PathElement()
node.set("d", rings_to_d(net_rings(rings, cells)))
node.style = self.net_style
node.label = _("Net")
group.add(node)
if not opt.keep_original:
elem.delete()
if __name__ == "__main__":
VoronoiFill().run()