inkscape.Ypatern/y_pattern.py
2026-09-24 10:39:18 +02:00

194 lines
7.1 KiB
Python

#!/usr/bin/env python3
# coding=utf-8
"""
Extension Inkscape : remplit une forme fermee avec le motif « Y fleche ».
Reseau triangulaire de Y identiques dont chaque bras se termine par un
chevron ; la pointe de chaque fleche se loge dans le V arriere du Y voisin.
Les motifs sont soit decoupes au contour de la forme, soit conserves entiers
uniquement s'ils tiennent a l'interieur.
La geometrie vit dans `pattern_core`, sans dependance a inkex.
"""
import math
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 pattern_core.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from pattern_core import (fill_pattern, parse_color, polylines_to_d, # noqa: E402
rings_bbox)
# Installe _() dans les builtins : catalogue « yarrowpattern » 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 pattern_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 YArrowPattern(inkex.EffectExtension):
"""Remplit chaque forme selectionnee avec le motif Y fleche."""
def add_arguments(self, pars):
pars.add_argument("--tab", default="motif")
pars.add_argument("--period", type=float, default=10.0)
pars.add_argument("--arm", type=float, default=83.0)
pars.add_argument("--barb", type=float, default=33.0)
pars.add_argument("--angle", type=float, default=0.0)
pars.add_argument("--stroke_width", type=float, default=0.5)
pars.add_argument("--stroke_color", default="3014898687")
pars.add_argument("--unit", default="mm")
pars.add_argument("--mode", default="clip")
pars.add_argument("--margin", type=float, default=0.0)
pars.add_argument("--anchor", default="document")
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(_("Select at least one closed shape "
"(path, rectangle, circle, ellipse, polygon)."))
return
def to_uu(value):
return self.svg.unittouu("{}{}".format(value, opt.unit))
period = to_uu(opt.period)
stroke_width = to_uu(opt.stroke_width)
margin = to_uu(opt.margin)
if period <= 0:
inkex.errormsg(_("The pattern period must be strictly positive."))
return
if not 0 < opt.arm < 100:
inkex.errormsg(_("The arm length must be between 0 and 100 % "
"of the period."))
return
color, opacity = parse_color(opt.stroke_color)
style = inkex.Style({
"fill": "none",
"stroke": color,
"stroke-opacity": str(opacity),
"stroke-width": str(stroke_width),
"stroke-linecap": "butt",
"stroke-linejoin": "miter",
"stroke-miterlimit": "4",
})
total = 0
for elem in shapes:
total += self.fill_shape(elem, period, stroke_width, margin, style)
if total == 0:
if opt.mode == "whole":
inkex.errormsg(_("No whole pattern fits inside the shape. "
"Reduce the period or the margin, or switch "
"to \"Clip patterns at the outline\"."))
else:
inkex.errormsg(_("No pattern could be placed: the shape is "
"probably smaller than the period."))
@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 fill_shape(self, elem, period, stroke_width, margin, style):
"""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
if opt.anchor == "shape":
xmin, ymin, xmax, ymax = rings_bbox(rings)
origin = ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0)
else:
origin = (0.0, 0.0)
motifs = fill_pattern(
rings, period,
arm_ratio=opt.arm / 100.0,
barb_ratio=opt.barb / 100.0,
angle=opt.angle,
mode=opt.mode,
# En mode « motifs entiers », le trait ne doit pas deborder :
# on ajoute sa demi-epaisseur a la marge demandee.
clearance=margin + stroke_width / 2.0,
origin=origin)
if not motifs:
return 0
parent = elem.getparent()
group = inkex.Group()
group.label = _("Y arrow pattern")
if parent is not None:
# Les motifs 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)
# Un chemin par motif, toutes ses polylignes en sous-chemins : peu
# d'objets dans le document, ordre serpentin conserve pour la decoupe.
for polylines in motifs:
node = inkex.PathElement()
node.set("d", polylines_to_d(polylines))
node.style = style
group.add(node)
if not opt.keep_original:
elem.delete()
return len(motifs)
if __name__ == "__main__":
YArrowPattern().run()